8 Commits
109 changed files with 9465 additions and 1267 deletions

No files matched your search

+5
View File
@@ -10,3 +10,8 @@ slice/*
__pycache__/
*.pyc
_tmp_*
%s
# gebaute .deb (Skript legt sie hier ab)
packaging/dist/*
!packaging/dist/.gitkeep
+111
View File
@@ -4,6 +4,116 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
## [Unreleased]
## [3.7.0] – 2026-11-02
### Neu
- **Treiber:** Moonraker bleibt der Weg für Kobra 3 und S1 (Rinkhals, kein Firmware-Restart). Kobra 4 spricht die Stock-Firmware über den LAN-Broker (Info, ACE-Plätze, Upload auf `fileUploadurl`, Start per MQTT). Die laufende Farm auf dem Pi bleibt auf Moonraker.
- **Marlin:** Eigener Dienst hält die USB-Leitung. Grotti startet ihn selbst, sobald ein i3 oder Tenlog eingetragen ist, und beendet ihn nicht. Läuft er schon, bleibt er über einen Neustart von Grotti hinweg. Start pro Auftrag als Strom oder SD-Karte (`marlin_mode`: `stream` oder `sd`). Anycubic i3 ein Kopf, Tenlog zwei Köpfe (T0/T1, auf der Wand beide Temperaturen). Kein ACE, kein Heiz-Panel. Auf dem Pi liegt der Prozess in `grotti-marlin.service`.
- **Problem melden:** Knopf in der Kopfzeile. Kopie lokal für den Admin der Installation, dazu Versand an den Empfänger auf git.gundel.dev (Issue). Token nur dort.
- **Etiketten:** Papierformat in den Einstellungen (Standard 70×40 mm, Presets, `@page`).
### Geändert
- **Senden:** Upload und Start laufen über den Treiber (Moonraker-Vorbereitung unverändert). Hand-Abbruch in der Warteschlange stoppt Marlin und Kobra 4 ebenfalls.
- **Ideen:** API und Store entfernt; `/ideen` bleibt Weiterleitung auf Aufträge. Tabelle `ideas` bleibt.
- **Dateien:** Beim Wechsel der Datei bleibt der Drucker nur, wenn Familie und Rolle passen — sonst der passende freie. Orca-Upload und Watch-Kopie derselben Datei werden zusammengelegt (`watch_dir_local` in `orca-senden.json`).
- **Live-UI:** Drucker-Ereignisse zeichnen Wand und Zieldrucker nur noch einmal und nur bei geändertem Markup; Karten werden nicht mehr bei jedem Tick umgehängt.
- **Dateien / Wand / Material / Einstellungen:** Familien Kobra 4, i3 und Tenlog. Bettnetz nur bei Moonraker, Strom/SD bei Marlin, Mainsail nur bei Moonraker, Fehler löschen über den Treiber, Platzwahl nach Drucker (ACE / Köpfe / am Drucker), Host-Feld folgt der Familie.
## [3.6.1] – 2026-11-01
### Behoben
- **Abbruch fehlt nach Neustart:** Der History-Abgleich hat den Live-Lauf übernommen und gelöscht, das Ereignis „Druck abgebrochen“ zeigte noch auf diese Zeile. Der Fremdschlüssel hat den ganzen Watcher-Tick zurückgerollt, der Abbruch kam nie ins Protokoll, und der neue Druck hat dieselbe Zeile weiterbenutzt. Das Ereignis hängt sich an den überlebenden Lauf. Wechselt die Datei, wird der offene Lauf geschlossen statt umbenannt. Ein Live-Lauf einer anderen Datei wird nicht mehr übernommen.
- **Spulenwechsel:** Nach Filamentpause kein vorzeitiger Spulenabzug mehr (`watcher_resume`). Beim Zurücknehmen eines Abzugs wird der letzte Abschnitt wieder geöffnet; der Messwert wird anteilig nach mm verteilt. Beim Einlegen während des Drucks entsteht der erste Abschnitt auch dann (0→jetzt), wenn die alte Rolle schon im Regal ist oder ACE ohne Start-Abschnitt lief. Fallback slot-genau (Platz / ACE-Map / letzter Abschnitt). ACE-Start öffnet Abschnitte für gemappte Plätze mit Spule — ohne Wechsel bleibt Anteil-Split.
### Geändert
- **systemd:** `TimeoutStopSec=20` und `KillMode=mixed`, damit ein hängender Stopp nicht 90 s blockiert.
- **Orca:** Anleitung und Empfehlung zeigen auf Grotti am Pi (`http://10.10.1.41:8770/`), nicht mehr lokal.
- **Dateien:** Unveränderte Watch-Dateien werden beim Sync nur per Größe/mtime geprüft, nicht erneut geöffnet. `ace_trailer` liegt in der Datenbank; die Dateiliste öffnet die Datei nur noch einmal, wenn der Wert fehlt. Wand zeigt Drucker sofort, die Bibliothek lädt im Hintergrund. Pi-Paket: `http_threads` 8.
- **3D-Pfad:** Ungespeicherte Bahnen werden nacheinander gebaut, nicht parallel. `http_threads` begrenzt die Request-Threads. Sonst liest die Vorschau mehrere G-codes gleichzeitig und füllt auf einem Pi mit 2 GB den Speicher.
- **UI:** Wärmeres Orange-Licht (App und Login), Warten-Status in Amber statt Knopf-Orange, Druckerwand mit heller Kartenkante, größerer Prozentzahl und atmendem Druck-Streifen.
## [3.6.0] – 2026-10-25
### Neu
- **Projekte:** Kennzahlen (Läufe, Zeit, Filament, Kosten), Tab Läufe, Einreihen / Alle einreihen, Filter Aktiv/Fertig, Deep-Link `#id`. API: `GET …/laeufe`, `POST …/einreihen`.
- **Wand-Cockpit:** Block „Als Nächstes“ — Nochmal, Queue-Hinweis, passende Dateien (Start oder Anpassen unter Dateien).
- **Querverlinkung:** Datei/Projekt/Spule/Auftrag/Drucker verlinkt in Protokoll, Bericht, Warteschlange, Aufträge, Material, Statistik (Projektfilter), Dateien.
- **API für Apps:** `POST /auth/token`, QR bei Token-Erzeugung, Server-Filter für Dateien/Queue/Spulen (`limit`/`offset`, `X-Total-Count`), `?token=` für Datei-Vorschau und Assets, `/version` mit `api`/`features`, Aktions-Alias `resume`/`cancel`. Doku: `docs/internal/API.md`, `openapi.yaml`.
- **Pi:** Umzugsanleitung Windows→Pi, Skript `scripts/pfade_umziehen.py` im `.deb`.
- **Protokoll Verbinden:** Verdachtspaare nach Runout manuell zusammenlegen (`POST /laeufe/<id>/verbinden`). Liste zeigt Rollenwechsel, mehrere Spulen und Pausendauer.
- **Reparatur:** `scripts/laeufe_reparieren.py` findet Alt-Paare und verwaiste Live-Läufe (Probelauf standardmäßig). Orphan-Übernahme nur bei gleicher Datei und wenn der Live-Lauf wirklich verschwindet; kein Host-Fallback auf fremde offene Drucke.
### Geändert
- **Projekte / Aufträge:** Liste zuerst (volle Breite); Formular und Detail nur bei Neu, Auswahl oder Bearbeiten — wie Material. Aufträge: Positionen ohne `prompt()`, Kopf-Button „Neuer Auftrag“.
- **Layout:** `.edit-split.is-idle`, Split/Panel ab 1080px einspaltig; Pfad-Ellipsis in Tabellen; Protokoll-Hilfe einklappbar; Queue-Aktionen mit Textlabels; Jetzt-Seite zeigt „Alles erledigt“; Aktivität Drucker-Filter als Select; Statistik-Filter unter „Weitere Filter“; Nav Aufträge = Clipboard-Icon; Mobile-Mehr gruppiert.
- **Material-Routing:** PLA ist PLA. Meta, Matte, Silk und die Marke halten die Queue nicht mehr an; liegt die genannte Sorte am Drucker, wird sie weiter bevorzugt. Ein anderer Typ (PETG statt PLA) bleibt ein Wartegrund.
- **mcp** auf `2.0.0` gepinnt.
- **Lauf-Identität:** Zusammenlegen nur mit Beweis (`print_duration` fortgesetzt + Lücke ≤ 5 s, oder Watcher „druckt weiter“). Leere History-Hüllen werden nicht gespeichert. `sync_history` unter Advisory-Lock; Abzug atomar. Doku: `docs/internal/Lauf-Identitaet.md`.
- **Queue `print_active`:** Nur `complete` = fertig. `cancelled`/`standby` werden entprellt; echter Abbruch heißt „Druck abgebrochen“, nicht „Fertig.“. Kein Auto-Retry nach Abbruch. Worker respektiert Abbruch während Upload. `print_queue.run_id` + höchstens ein `running` pro Host.
- **Wand Runout:** Hinweis nennt ACE-Platz und verlinkt Material mit vorausgefülltem Drucker/Platz.
- **Live-UI:** SSE `queue`/`drucker` aktualisiert Warteschlange und Protokoll.
### Behoben
- **Legacy-Kameras-Seite:** totes Template entfernt (Route leitete schon auf die Wand um).
- **Laufende Charge bleibt am History-Job:** Kommt die Moonraker-Job-ID, übernimmt dieser Lauf die Nummer. Sie fällt nicht weg, nur weil der Druck noch läuft.
- **Alte History-Jobs verbrauchen die Charge von heute:** Eine Filament-Pause öffnet eine zweite Job-ID. Bleibt die ohne Ende und ohne Dauer in der History stehen, hing Grotti sie als neuen Druck an den heutigen Tag und buchte das Filament noch einmal ab. Die Job-ID bleibt am ursprünglichen Lauf, eine leere Hülle überschreibt ihn nicht, und die Charge trägt das Startdatum des Drucks.
- **Kontaktverlust spaltet den Druck:** Zwei fehlende Polls melden den Drucker offline, lassen den Lauf aber offen und ziehen nichts ab. Dieselbe Charge läuft weiter, wenn derselbe Auftrag wieder da ist. Erst fertig, Abbruch, Fehler oder ein anderer Auftrag schließt den Lauf.
- **Queue bleibt auf „Druckstart kann eine Minute dauern“, während der Drucker längst druckt:** Der Abschluss hat `await_print` erst geprüft, wenn der Drucker wieder frei war, und den laufenden Druck dann als Start-Timeout gewertet.
- **Strombericht ohne Düse/Bett nach Pause:** Beim Zusammenlegen zweier History-Jobs bleiben Temperatur- und ED-Proben am Lauf. Vorher wurden die Proben des Folgejobs gelöscht, der Bericht fiel auf die Grundlast zurück.
- **Filament-Pause spaltet das Protokoll:** Setzt Anycubic nach dem Fortsetzen eine neue History-Job-ID, bleibt es ein Lauf. Der Abbruch-Teil wird nicht noch einmal als ganzer Druck abgezogen.
- **Materialwechsel öffnet trotzdem einen zweiten Auftrag:** Moonraker liefert die History neueste zuerst. Die Fortsetzung kam vor dem abgebrochenen Teil an die Reihe, fand keinen Vorgänger und wurde ein eigener Lauf; der alte Teil wurde als Abbruch abgezogen. Der Abgleich geht jetzt pro Drucker von alt nach neu. Eine eingeklappte Job-ID setzt den laufenden Druck nicht mehr auf „Abgebrochen“ zurück, und der zusammengelegte Lauf hat kein Ende, solange er druckt.
- **Fertig vor History bucht doppelt:** Live-Lauf wird geschlossen ohne Abzug; History bucht. Fallback nach 10 min ohne History.
- **Abbruch + schnelles Nochmal:** Frischer Neustart (Dauer nahe 0) wird nicht mehr als Filament-Fortsetzung zusammengelegt.
- **Alter Lauf auf der neuen Spule:** Der History-Abgleich zieht einen Druck nur von Rollen ab, die vor dem Druckende schon existierten. Die Rolle, die gerade im ACE liegt, bekommt keine alten Jobs.
- **Projektfilter leer neben „Wurzel“:** Ein gewähltes Projekt hebt den Ordnerfilter „Wurzel“ auf. Ein Ordner zeigt auch die Dateien in seinen Unterordnern.
## [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 +122,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).
+118 -82
View File
@@ -1,120 +1,156 @@
# 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.7.0-e85d04?style=flat-square" alt="Version 3.7.0"></a>
<img src="https://img.shields.io/badge/Python-3.11+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
<img src="https://img.shields.io/badge/PostgreSQL-14+-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
<img src="https://img.shields.io/badge/Moonraker-Klipper-2ecc71?style=flat-square" alt="Moonraker">
<img src="https://img.shields.io/badge/UI-Deutsch-111827?style=flat-square" alt="UI Deutsch">
<img src="https://img.shields.io/badge/Self--hosted-lokal-6b7280?style=flat-square" alt="Self-hosted">
</p>
Die Drucker laufen mit Rinkhals (Kobra 3, Kobra S1). Grotti spricht sie über Moonraker an.
<p align="center">
<a href="#features">Features</a> ·
<a href="#screenshots">Screenshots</a> ·
<a href="#schnellstart">Schnellstart</a> ·
<a href="#api">API</a> ·
<a href="CHANGELOG.md">Changelog</a> ·
<a href="docs/community/README.md">Community</a>
</p>
Aktuelle Version: siehe `grotti/version.py`, Änderungen in [CHANGELOG.md](CHANGELOG.md).
---
## Voraussetzungen
<p align="center">
<img src="docs/community/01-druckerwand.png" alt="Druckerwand" width="900">
</p>
- Python 3.11
- PostgreSQL (eine leere Datenbank `grotti` reicht, das Schema legt Grotti selbst an)
- Drucker im selben Netz mit Moonraker auf Port 80
## Features
## Einrichten
| | |
| --- | --- |
| **Druckerwand & Cockpit** | Live-Status, Temps, Pause/Cancel, Farm-ETA |
| **Warteschlange** | Priorität, „nächster freier“, Material-Match, ACE-Map |
| **Dateien & Projekte** | Slice-Watch, Senden, Umbenennen, Projektordner |
| **Kobra-3-Ablauf** | Druck → Abkühlen → Schieben → Tasten |
| **Material** | Spulen, Restgewicht, Bedarf, Chargen |
| **Aufträge & Statistik** | Kunden, Lieferschein, Kosten (€ Material+Strom) |
| **Betrieb** | Auth (Session/`grt_…`), Telegram/ntfy, PWA |
Stack: **Python · Flask · PostgreSQL · Moonraker** (Rinkhals auf Kobra 3 / S1).
## Screenshots
<p align="center">
<img src="docs/community/06-jetzt.png" alt="Jetzt-Liste" width="440">
&nbsp;
<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">
&nbsp;
<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">
&nbsp;
<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_…` (kein CSRF). Referenz: [`docs/internal/API.md`](docs/internal/API.md), OpenAPI-Skizze [`docs/internal/openapi.yaml`](docs/internal/openapi.yaml).
| Methode und Pfad | Zweck |
| --- | --- |
| `POST /auth/setup`, `/auth/login`, `/auth/logout` | Ersten Admin, Login, Logout |
| `POST /auth/setup`, `/auth/login`, `/auth/token`, `/auth/logout` | Admin, Session-Login, App-Token, Logout |
| `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) |
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE |
| `GET /version` | Version, `api`, `features` |
| `GET`/`PATCH /einstellungen`, `POST /telegram/test`, `/ntfy/test` | Config und Push-Probe |
| `GET /drucker?live=1` | Drucker mit Live-Zustand |
| `POST /drucker/<host>/<aktion>` | `pause`, `fortsetzen`/`resume`, `abbrechen`/`cancel` |
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE, `clear_error`, E-Stop |
| `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 (Filter `family`, `material`, `project_id`, …) |
| `POST /senden`, `GET`/`POST /druck-queue` | Senden und Warteschlange (`confirm`) |
| `GET`/`POST /projekte`, `…/einreihen`, Assets | Projekte |
| `GET /spulen`, `POST /spulen/<id>/einlegen` | Material |
| `GET /laeufe`, `/statistik`, `/ereignisse`, `/stream` | Protokoll, Statistik, SSE |
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>
+6
View File
@@ -7,6 +7,12 @@
"token": "",
"secret_key": "",
"fqdn": "",
"etikett": {
"width_mm": 70,
"height_mm": 40
},
"meldung_relay": "https://git.gundel.dev/hooks/grotti-meldung",
"log_level": "INFO",
"auto_queue": true,
"auto_retry": true,
"oauth": {
+4 -4
View File
@@ -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.
![ACE-Zuordnung beim Senden](../web/static/doku/ace-zuordnung.png)
@@ -16,7 +16,7 @@ Es gibt **kein freies Spulen-Dropdown** beim Start: Grotti nimmt die Rolle(n), d
Während Druck oder Pause die neue Spule unter Material dem Drucker zuweisen und speichern. Grotti schließt den Abschnitt der alten Rolle und öffnet einen neuen — im Druckbericht erscheinen dann zwei Spulenzeilen und Ereignisse. Nur Filament am Gerät laden ohne Zuweisung: Abzug und Protokoll bleiben bei der alten Rolle.
Wenn Moonraker den Druck nach Filament-Pause als neuen History-Job führt, übernimmt Grotti die neu zugewiesene Rolle für den fortgesetzten Teil (Send- und Lauf-Spule werden beim Einlegen aktualisiert).
Wenn Moonraker den Druck nach Filament-Pause als neuen History-Job führt, übernimmt Grotti die neu zugewiesene Rolle für den fortgesetzten Teil (Send- und Lauf-Spule werden beim Einlegen aktualisiert). Die beiden Job-IDs bleiben **ein** Protokoll-Lauf, wenn die Druckdauer fortgesetzt wird oder die Wand „druckt weiter“ gesehen hat.
Etiketten mit QR: [Material und mehr](Anleitung-Material.md).
@@ -30,7 +30,7 @@ Unter **Dateien → Optionen → ACE-Zuordnung**:
- Ohne passende Map schreibt der Drucker oft **„cannot parse the file“** und startet nicht
- Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit
Bei **Nächster passender** (kein Drucker gewählt) baut Grotti die Map erst beim Claim aus den Spulen am gewählten Drucker. Passt die Sorte nicht → Wartegrund in der Queue.
Bei **Nächster passender** (kein Drucker gewählt) baut Grotti die Map erst beim Claim aus den Spulen am gewählten Drucker. Passt der Typ nicht (PETG statt PLA) → Wartegrund in der Queue. Meta/Matte/Silk halten nicht an; die genannte Sorte wird nur bevorzugt.
Externe Spule / keine ACE-Slots: keine `.acm` nötig.
@@ -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. |
+1 -1
View File
@@ -28,7 +28,7 @@ Projekte mit eigenem Ordner: [Projekte](Anleitung-Projekte.md).
2. **Drucker** wählen — Kacheln zeigen Live-Zustand und eingelegte Rolle. Passt die Maschine nicht, ist die Kachel blass.
3. **Start:**
- **Dieser Drucker** — frei und Platte klar → sofort starten; sonst Warteschlange für diesen Drucker
- **Nächster passender** — Grotti wählt den nächsten freien Drucker mit passender Sorte (z. B. Sunlu PLA Meta, nicht Matte)
- **Nächster passender** — Grotti wählt den nächsten freien Drucker mit passendem Typ (PLA, PETG, …). Die genannte Sorte (Meta, Matte) wird bevorzugt, wenn sie liegt; sonst reicht derselbe Typ.
Auswahl merkt sich der Browser (`localStorage`).
+4 -2
View File
@@ -1,6 +1,6 @@
# Einrichtung
Stand **15.10.2026**. Grotti auf dem Haus-PC installieren und den Browser öffnen. Die Drucker brauchen vorher Moonraker (Rinkhals auf Kobra 3 und S1).
Stand **24.10.2026**. Grotti auf dem Haus-PC oder Raspberry Pi installieren und den Browser öffnen. Die Drucker brauchen vorher Moonraker (Rinkhals auf Kobra 3 und S1).
## Was du brauchst
@@ -56,8 +56,10 @@ Dann <http://127.0.0.1:8770/> öffnen. Beim ersten Start legst du den Admin-Benu
- Druckstart über die API nur mit `confirm: true` (Dateien-UI: ein Klick).
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
- Benutzer und API-Tokens unter Einstellungen. Für Zugriff aus dem Internet: Reverse Proxy mit TLS, nicht den Entwickler-Server öffentlich freigeben.
- Benutzer und API-Tokens unter Einstellungen. App-Kopplung: Token erzeugen und QR scannen, oder `POST /api/v1/auth/token` mit Benutzer/Passwort. Push aufs Handy: **ntfy** (kein FCM). API-Referenz: intern `docs/internal/API.md`.
- Für Zugriff aus dem Internet: Reverse Proxy mit TLS, nicht den Entwickler-Server öffentlich freigeben.
- `config.json` und `data/` bleiben lokal, nicht im Git.
- Raspberry Pi: [Installation auf dem Pi](Anleitung-Installation-Pi.md) (`.deb`, Umzug Windows→Pi).
## Weiter
+5 -1
View File
@@ -31,13 +31,17 @@ Pro Drucker: Name, Host, Familie, **gesperrt**, optional **Start-Zeitfenster**.
Öffentliche Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host aus dem aktuellen Request. Wichtig, wenn du Etiketten von einem Handy scannst, das nicht `127.0.0.1` kennt.
## Etiketten
Breite und Höhe in Millimetern für Spulen- und Drucketiketten (Browser-Druck). Standard 70×40 mm; Voreinstellungen z. B. 62×29 mm. Der QR wird auf flacheren Rollen kleiner. Liegt in `config.json` unter `etikett`.
## Telegram, ntfy und Alerts
Telegram: Bot-Token und Chat-ID, pro Ereignisart an/aus, Test-Knopf. **ntfy:** Server, Topic, optional Token — gleiche Meldungen, unabhängig schaltbar, ebenfalls mit Test. Ereignisse stehen auch in der Sidebar unter Alerts.
## Benutzer und API-Tokens
Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`). Ein Shared-`token` in der Config-Datei gilt nicht mehr.
Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`), dazu ein QR mit Server-URL und Token zum Scannen. Alternativ stellt die App `POST /api/v1/auth/token` mit Benutzer/Passwort aus. Ein Shared-`token` in der Config-Datei gilt nicht mehr.
MCP (optional): Token erzeugen, dann `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN`. Für nur Lesen: `GROTTI_MCP_READ_ONLY=1`.
+120
View File
@@ -0,0 +1,120 @@
# 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
```
## Umzug Windows → Pi
1. **Backup auf dem alten Rechner** (Einstellungen → Backup) oder `pg_dump` der PostgreSQL-Datenbank.
2. Auf dem Pi Grotti per `.deb` installieren (oben).
3. Backup einspielen (Einstellungen → Backup wiederherstellen) **oder** `pg_restore` in die DB `grotti`.
4. Slice-Ordner nach `/var/lib/grotti/slice` kopieren (rsync/scp).
5. Absolute Windows-Pfade in der DB umschreiben (Dry-Run zuerst):
```bash
sudo -u grotti /opt/grotti/venv/bin/python /opt/grotti/scripts/pfade_umziehen.py \
--database-url 'postgresql:///grotti?host=/var/run/postgresql' \
--from 'D:/dein/slice' --to /var/lib/grotti/slice
# prüfen, dann mit --apply
```
(Alternativ aus dem Repo-Clone mit derselben DB-URL.)
6. Unter Einstellungen: `watch_dir` = `/var/lib/grotti/slice`, ggf. `fqdn` auf die Pi-URL.
7. In Orca „Senden an Grotti“ auf `http://<pi-ip>:8770/` umstellen.
8. `sudo systemctl restart grotti` und unter **Dateien → Ordner neu lesen**.
## Last auf dem Pi
- Watcher und Toolpath-Cache liegen unter `/var/lib/grotti/`. Bei wenig Speicher Toolpath-Cache-Ordner (`data/toolpath`) gelegentlich leeren.
- Wenige SSE-Clients sind unkritisch; viele offene Browser-Tabs mit Kamera-Stream belasten CPU/Netz.
- Empfohlen: Pi 4/5 mit SSD oder schneller SD, nicht parallel schwere Builds auf demselben Gerät.
## 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).
- `.deb` auf dem Ziel-Pi bauen (`bash packaging/build-deb.sh`), damit arm64-Wheels (`psycopg`, `cryptography`) passen.
+2 -6
View File
@@ -26,9 +26,9 @@ Unter **Aufträge** Kunden, Positionen und Fortschritt. Dateien lassen sich eine
## Protokoll und Statistik
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Kosten-Aufschlüsselung Filament + Strom mit Tortendiagramm, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Kosten-Aufschlüsselung Filament + Strom mit Tortendiagramm, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option (nicht während der Druck noch läuft).
Nach Filament-Ende: neue Spule unter **Material** dem Drucker/ACE-Platz zuweisen (Ort setzen und speichern — das legt den Rollenwechsel im Lauf an), am Gerät laden, an der Wand **Weiter**. Nur physisch nachlegen reicht nicht. Die Zuweisung gilt auch, wenn Moonraker den Druck nach der Pause als neuen Job zählt.
Nach Filament-Ende: neue Spule unter **Material** dem Drucker/ACE-Platz zuweisen (Ort setzen und speichern — das legt den Rollenwechsel im Lauf an), am Gerät laden, an der Wand **Weiter**. Nur physisch nachlegen reicht nicht. Die Zuweisung gilt auch, wenn Moonraker den Druck nach der Pause als neuen Job zählt — Grotti hält das als **einen** Lauf (gleiche Charge). Im Protokoll siehst du bei Rollenwechsel die Spulennamen; falls doch zwei Zeilen stehen, **Läufe holen** oder **Verbinden**.
Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis.
@@ -44,10 +44,6 @@ Auf der **Druckerwand** erscheint je Drucker ein kleines Snapshot-Bild (ca. alle
Zeitstrahl: Starts, Schritte, Fertig, Fehler, Abbrüche, offline. Filter nach Drucker, Art, Zeitraum. Ungelesene Alerts sind markiert; in der Sidebar kannst du sie als gelesen setzen.
## Ideen
`/ideen` — Notizen und Ideen für später. Kein Druckfluss, nur Merkliste.
## Weiter
- [ACE und Spulen](Anleitung-ACE.md)
+9 -7
View File
@@ -1,23 +1,25 @@
# Orca an Grotti
Stand **18.10.2026**. Nach dem Export landet der G-code in der Bibliothek und Grotti öffnet sich als **eigenes Fenster** (Chrome/Edge-App auf dem Desktop). Drucker, ACE-Platz und Einreihen wählst du dort.
Stand **25.10.2026**. Nach dem Export landet der G-code in der Bibliothek und Grotti öffnet sich als **eigenes Fenster** (Chrome/Edge-App auf dem Desktop). Drucker, ACE-Platz und Einreihen wählst du dort. Ziel ist die Instanz auf dem Pi (`http://10.10.1.41:8770/`), nicht mehr lokal.
## Einmal einrichten
1. Grotti starten und im Browser anmelden (die Session bleibt für die Senden-Seite).
1. Grotti auf dem Pi erreichbar machen und im Browser anmelden (die Session bleibt für die Senden-Seite).
2. Unter **Einstellungen → API-Token** ein Token anlegen (Rolle Operator reicht). Den Wert `grt_…` nur einmal sichtbar.
3. Datei `data/orca-senden.json` anlegen (gleicher Ordner wie `config.json`):
3. Datei `data/orca-senden.json` anlegen (im Grotti-Repo unter Windows, gleicher Ordner wie lokal `config.json`):
```json
{
"url": "http://127.0.0.1:8770",
"url": "http://10.10.1.41:8770",
"token": "grt_…",
"browser": "app"
"browser": "app",
"watch_dir_local": "D:/Slice"
}
```
`url` ist die Adresse, unter der du Grotti im Browser öffnest.
`url` ist die Adresse des Pi (LAN-IP oder Hostname).
`browser`: `app` (Standard) = eigenes Desktop-Fenster; `tab` = klassischer Browser-Tab.
`watch_dir_local`: Windows-Pfad desselben Slice-Ordners, den Grotti auf dem Pi beobachtet. Ohne diesen Eintrag lädt das Skript jede Exportdatei zusätzlich hoch — in der Bibliothek erscheint sie dann doppelt (einmal Watch mit Projekt, einmal Upload).
4. Im **Prozessprofil** (Druckqualität, z. B. „0.20mm Standard“): **Sonstiges → Nachbearbeitungsskripte**, eine Zeile:
@@ -34,7 +36,7 @@ Damit unter **Gerät** die Grotti-Oberfläche erscheint:
1. Orca → Drucker-Dropdown → **Physikalischer Drucker** (Verbindung/Stecker-Symbol).
2. **Host-Typ:** z. B. `Octo/Klipper` (egal, solange du nicht „Drucken“ an den Host schickst).
3. **Hostname / API:** leer lassen — oder nur eine Dummy-Adresse. **Nicht** die Moonraker-IP eines Kobras eintragen.
4. **Device UI / Web-UI-URL:** `http://127.0.0.1:8770/` (Grotti muss laufen).
4. **Device UI / Web-UI-URL:** `http://10.10.1.41:8770/` (Grotti auf dem Pi).
5. Speichern → Reiter **Gerät** öffnen → einmal bei Grotti anmelden.
Wichtig: Den Orca-Button **Drucken / Upload zum Host** nicht nutzen — der spricht Moonraker/OctoPrint und umgeht ACE-Zuordnung in Grotti. Weiterhin **G-code exportieren** + Nachbearbeitungsskript.
+12 -4
View File
@@ -1,6 +1,6 @@
# Projekte
Stand **18.10.2026**. Arbeitsmappen unter `/projekte` — getrennt von Kunden-[Aufträgen](Anleitung-Auftraege.md).
Stand **24.10.2026**. Arbeitsmappen unter `/projekte` — getrennt von Kunden-[Aufträgen](Anleitung-Auftraege.md).
## Wozu
@@ -16,11 +16,19 @@ Ein **Projekt** bündelt Konstruktion und Druckdateien in einem Ordner unter dem
**Neues Projekt** — Name, Status, optional Ordner (Standard: `Projekte/<Kurzname>`). Mit gesetztem Slice-Ordner legt Grotti den Ordner an.
G-code und 3MF in diesem Ordner (oder dorthin [verschoben](Anleitung-Dateien.md)) werden dem Projekt zugeordnet und erscheinen in der Druck-Bibliothek.
Filter **Aktiv** / **Fertig** / **Alle** in der Liste. G-code und 3MF in diesem Ordner (oder dorthin [verschoben](Anleitung-Dateien.md)) werden dem Projekt zugeordnet und erscheinen in der Druck-Bibliothek.
## Weitere Dateien
## Detail
Im Projekt-Detail: Upload von STEP, STL, PDF, SolidWorks usw. — liegen im selben Ordner, erscheinen **nicht** in der Druck-Bibliothek. Download und Löschen pro Datei.
Im Projekt siehst du Kennzahlen (Läufe, Druckzeit, Filament, Kosten) und drei Tabs:
- **Druckdateien** — Link zur Bibliothek (`?file=` / `?project=`), **Einreihen** pro Datei oder **Alle einreihen** (Warteschlange, mit Bestätigung)
- **Läufe** — letzte Drucke mit Link zum Protokoll / Bericht
- **Weitere Dateien** — STEP, STL, PDF, SolidWorks usw. (nicht in der Druck-Bibliothek)
Wenn jede Datei mindestens einmal ok gedruckt wurde, schlägt Grotti vor, den Status auf **fertig** zu setzen.
Deep-Link: `/projekte#<id>`.
## Löschen
+4 -3
View File
@@ -1,6 +1,6 @@
# Druckerwand
Stand **18.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
Stand **24.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
![Druckerwand](../web/static/doku/wand-uebersicht.png)
@@ -19,7 +19,7 @@ Aktionen auf der Karte brauchen oft **zwei Klicks** (erster Klick „scharf“,
| Aktion | Wann |
| --- | --- |
| **Pause** / **Weiter** | Während des Drucks; nach Materialwechsel Spule in Grotti an den Drucker hängen, dann **Weiter** |
| **Pause** / **Weiter** | Während des Drucks; nach Materialwechsel gelber Hinweis mit ACE-Platz → Link zu Material (Drucker/Platz vorausgefüllt), Speichern, dann **Weiter**. Dieselbe Charge läuft weiter. |
| **Abbrechen** | Druck oder Pause |
| **Fehler löschen** | Zustand Fehler — räumt den hängenden Fehler, Heizung aus |
| **Platte geräumt** | Nach Fertig, wenn die Platte wieder frei ist |
@@ -39,7 +39,8 @@ Inhalt:
- Status, Temps inkl. Bauraum wo verfügbar, Lüfter
- **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole. Mit ACE zusätzlich Trockner (Temp, Dauer, Start/Stopp) und pro bestücktem Platz **an den Drucker** (Sorte und Farbe aus der Grotti-Spule)
- **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an
- **Verlauf** Temperatur und ED des aktuellen oder letzten Drucks; Link zum Druckbericht (dort Kosten: Filament + Grundlast/Düse/Bett)
- **Verlauf** Temperatur und ED des aktuellen oder letzten Drucks; Links zu Datei und Druckbericht (dort Kosten: Filament + Grundlast/Düse/Bett)
- **Als Nächstes** (nur wenn der Drucker frei und die Platte quittiert ist): **Nochmal** der letzten Datei, Hinweis zur Warteschlange, passende Dateien nach Familie/Material — Start mit Standardwerten oder **Anpassen…** unter Dateien
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
- **Bettnetz**-Heatmap, wenn vorhanden
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

+3 -1
View File
@@ -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).
---
+86
View File
@@ -0,0 +1,86 @@
# Grotti HTTP-API (`/api/v1`)
Stand für Android/App-Clients und Integrationen. Nutzer-Anleitungen bleiben unter `docs/Anleitung-*.md`.
## Auth
| Weg | Header / Cookie | CSRF |
| --- | --- | --- |
| Session | Cookie nach `POST /auth/login` | `X-CSRF-Token` bei mutierenden API-Calls |
| Bearer | `Authorization: Bearer grt_…` | nicht nötig |
**Token ausstellen**
- Eingeloggt: `POST /tokens` `{name, role?}` → Klartext einmalig in `token`
- Ohne Session (App): `POST /auth/token` `{username, password, device_name?, role?}` → `201` mit `token` + `user` (Rate-Limit wie Login)
Öffentlich ohne Login: `/auth/status`, `/auth/login`, `/auth/token`, `/auth/setup`, `/version`.
Rollen: `operator` ≤ `admin`. Admin-only u. a. Benutzer, Backup, volle Einstellungen, Drucker anlegen.
**Medien mit Query-Token** (nur GET): `?token=grt_…` für Kamera, Live-Vorschau, Datei-Vorschau (`/dateien/<id>/vorschau`), Projekt-Assets.
## Fehler
```json
{"error": "Text", "code": "kurzcode"}
```
HTTP-Status: 400/401/403/404/409/429/503. Bild-Endpunkte liefern bei fehlendem Bild ebenfalls dieses JSON (nicht nur leeren 404).
## Pagination
Listen akzeptieren `limit` / `offset` wo sinnvoll. Antwort bleibt ein **JSON-Array**; Gesamtzahl im Header `X-Total-Count`.
Ohne `limit` liefern `/dateien` und `/spulen` die volle Liste (Web-UI).
## Bestätigung
Mutierende Druckaktionen brauchen oft `"confirm": true`:
- `POST /senden`, `POST /druck-queue`, `POST /ablauf`
- `POST /projekte/<id>/einreihen`, `POST /auftraege/<id>/einreihen`
- `POST /drucker/<host>/abraeumen`
- `steuern` mit `emergency_stop`, `clear_error`, `exclude_object`
## Server-Info
`GET /version` → `{name, version, api: 1, features: [...]}`.
## SSE Live
`GET /stream` (Session-Cookie oder Bearer). Events:
| Event | Bedeutung |
| --- | --- |
| `ping` | Keepalive (~15 s), Payload `{}` |
| `drucker` | Live-Zustand der Drucker geändert |
| `queue` | Druckwarteschlange geändert |
| `senden` | Sende-Auftrag geändert |
| `ereignis` | Neues Ereignis / Aktivität |
| `aufgaben` | Offene Aufgaben (Platte …) |
Ohne Stream: Client pollt `/drucker?live=1`, `/jetzt`, `/druck-queue`.
## Push an Geräte
Grotti sendet **kein** FCM. Empfohlen: **ntfy** (Topic unter Einstellungen). Die App kann denselben Topic abonnieren; Secrets (ntfy-Token) bleiben serverseitig.
## Wichtige Ressourcen
| Bereich | Pfade |
| --- | --- |
| Drucker | `GET /drucker?live=1`, `POST /drucker/<host>/pause\|fortsetzen\|abbrechen` (Alias `resume`/`cancel`), `…/steuern`, `…/abraeumen` |
| Dateien | `GET /dateien?family=&material=&project_id=&host=&q=&limit=&offset=`, Upload multipart, `…/vorschau`, `…/pfad` |
| Senden / Queue | `POST /senden`, `GET /senden-zuletzt?host=&file_id=`, `GET\|POST /druck-queue?host=&status=`, `POST /ablauf` |
| Projekte | `GET\|POST /projekte`, `GET /projekte/<id>`, `…/laeufe`, `POST …/einreihen`, Assets |
| Aufträge | `GET\|POST /auftraege`, Positionen, `…/einreihen` |
| Material | `GET /spulen?aktiv=1`, Akte, Einlegen |
| Protokoll | `GET /laeufe`, `GET /laeufe/<id>`, `GET /statistik?project_id=` |
| Kamera | `GET /kamera?host=`, `…/stream` |
Pfadnamen sind überwiegend **deutsch**. Aktionen: deutsch + englische Alias für Pause/Resume/Cancel.
## MCP
`python -m grotti.mcp_server` nutzt dieselbe REST-API (Teilmenge). Siehe `docs/internal/MCP.md`.
-2
View File
@@ -73,8 +73,6 @@ Grotti-Schalter „Trockenlauf / PROBE statt z_push“ noch nicht gebaut — bis
## Backlog (später)
Ideen liegen in Grotti unter **Ideen** (Status „später“), nicht nur hier:
- **Abdeckungen gegen Verkanten** — Führung/Rutsche vorn, damit geschobene Teile nicht an Bettkante, Rahmen oder in der Kiste verkanten und den nächsten Lauf blockieren.
- **Kobra S1** — eigener Abräum-Ablauf (geschlossen, Tür).
+15
View File
@@ -134,8 +134,23 @@ Ohne Map: Rinkhals füllt oft Identity T0→Platz0 … — deshalb Grotti bei ei
| Slots anzeigen | Moonraker `mmu` (Rinkhals) | `mmu_ace.py` |
| Kein Firmware-Restart | abgelehnt | Rinkhals #32, `patch_klipper_restart` |
## History nach Filament-Pause
Runout, Rolle tauschen, Fortsetzen: Anycubic schließt den History-Job als `cancelled` und öffnet eine neue Job-ID. Beobachtet am 2026-10-25 auf Kobra 3 A1 (`000040` → `000041`):
- Der neue `start_time` liegt etwa 0,04 s nach dem `end_time` des alten.
- `print_duration` und `total_duration` sind an beiden Einträgen gleich und laufen weiter. Der Zähler beginnt nicht bei 0.
- `/server/history/list` liefert neueste zuerst. Grotti sortiert pro Drucker nach `start_time`, sonst kommt die Fortsetzung vor ihrem Vorgänger dran (`jobs.history_in_order`).
- Nach dem Zusammenlegen hängt die alte Job-ID in `run_jobs` am Lauf. `store.history_job_retired` überspringt sie, sonst schreibt sie `cancelled` zurück.
- Zusammenlegen nur mit Beweis: `print_duration` fortgesetzt und Lücke ≤ 5 s, oder Watcher `paused→printing` (`docs/internal/Lauf-Identitaet.md`).
## Offene Grenzen
- 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
+2 -2
View File
@@ -8,13 +8,13 @@ Jede Spule in Grotti bekommt automatisch eine `chargen_nr` der Form `S-YYYYMMDD-
Scannen: auf Material den Code tippen oder per USB-Handscanner (Tastatur + Enter). Lookup `GET /api/v1/spulen/by-code/<code>`. QR auf dem Etikett öffnet `/material?spool=<id>`.
Etikett: `/etikett/<id>`, Druck über den Browser. QR per lokalem `qrcode.min.js` (`web/static/`). Basis-URL für den QR kommt aus Einstellungen → **FQDN** (`config.json` Key `fqdn`); leer = Host der aktuellen Anfrage. Beispiel: `https://grotti.example.de/material?spool=12`.
Etikett: `/etikett/<id>`, Druck über den Browser. QR per lokalem `qrcode.min.js` (`web/static/`). Basis-URL für den QR kommt aus Einstellungen → **FQDN** (`config.json` Key `fqdn`); leer = Host der aktuellen Anfrage. Beispiel: `https://grotti.example.de/material?spool=12`. Format (Breite/Höhe, `@page`) aus Einstellungen → **Etiketten** (`config.json` Key `etikett`, Standard 70×40 mm).
## Druck-Chargen
`D-YYYYMMDD-0001` vergibt der Watcher am aktiven Lauf (History-Job, sonst Live). Die nächste Nummer zählt Läufe und schon vergebene Objekt-Etiketten, damit eine Charge nicht doppelt entsteht. `adopt_live_run` übernimmt die Nummer nur auf einen fertigen History-Lauf ohne eigene Charge. Objekt-Etiketten (`D-…-01`) wandern nur mit, wenn der Ziel-Lauf dieselbe Charge behält; sonst fallen sie mit dem Live-Lauf weg und werden unter der verbleibenden Charge neu vergeben.
Druck über `/etikett/lauf/<id>?mode=druck|objekt`. Auf dem Bogen: Charge, **Soll … g** (Slicer-Filamentmenge), Drucker, Material (Abzug oder Send/ACE/Lauf-Spule), QR 20 mm im 70‑mm-Bogen. Wand: Knopf **Etikett** bei Druck/Pause. Urteil Ausschuss ändert die Charge nicht — gedruckte Bögen verwerfen.
Druck über `/etikett/lauf/<id>?mode=druck|objekt`. Auf dem Bogen: Charge, **Soll … g** (Slicer-Filamentmenge), Drucker, Material (Abzug oder Send/ACE/Lauf-Spule), QR proportional zur Etikettenhöhe. Wand: Knopf **Etikett** bei Druck/Pause. Urteil Ausschuss ändert die Charge nicht — gedruckte Bögen verwerfen.
## 3D-Pfad
+33
View File
@@ -0,0 +1,33 @@
# Lauf-Identität: ein physischer Druck = ein Lauf
## Regel
Zusammengelegt wird nur mit **Beweis**, nicht nur wegen Zeit und Dateiname:
1. Der neue Moonraker-Job hat `print_duration` ≥ Dauer des Vorgängers − 30 s, und die Lücke `end→start` ist höchstens 5 s (Anycubic setzt `end_time` erst beim Fortsetzen).
2. Oder der Watcher hat `paused → printing` bei gleicher Datei gesehen (`watcher_resume`, `printers.active_run_id`).
Ein Neustart mit `print_duration` ≤ 60 s wird nie zusammengelegt.
Ein Live-Lauf wird nur übernommen, wenn der Dateiname übereinstimmt (eine noch namenlose Hülle zählt zum neuen Druck). Ein Abbruch-Ereignis auf eine bereits gelöschte Zeile rollt den Abgleich nicht zurück.
## Pfade
| Pfad | Datei |
| --- | --- |
| Beweis | `jobs.is_filament_resume` |
| Fold | `jobs.fold_filament_resume` |
| Abgleich | `jobs.sync_history` (Advisory-Lock) |
| Job-Aliase | `run_jobs` + `history_job_retired` |
| Leere Hülle | übersprungen in `_sync_one_job` |
| Live-Abzug | erst History; Fallback nach 10 min (`LIVE_DEDUCT_FALLBACK_S`) |
| Manuell | `POST /api/v1/laeufe/<id>/verbinden` |
## Queue
In `print_active` heißt nur `complete` fertig. `cancelled`/`standby` werden entprellt (`TERMINAL_CONFIRM_HITS`). Echter Abbruch → Status `cancelled`, nicht „Fertig.“.
## Reparatur
`scripts/laeufe_reparieren.py` (Probelauf standardmäßig, `--apply` nach Freigabe).
Verwaiste `live:`-Läufe nur an offenen History-Jobs **derselben Datei**; `adopt_live_run` greift geschlossene Live-Läufe nur ≤ 2 h.
+3 -1
View File
@@ -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
+195
View File
@@ -0,0 +1,195 @@
# Makros und G-code: Kobra S1 und Kobra 3
Stand **19.10.2026**. Interne Referenz — nicht unter `/doku`. Messwerte und Bettnetz-Story: `D:\Projekte\3DP\FDM\Wissen\Drucker\` (`Kobra S1.md`, `Kobra 3.md`). Stack: [Anycubic-Stack](Anycubic-Stack.md).
Quellen: `printer/gcode/help` (11.10. auf den Maschinen), GoKlipper/`extras_ace.go`, Rinkhals-RE `GOKLIPPER_ACE_GCODE_COMMANDS.md`, Grotti-Policy.
---
## Nicht tun
| Nicht | Warum | Stattdessen |
| --- | --- | --- |
| `FIRMWARE_RESTART` / `RESTART` | gklib Deadlock bis Power-Cycle (Rinkhals #32) | Display-Reboot oder Strom; hängenden Job: **Fehler löschen** (`SDCARD_RESET_FILE`) |
| `LEVIQ` / `LEVIQ2` aus Mainsail | Heizung + volle Nivellierfolge, fährt unerwartet | Am Display Auto-Level; Einzelschritte nur bewusst |
| `SAVE_CONFIG` aus Mainsail | schreibt u. a. Z-Offset mit | Dauerhaft nur gezielt / nach Display-Nivellieren |
| Nur Moonraker-Cancel | Anycubic-Task bleibt → Start-Code **10101** | Cancel + MQTT-stop (Grotti `abbrechen`) |
| Community-`ACE_SET_SLOT` / manuelles `G9111` als Grotti-Pfad | Grotti mappt über `.acm` + MQTT | Map in der App; Slice-`G9111` im Start-G-code belassen |
| `minimum_cruise_ratio` auf **0** (S1) | Moonraker hängt auf dieser Firmware | bei **0,05** belassen |
| `G28 Y` mit Teilen auf dem Bett (K3) | Sensorless-Home nach **vorn** über die Teile | Bett leeren; Abräumen ohne `G28 Z` |
| Blind `FEED` / `FEED_FILAMENT` ohne Unwind | Blockade bis Neustart (RE-Doku) | Unload/Unwind-Sequenz einhalten |
| `SET_KINEMATIC_POSITION` mit erfundener Z nach Stepper-aus | Portal sackt → Null daneben, Probe „No trigger“ oder Crash | echte letzte XYZ; oder Bett leer + `G28` |
| Freie G-code-Konsole in Grotti für Experimente | Policy | nur bekannte, bestätigte Scripts |
| Abräumen-Makro in `printer.cfg` pflegen | Rinkhals schreibt Config neu | Grotti schickt Scripts per Moonraker |
---
## Vanilla-Klipper-Alltag (beide Typen)
Befehl → was er macht. Anycubic-Hinweis nur wo nötig.
### Homes und Bewegung
| Befehl | Funktion |
| --- | --- |
| `G28` | Homed die genannten Achsen (ohne Argument: alle). **K3:** Y-Home fährt nach vorn — Bett muss leer sein. |
| `G0` / `G1` | Lineare Bewegung / Extrusion. Absolute/relative Modi wie üblich (`G90`/`G91`, `M82`/`M83`). |
| `M400` | Wartet, bis alle Moves fertig sind. |
| `SET_VELOCITY_LIMIT` | Limits für Speed/Accel zur Laufzeit. |
| `SET_KINEMATIC_POSITION` | Setzt die interne Position ohne zu fahren. Nach `M84`/Leerlauf riskant — siehe „Nicht tun“. |
| `SET_IDLE_TIMEOUT` | Idle-Zeit bis Stepper aus. Abräumen hält Idle bewusst länger. |
| `M84` / `M18` | Stepper aus → Achsen unhomed. Slice-Ende K3 macht das oft; Abräumen entfernt es beim Upload. |
### Heizen
| Befehl | Funktion |
| --- | --- |
| `M104` / `M109` | Düse Soll / warten bis erreicht. |
| `M140` / `M190` | Bett Soll / warten. |
| `TURN_OFF_HEATERS` | Düse und Bett aus. |
| `PID_CALIBRATE` | PID für Extruder oder Bett kalibrieren. Danach oft `SAVE_CONFIG` — auf diesen Maschinen vorsichtig. |
| `M106` / `M107` | Teilkühlung an / aus. |
### Drucklauf
| Befehl | Funktion |
| --- | --- |
| `PAUSE` / `RESUME` / `CANCEL_PRINT` | Rinkhals-Makros um Pause/Weiter/Abbruch. Moonraker-Cancel allein räumt den Anycubic-Task nicht. |
| `SDCARD_RESET_FILE` | Löscht hängenden virtual-sdcard-/Druckstatus („Fehler löschen“ in Grotti). |
| `PRINT_STATS` | Status des laufenden/letzten Jobs (Objekt auch über Moonraker). |
### Bettnetz und Probe
| Befehl | Funktion |
| --- | --- |
| `BED_MESH_CALIBRATE` | Tastet das Netz und schaltet es aktiv. |
| `BED_MESH_OUTPUT` | Gibt die Punkte aus, ohne zu fahren. |
| `BED_MESH_MAP` | Schreibt das interpolierte Netz in die Konsole. |
| `BED_MESH_CLEAR` | Schaltet die Korrektur aus. |
| `BED_MESH_OFFSET` | Verschiebt das Netz in X/Y. |
| `BED_MESH_PROFILE` | `LOAD` / `SAVE` / `REMOVE` für Profile. Dauerhaft erst mit `SAVE_CONFIG`. |
| `PROBE` | Ein Tastpunkt an der aktuellen Position. |
| `PROBE_ACCURACY` | Mehrfach tasten, Streuung ausgeben. |
| `PROBE_CALIBRATE` | Sensor kalibrieren (nicht der Z-Regler am Display). |
| `Z_OFFSET_APPLY_PROBE` | Laufenden G-code-Offset in `probe.z_offset` schreiben. |
| `SET_GCODE_OFFSET` | Offset nur für den aktuellen Auftrag. |
| `SAVE_CONFIG` | Schreibt Config dauerhaft — **nicht** blind aus Mainsail. |
### Diagnose
| Befehl | Funktion |
| --- | --- |
| `QUERY_ENDSTOPS` / `M119` | Endstop-Zustände. |
| `GET_POSITION` / `M114` | Toolhead-/Stepper-Position. |
| `QUERY_ADC` | ADC-Werte (z. B. Thermistoren). |
| `DUMP_TMC` | TMC-Register (Stepper nennen). |
### Slice-Start Anycubic
| Befehl | Funktion |
| --- | --- |
| `G9111` | Vorheizen aus dem Slicer: typisch `bedTemp=…` und `extruderTemp=…`. Steht im Maschinen-Start-G-code. Grotti ersetzt das nicht und startet auch nicht darüber. |
---
## ACE (gemeinsam)
Zwei Namenswelten:
1. **Open-Source** GoKlipper `extras_ace.go` (BunnyACE-Stil): `ACE_CHANGE_TOOL`, `ACE_FEED`, `ACE_RETRACT`, `ACE_SET_SLOT`, `ACE_QUERY_SLOTS`, `ACE_START_DRYING` / `ACE_STOP_DRYING`, Feed-Assist, Endless Spool, …
2. **Ausgeliefertes Binary** (Rinkhals-RE): u. a. `FEED_FILAMENT`, `UNWIND_FILAMENT`, `UNWIND_ALL_FILAMENT`, `SET_CURRENT_FILAMENT`, `ACE_INFO`, `ACE_STATUS`, Cutter-/Pipe-Befehle.
Grotti nutzt **keine** Community-Makros `ACE_SET_SLOT` / manuelles Tool-Mapping per G-code. Zuordnung: Sidecar `.acm` + MQTT `ams_settings` ([Senden-Queue](Senden-Queue.md), [ACE-Live](ACE-Live.md)). Slots anzeigen: Moonraker-Objekt `mmu` (Rinkhals). Trocknen: Moonraker `POST /server/filament_hub/start_drying` (nicht zwingend der G-code).
| Befehl (Auswahl) | Funktion |
| --- | --- |
| `ACE_CHANGE_TOOL` / Tool-Macros `T0`… | Werkzeugwechsel über ACE. |
| `ACE_FEED` / `FEED_FILAMENT` | Filament vom Gate zur Düse laden. **Vorher unloaden.** |
| `ACE_RETRACT` / `UNWIND_FILAMENT` | Zurückziehen / parkieren. |
| `ACE_SET_SLOT` | Inventar Slot setzen — Grotti meidet das. |
| `ACE_START_DRYING` / `ACE_STOP_DRYING` | Trockner; alternativ `filament_hub`-API. |
| `ACE_ENABLE_ENDLESS_SPOOL` / `…_DISABLE` / `…_STATUS` | Endless Spool. |
Details und Parameter: Rinkhals `docs/GOKLIPPER_ACE_GCODE_COMMANDS.md` (lokal unter `D:\Projekte\_ref\Rinkhals\`).
---
## Kobra S1 — Anycubic-Makros
Config-Sektion heißt `[leviQ3]`, die Befehle auf dem Gerät heißen **`LEVIQ2`**. Stecken in GoKlipper. Hilfe gelesen 11.10. über `printer/gcode/help`.
### LeviQ2
| Befehl | Funktion |
| --- | --- |
| `LEVIQ2` | Ganze Nivellierfolge: heizen, wischen, antasten. **Nicht** aus Mainsail. `SYNC=enable` startet danach neu. |
| `LEVIQ2_PREHEATING` | Vorheizen: Bett 55 °C, Düse 170 °C, dann `HOME_XY`. `S` = Düsentemp, `E` = Temp nach dem Wischen. |
| `LEVIQ2_WIPING` | Wischen: Netz löschen, Düse auf 140 °C, intern `G9112`, dann `G28 Z`. |
| `LEVIQ2_PROBE` | Antasten: `BED_MESH_CALIBRATE`, Lüfter/Bett aus, Z auf 20 mm, `HOME_XY`, Düse aus. |
| `LEVIQ_SET_ZERO` | Z-Basis und Home-Position auf 0. |
| `LEVIQ2_AUTO_ZOFFSET` | Löscht laut Hilfe den Koeffizienten K. |
| `LEVIQ2_AUTO_ZOFFSET_ON_OFF` | Schaltet automatischen Z-Offset. |
| `LEVIQ2_TEMP_OFFSET` | Temperaturausgleich (`expansion_factor` / `zoffset_base` in der Config). Fehlt im veröffentlichten Quelltext. |
| `HOME_XY` | Nur X/Y homen (von LeviQ-Schritten genutzt). |
Netz: 5×5, X/Y 5–245 mm. Details: Wissen `Kobra S1.md`.
### Wipe (Pad hinten)
| Befehl | Funktion |
| --- | --- |
| `WIPE_ENTER` | Anfahrt Pad (Y 250 → X 81, Y 273). |
| `WIPE_NOZZLE` | Wischbahn; wiederholt die Strecke X 96↔81. |
| `WIPE_STOP` | Kurze Stopp-Bahnen, dann X 75. |
| `WIPE_EXIT` | Weg vom Pad (X 83, Y 250). |
| `TO_THROW_POSITION` | Position zum Abwerfen (~X 47, Y 276). |
| `MOVE_TO_SWEEP_POSITION` | Wisch-/Kehrposition (gleiche Gegend). |
| `MOVE_TO_IMPACT_POSITION` | Weg vom Pad nach vorn rechts (Y 25, X 261). |
| `PRINT_LINE` | Purge-Linie bei Y 251, Z 0,28 bis X 125. |
---
## Kobra 3 — Anycubic-Makros
Befehle gelesen 11.10. auf `da26` (`92ee`/`6e41` gleiche `printer.generated.cfg`). Im veröffentlichten Quelltext heißen Teile noch `LEVIQ3_*` / `LEVIQ2_PROBE` — auf den drei Druckern gelten die Namen unten.
### LeviQ
| Befehl | Funktion |
| --- | --- |
| `LEVIQ` | Ganze Folge inkl. Z-Offset-Rechnung. **Nicht** aus Mainsail. `SYNC=enable` startet danach neu. |
| `LEVIQ_PREHEATING` | Z-Basis 0, Bett 60 °C, Düse 170 °C, `HOME_XY`, auf Düse warten. `S` / `E` wie bei S1. |
| `LEVIQ_WIPING` | Netz löschen, Düse 140 °C, `G9112`, `G28 Z`. |
| `LEVIQ_PROBE` | `BED_MESH_CALIBRATE`, Lüfter/Bett aus, Z auf 30 mm, `HOME_XY`, Düse aus. |
| `LEVIQ_SET_ZERO` | Z-Basis und Home auf 0. |
| `LEVIQ_AUTO_ZOFFSET` | Löscht Koeffizient K (Hilfe). |
| `LEVIQ_AUTO_ZOFFSET_ON_OFF` | Automatischen Z-Offset schalten (oft an). |
| `LEVIQ_TEMP_OFFSET` | Temperaturausgleich (Hilfezeile „LEVIQ3 TEMP OFFSET“). Fehlt im Open-Source-Code. |
| `HOME_XY` | Nur X/Y homen. |
Netz: 5×5, X 15–240, Y 15–235 mm. Details: Wissen `Kobra 3.md`.
### Wipe
| Befehl | Funktion |
| --- | --- |
| `WIPE_NOZZLE` | Dreimal `SINGLE_WIPE_NOZZLE` (Y 260/259, X 46–90, Z −0,8↔1, Lüfter an). |
| `SINGLE_WIPE_NOZZLE` | Eine einzelne Wischbahn. |
| `MOVE_HEAT_POS` | Z 15, dann X 278 (Heiz-/Parknahe Position). |
| `UNDERLINE` | Purge-Linie Z 0,28 bis X 180, ~40 mm Extrusion. |
| `MOVE_END_POS` | Ende/Park X 5, Y 242. |
### Wägezelle
| Befehl | Funktion |
| --- | --- |
| `CS1237_DUMP` | Debug-Ausgabe zum CS1237 (Z-Sensor / Kraft). Moonraker liefert keine laufende Gramm-Anzeige; `probe.last_z_result` fehlt. |
---
## Siehe auch
- [Anycubic-Stack](Anycubic-Stack.md) — MQTT-Start/Stop, 10101, kein Firmware-Restart
- [Senden-Queue](Senden-Queue.md) — `.acm`, kein Community-ACE-Makro
- [Abraeumen](Abraeumen.md) — K3 Farm-Scripts statt Druckermakro
- [ACE-Live](ACE-Live.md) — Live-Mapping und Trailer
+25
View File
@@ -0,0 +1,25 @@
# Problem melden
Stand 2.11.2026. Nutzer melden aus der Grotti-Oberfläche. Du bist oft nicht Admin ihrer Installation.
## Ablauf
1. Knopf in der Kopfzeile öffnet einen kurzen Text.
2. Die Installation speichert eine Kopie in PostgreSQL (`meldungen`, offen/erledigt). Der Admin dort sieht die Liste unter Einstellungen.
3. Dieselbe Meldung geht an den Empfänger `meldung_relay` (Standard `https://git.gundel.dev/hooks/grotti-meldung`).
4. Der Empfänger legt ein Issue im Grotti-Repo an. Der Forgejo-Token liegt nur dort, nicht in der Installation und nicht im Browser.
Mitgeschickt: Text, Version, Seite, Drucker-Host, letzter Fehlertext. Kein Benutzername der fremden Installation.
Kommt der Empfänger nicht ran, bleibt die lokale Kopie. Der Absender sieht, dass die Meldung bei dir nicht angekommen ist.
## Empfänger starten
```
export FORGEJO_URL=https://git.gundel.dev
export FORGEJO_TOKEN=…
export FORGEJO_REPO=dg/grotti
python scripts/meldung_relay.py
```
Reverse-Proxy: `/hooks/grotti-meldung` → `127.0.0.1:8780`. In `config.json` der Installationen optional `"meldung_relay": "https://git.gundel.dev/hooks/grotti-meldung"`.
+1 -1
View File
@@ -27,7 +27,7 @@ Jedes Ereignis landet in `events` und auf der Seite `/aktivitaet`. Telegram ist
| `fertig` | Watcher, `printing`→`complete` | nein, Platte unter „Aufgaben“ | an |
| `fehler` | Watcher, Zustand `error` | ja | an |
| `abbruch` | Watcher, `printing`/`paused`→`cancelled`/`standby` | ja | wie `fehler` |
| `offline` | Watcher, ≥2 Polls weg; auch „wieder erreichbar“ | ja | an |
| `offline` | Watcher, ≥2 Polls weg; auch „wieder erreichbar“. Beendet den Lauf nicht und zieht nichts ab — der letzte Druckzustand bleibt gemerkt | ja | an |
| `spule` | Watcher, Rest unter Job-Gewicht | ja | an |
| `queue` | Dispatcher: Queue-Fehler, Ablauf gestoppt | ja | an |
| `start` | Watcher: Druck startet, Pause, Fortsetzen | nein | aus |
+8
View File
@@ -3,3 +3,11 @@
Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App.
Nutzer-Handbuch: `docs/Anleitung-*.md`.
| Datei | Inhalt |
| --- | --- |
| [API.md](API.md) | HTTP-API für App/Integrationen (Auth, confirm, SSE, Filter) |
| [openapi.yaml](openapi.yaml) | OpenAPI-Skizze (Client-Generator) |
| [MCP.md](MCP.md) | MCP-Server und Tool-Ablauf |
| [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 |
+42
View File
@@ -0,0 +1,42 @@
# Treiber
Stand 2.11.2026. Die Karte, die Moonraker heute liefert, ist der Vertrag. Ein Treiber füllt, was die Maschine hergibt.
| Treiber | Wer | Transport |
| --- | --- | --- |
| `moonraker` | Kobra 3, Kobra S1 | Moonraker plus Rinkhals-Richtlinie: kein Firmware-Restart, ACE-Start über MQTT, Kamera unter `/webcam/`, Bettnetz |
| `anycubic_lan` | Kobra 4, Stock-Firmware | MQTTS `:9883`, Upload auf `fileUploadurl`, Plätze über `multiColorBox`. Kamera der Firmware (FLV) zeigt die Wand noch nicht |
| `marlin` | Anycubic i3, Tenlog | Dienst auf dem Rechner mit dem Kabel. Grotti spricht nur `127.0.0.1` |
## Fähigkeiten
Wer eine Fähigkeit ändert, ändert diese Liste im selben Schritt wie das Changelog.
| Drucker | Status | Start | Pause / Abbruch | ACE | Bettnetz | Kamera | Mainsail | Heizen Wand | Ablauf |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Kobra 3 | ja | ja | ja | ja | ja | MJPEG | ja | ja | ja |
| Kobra S1 | ja | ja | ja | ja | ja | MJPEG | ja | ja | nein |
| Kobra 4 | ja | ja (LAN) | ja | ja | nein | noch nicht (FLV) | nein | nein | nein |
| Anycubic i3 | ja | Strom oder SD | ja | nein | nein | nein | nein | nein (G-code) | nein |
| Tenlog | ja (2 Köpfe) | Strom oder SD | ja | nein (Köpfe) | nein | nein | nein | nein (G-code) | nein |
Die Instanz auf `hoe` bleibt auf `master`. Dieser Stand liegt auf dem Branch `treiber` und wird lokal geprüft.
## Marlin-Dienst
Grotti startet ihn selbst, sobald ein i3 oder Tenlog in der Liste steht. Lauscht er schon, bleibt der Prozess: Neustart und Absturz von Grotti schicken keinen Abbruch und öffnen die Leitung nicht neu. Grotti beendet den Dienst nicht.
Lokal ist das ein eigener Prozess (`python -m grotti.marlin_daemon`, Log `data/marlin-dienst.log`). Auf dem Pi ist es die Unit `grotti-marlin.service`, außerhalb von `grotti.service`, damit ein `systemctl restart grotti` den Druck nicht mitnimmt.
Port `GROTTI_MARLIN_PORT` (8771). `pyserial` öffnet die Leitung. Der Host des Druckers ist die Schnittstelle, Baud optional dahinter: `COM5`, `COM5@250000`, `/dev/ttyUSB0`. In den Einstellungen eintragen, Familie Anycubic i3 oder Tenlog.
Beim ersten Öffnen setzt USB den Drucker zurück (DTR). Danach bleibt der Port im Dienst.
Start pro Auftrag, Feld `marlin_mode` am Senden:
- `stream` — Zeile für Zeile, jede Zeile wartet auf `ok`. Fortschritt ist die Dateiposition. Bricht die Leitung ab, stirbt der Druck.
- `sd` — Datei per `M28`/`M29` auf die Karte (Name 8.3, `TEIL.GCO`), dann `M23`/`M24`. Der Drucker druckt allein, der Dienst fragt `M27` und `M105`.
`T0` und `T1` gehen unverändert in den Strom. Tenlog (`family: tenlog`) meldet zwei Köpfe unter `tools`, die Wand zeigt beide Temperaturen. `slots` bleibt leer, ein Start verlangt keine ACE-Zuordnung. Pause, Weiter und Abbruch gehen über die Wand. Heizen, Speed und Plätze nicht: das steht im G-code. Pause im Strom hält die nächsten Zeilen an, auf der Karte ist es `M25`/`M24`. Abbruch schaltet die Heizungen aus; auf der Karte zusätzlich `M524`.
Die Warteschlange „wenn frei“ übergibt den Modus noch nicht, dort gilt Strom.
+180
View File
@@ -0,0 +1,180 @@
openapi: 3.0.3
info:
title: Grotti API
version: "1"
description: |
JSON unter /api/v1. Auth: Session-Cookie oder Bearer grt_….
Mutierende Session-Calls brauchen Header X-CSRF-Token.
Siehe docs/internal/API.md.
servers:
- url: /api/v1
paths:
/version:
get:
summary: Server-Info
security: []
responses:
"200":
description: name, version, api, features
/auth/login:
post:
summary: Session-Login
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [username, password]
properties:
username: { type: string }
password: { type: string }
responses:
"200":
description: user + csrf
/auth/token:
post:
summary: Passwort → API-Token (App)
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [username, password]
properties:
username: { type: string }
password: { type: string }
device_name: { type: string }
role: { type: string, enum: [operator, admin] }
responses:
"201":
description: token (einmalig) + user
/drucker:
get:
summary: Druckerliste
parameters:
- name: live
in: query
schema: { type: integer, enum: [0, 1] }
responses:
"200":
description: Array Drucker
/drucker/{host}/{action}:
post:
summary: Pause / Fortsetzen / Abbrechen
parameters:
- name: host
in: path
required: true
schema: { type: string }
- name: action
in: path
required: true
schema:
type: string
enum: [pause, fortsetzen, abbrechen, resume, cancel]
responses:
"200":
description: OK
/dateien:
get:
summary: Bibliothek
parameters:
- { name: family, in: query, schema: { type: string } }
- { name: material, in: query, schema: { type: string } }
- { name: project_id, in: query, schema: { type: integer } }
- { name: host, in: query, schema: { type: string } }
- { name: q, in: query, schema: { type: string } }
- { name: limit, in: query, schema: { type: integer } }
- { name: offset, in: query, schema: { type: integer } }
responses:
"200":
description: Array Dateien; Header X-Total-Count
headers:
X-Total-Count:
schema: { type: integer }
/senden:
post:
summary: Druck starten oder einreihen
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [file_id, confirm]
properties:
file_id: { type: integer }
hosts: { type: array, items: { type: string } }
confirm: { type: boolean }
when_free: { type: boolean }
ace_maps: { type: object }
bed_leveling: { type: boolean }
responses:
"202":
description: jobs
/druck-queue:
get:
summary: Warteschlange
parameters:
- { name: host, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } }
- { name: limit, in: query, schema: { type: integer } }
- { name: offset, in: query, schema: { type: integer } }
responses:
"200":
description: Array; X-Total-Count
post:
summary: Einreihen
requestBody:
content:
application/json:
schema:
type: object
required: [file_id, confirm]
responses:
"200":
description: Queue-Eintrag
/projekte/{id}/einreihen:
post:
summary: Projektdateien einreihen
parameters:
- name: id
in: path
required: true
schema: { type: integer }
requestBody:
content:
application/json:
schema:
type: object
required: [confirm]
properties:
confirm: { type: boolean }
file_ids: { type: array, items: { type: integer } }
menge: { type: integer }
responses:
"201":
description: jobs
/stream:
get:
summary: SSE Live-Events
responses:
"200":
description: text/event-stream (drucker, queue, senden, ereignis, ping)
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: grt_
cookieAuth:
type: apiKey
in: cookie
name: session
security:
- bearerAuth: []
- cookieAuth: []
+86
View File
@@ -287,6 +287,10 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
text, code = _report_error(body, "Druckabbruch abgelehnt")
state["error"] = text
state["code"] = code
elif action in ("pause", "resume") and report in ("failed",):
text, code = _report_error(body, "Druckbefehl abgelehnt")
state["error"] = text
state["code"] = code
# start: heating / leveling / printing / preheating = angenommen
# stop: stoped / stopped / done = angenommen
state["done"] = True
@@ -477,6 +481,88 @@ def anycubic_task(host, use_cache=True):
return None
def print_action(host, action, timeout=15):
"""Pause, Fortsetzen oder Stopp über denselben MQTT-Kanal wie der Start."""
if action not in ("pause", "resume", "stop"):
raise LanError("Unbekannter Druckbefehl.")
request = {
"type": "print",
"action": action,
"msgid": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000),
"data": {"taskid": "-1"},
}
report = _mqtt_print(
host,
request,
timeout=timeout,
timeout_message="Timeout beim MQTT-Druckbefehl.",
)
_INFO_CACHE.pop(host, None)
return {"mqtt": action, "report": report}
def mqtt_report(host, channel, typ, action, timeout=8):
"""Eine Abfrage auf slicer/…/<channel>, liefert den Report-Körper."""
cred = credentials(host)
model_id = cred["modelId"]
device_id = cred["deviceId"]
topic_query = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/%s" % (
model_id, device_id, channel,
)
topic_report = "anycubic/anycubicCloud/v1/printer/public/%s/%s/%s/report" % (
model_id, device_id, channel,
)
payload = json.dumps({
"type": typ,
"action": action,
"msgid": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000),
"data": None,
})
state = {"body": None}
def on_connect(client, userdata, flags, reason_code, properties=None):
if getattr(reason_code, "is_failure", False):
state["body"] = {"state": "failed", "msg": "MQTT-Verbindung abgelehnt."}
return
client.subscribe(topic_report)
client.publish(topic_query, payload=payload, qos=1)
def on_message(client, userdata, msg):
try:
state["body"] = json.loads(msg.payload.decode("utf-8", "replace"))
except (json.JSONDecodeError, UnicodeError):
return
client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
protocol=mqtt.MQTTv311,
client_id="grotti-%s" % uuid.uuid4().hex[:10],
)
client.username_pw_set(cred["username"], cred["password"])
client.tls_set(cert_reqs=ssl.CERT_NONE)
client.tls_insecure_set(True)
client.on_connect = on_connect
client.on_message = on_message
try:
client.connect(host, 9883, keepalive=30)
except OSError as exc:
raise LanError("MQTT am Drucker nicht erreichbar (:9883).") from exc
deadline = time.time() + max(3, int(timeout))
try:
while state["body"] is None and time.time() < deadline:
client.loop(timeout=0.25)
finally:
try:
client.disconnect()
except Exception:
pass
if state["body"] is None:
raise LanError("Timeout bei der Druckerabfrage.")
return state["body"]
def cancel_print(host, timeout=15):
"""MQTT-Stop wie Rinkhals Docs: action stop, taskid -1 (Anycubic-Task)."""
request = {
+75 -4
View File
@@ -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:
@@ -110,6 +124,14 @@ def _oauth(raw):
}
def _http_threads(raw):
try:
count = int(raw.get("http_threads") or 16)
except (TypeError, ValueError):
count = 16
return max(2, min(32, count))
def _ensure_secret_key(path, raw):
key = str(raw.get("secret_key") or "").strip()
if key:
@@ -121,9 +143,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 +163,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,13 +176,16 @@ def load():
"secret_key": secret_key,
"oauth": _oauth(raw),
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
"printers_file": ROOT / "drucker.json",
"etikett": _etikett(raw),
"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),
"alerts": _alerts(raw),
"auto_queue": bool(raw.get("auto_queue", True)),
"auto_retry": bool(raw.get("auto_retry", True)),
"http_threads": _http_threads(raw),
"_raw": raw,
"_path": path,
}
@@ -204,6 +234,36 @@ def default_watch_suggestion():
return str((ROOT / DEFAULT_WATCH_REL).resolve())
DEFAULT_ETIKETT = {"width_mm": 70.0, "height_mm": 40.0}
ETIKETT_PRESETS = (
{"id": "70x40", "label": "70 × 40 mm", "width_mm": 70, "height_mm": 40},
{"id": "62x29", "label": "62 × 29 mm (Brother)", "width_mm": 62, "height_mm": 29},
{"id": "50x30", "label": "50 × 30 mm", "width_mm": 50, "height_mm": 30},
{"id": "100x50", "label": "100 × 50 mm", "width_mm": 100, "height_mm": 50},
)
def _etikett(raw):
src = raw.get("etikett") if isinstance(raw.get("etikett"), dict) else {}
try:
width = float(src.get("width_mm", DEFAULT_ETIKETT["width_mm"]))
except (TypeError, ValueError):
width = DEFAULT_ETIKETT["width_mm"]
try:
height = float(src.get("height_mm", DEFAULT_ETIKETT["height_mm"]))
except (TypeError, ValueError):
height = DEFAULT_ETIKETT["height_mm"]
width = max(20.0, min(200.0, width))
height = max(15.0, min(200.0, height))
qr = max(10.0, min(20.0, round(height * 0.5, 1)))
return {
"width_mm": width,
"height_mm": height,
"qr_mm": qr,
"presets": list(ETIKETT_PRESETS),
}
def settings_public(cfg):
tg = cfg.get("telegram") or {}
oauth = cfg.get("oauth") or {}
@@ -213,6 +273,7 @@ def settings_public(cfg):
"auto_queue": bool(cfg.get("auto_queue", True)),
"auto_retry": bool(cfg.get("auto_retry", True)),
"fqdn": str(cfg.get("fqdn") or ""),
"etikett": _etikett({"etikett": cfg.get("etikett") or {}}),
"legacy_token_set": bool(cfg.get("token")),
"energy": dict(cfg.get("energy") or {}),
"telegram": {
@@ -248,6 +309,7 @@ def save_settings(
auto_retry=None,
fqdn=None,
watch_dir=None,
etikett=None,
):
"""Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
with _SETTINGS_LOCK:
@@ -261,6 +323,7 @@ def save_settings(
auto_retry=auto_retry,
fqdn=fqdn,
watch_dir=watch_dir,
etikett=etikett,
)
@@ -274,6 +337,7 @@ def _save_settings_locked(
auto_retry=None,
fqdn=None,
watch_dir=None,
etikett=None,
):
path = cfg.get("_path") or CONFIG_PATH
raw = dict(cfg.get("_raw") or {})
@@ -296,6 +360,13 @@ def _save_settings_locked(
normalized = normalize_fqdn(fqdn)
raw["fqdn"] = normalized
cfg["fqdn"] = normalized
if etikett is not None and isinstance(etikett, dict):
parsed = _etikett({"etikett": etikett})
raw["etikett"] = {
"width_mm": parsed["width_mm"],
"height_mm": parsed["height_mm"],
}
cfg["etikett"] = parsed
if energy is not None:
raw_energy = {
"watts": float(energy.get("watts", 40)),
+4 -1
View File
@@ -45,7 +45,10 @@ def _yesterday_stats(conn):
def build_text(conn, watcher_latest=None, printers=None):
stats = _yesterday_stats(conn)
waiting = len([j for j in store.list_print_queue(conn, limit=200) if j.get("status") == "waiting"])
waiting = len([
j for j in store.list_print_queue(conn, limit=200)["items"]
if j.get("status") == "waiting"
])
eta = jetzt_mod.farm_eta([
{"remain": (live or {}).get("remain") or 0}
for live in (watcher_latest or [])
+5 -1
View File
@@ -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)
+63
View File
@@ -0,0 +1,63 @@
"""Drucker-Treiber. Die Karte ist das, was moonraker.query schon liefert.
Vorhandene Kobra 3 und S1 bleiben auf Moonraker. Kobra 4 spricht die
Stock-Firmware über den LAN-Broker. i3 und Tenlog gehen an den Marlin-Dienst.
"""
from grotti.drivers.anycubic_lan import AnycubicLanDriver
from grotti.drivers.marlin import MarlinDriver
from grotti.drivers.moonraker_drv import MoonrakerDriver
from grotti.printers import FAMILY_DRIVER
_BY_ID = {
"moonraker": MoonrakerDriver(),
"anycubic_lan": AnycubicLanDriver(),
"marlin": MarlinDriver(),
}
def for_printer(printer):
family = (printer or {}).get("family") or ""
driver_id = (printer or {}).get("driver") or FAMILY_DRIVER.get(family) or "moonraker"
return _BY_ID.get(driver_id) or _BY_ID["moonraker"]
def snapshot(printers):
"""Live-Karten in der Reihenfolge der Drucker. Moonraker-Gruppen bleiben ein Aufruf."""
buckets = {}
order = []
for printer in printers or []:
drv = for_printer(printer)
key = drv.transport
if key not in buckets:
order.append(key)
buckets[key] = (drv, [])
buckets[key][1].append(printer)
by_host = {}
for key in order:
drv, group = buckets[key]
for card in drv.snapshot(group):
host = card.get("host")
if host:
by_host[host] = card
lives = []
for printer in printers or []:
host = printer.get("host")
card = by_host.get(host)
if card:
lives.append(card)
continue
lives.append({
"name": printer.get("name") or host,
"host": host,
"family": printer.get("family") or "",
"family_label": printer.get("family_label") or "",
"ok": False,
"state": "offline",
"message": "Kein Treiber-Ergebnis.",
"filename": "",
"slots": [],
"has_camera": False,
"camera": "",
})
return lives
+250
View File
@@ -0,0 +1,250 @@
"""Stock-Firmware über den Anycubic-LAN-Broker. Kein Moonraker.
Info, Plätze und Druckbefehle laufen über MQTTS :9883. Die Datei geht an
die fileUploadurl aus der Info-Antwort (:18910/gcode_upload). Die Kamera
der Standard-Firmware ist FLV, die Wand zeigt sie noch nicht.
"""
import uuid
import urllib.error
import urllib.request
from grotti import anycubic_lan
from grotti.moonraker import PrinterError, safe_upload_name
_STATE = {
"free": "standby",
"idle": "standby",
"standby": "standby",
"done": "complete",
"complete": "complete",
"finish": "complete",
"finished": "complete",
"printing": "printing",
"busy": "printing",
"heating": "printing",
"preheating": "printing",
"leveling": "printing",
"paused": "paused",
"pause": "paused",
"error": "error",
"failed": "error",
}
def _number(value):
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value)
def card_from_info(info, printer=None):
"""LAN-info/report → dieselbe Karte wie moonraker.query, leere Felder bleiben leer."""
info = info or {}
printer = printer or {}
project = info.get("project") if isinstance(info.get("project"), dict) else {}
raw_state = str(info.get("state") or project.get("print_status") or "").strip().lower()
state = _STATE.get(raw_state, raw_state or "standby")
filename = project.get("filename") or project.get("name") or ""
if isinstance(filename, str):
filename = filename.replace("\\", "/").rsplit("/", 1)[-1]
else:
filename = ""
progress = _number(project.get("progress"))
if progress is None:
progress = 0.0
elif progress > 1:
progress = progress / 100.0
progress = max(0.0, min(1.0, progress))
temp = info.get("temp") if isinstance(info.get("temp"), dict) else {}
host = printer.get("host") or info.get("ip") or ""
return {
"ok": True,
"name": printer.get("name") or info.get("printerName") or host,
"host": host,
"family": printer.get("family") or "",
"family_label": printer.get("family_label") or "",
"state": state,
"message": "",
"filename": filename,
"progress": progress,
"layer": int(_number(project.get("curr_layer")) or 0),
"layers": int(_number(project.get("total_layers") or project.get("total_layer")) or 0),
"remain": int(_number(project.get("remain_time")) or 0),
"nozzle": _number(temp.get("curr_nozzle_temp")),
"nozzle_target": _number(temp.get("target_nozzle_temp")),
"bed": _number(temp.get("curr_hotbed_temp")),
"bed_target": _number(temp.get("target_hotbed_temp")),
"fan": _number(info.get("fan_speed_pct")),
"slots": [],
"has_camera": False,
"camera": "",
"camera_stream": "",
"speed_factor": None,
"extrude_factor": None,
"mesh": None,
}
def slots_from_report(data):
"""multiColorBox/getInfo → Plätze mit index ab 0, wie Moonraker-Slots."""
data = data or {}
boxes = data.get("multi_color_box")
if isinstance(boxes, dict):
boxes = boxes.get("slots") or boxes.get("boxes") or [boxes]
if not isinstance(boxes, list):
return []
found = []
for box_index, box in enumerate(boxes):
if not isinstance(box, dict):
continue
slots = box.get("slots")
if not isinstance(slots, list):
kind = str(box.get("type") or box.get("material") or "").strip()
if kind:
found.append({"index": box_index, "type": kind, "color": ""})
continue
for offset, slot in enumerate(slots):
if not isinstance(slot, dict):
continue
kind = str(slot.get("type") or slot.get("material_type") or slot.get("material") or "").strip()
if not kind or kind.lower() == "unknown":
continue
index = slot.get("index")
if isinstance(index, bool) or not isinstance(index, int):
index = box_index * 4 + offset
found.append({"index": index, "type": kind, "color": ""})
found.sort(key=lambda item: item["index"])
return found
class AnycubicLanDriver:
transport = "anycubic_lan"
rinkhals = False
firmware_restart = False
def snapshot(self, printers):
cards = []
for printer in printers or []:
host = printer.get("host") or ""
try:
card = self.query(host)
card["name"] = printer.get("name") or card.get("name") or host
card["host"] = host
card["family"] = printer.get("family") or ""
card["family_label"] = printer.get("family_label") or ""
card["slots"] = self.slots(host)
except PrinterError as exc:
card = {
"name": printer.get("name") or host,
"host": host,
"family": printer.get("family") or "",
"family_label": printer.get("family_label") or "",
"ok": False,
"state": "offline",
"message": exc.message,
"filename": "",
"slots": [],
"has_camera": False,
"camera": "",
}
cards.append(card)
return cards
def query(self, host):
info = anycubic_lan.print_info(host, timeout=8, use_cache=False)
if not info:
raise PrinterError("Der Drucker ist nicht erreichbar.")
return card_from_info(info, {"host": host})
def slots(self, host, status=None):
try:
body = anycubic_lan.mqtt_report(host, "multiColorBox", "multiColorBox", "getInfo")
except anycubic_lan.LanError as exc:
raise PrinterError(exc.message) from exc
data = body.get("data") if isinstance(body, dict) else None
if not isinstance(data, dict):
return []
return slots_from_report(data)
def upload(self, host, filename, content, on_progress=None):
filename = safe_upload_name(filename)
info = anycubic_lan.print_info(host, timeout=8, use_cache=False) or {}
url = ((info.get("urls") or {}).get("fileUploadurl") or "").strip()
if "/gcode_upload" not in url:
raise PrinterError("Der Drucker hat keine Upload-Adresse gemeldet.")
body, boundary = _multipart(filename, content)
request = urllib.request.Request(
url,
data=body,
headers={
"Content-Type": "multipart/form-data; boundary=%s" % boundary,
"Content-Length": str(len(body)),
"X-File-Length": str(len(content)),
"User-Agent": "Grotti",
"X-BBL-Client-Name": "Grotti",
"X-BBL-Client-Type": "slicer",
},
method="POST",
)
if on_progress:
on_progress(0, len(content))
try:
with urllib.request.urlopen(request, timeout=_upload_timeout(len(content))) as response:
response.read()
except urllib.error.HTTPError as exc:
detail = exc.read(400).decode("utf-8", "replace").strip()
raise PrinterError(detail or "Hochladen ist fehlgeschlagen.") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise PrinterError("Hochladen ist fehlgeschlagen.") from exc
if on_progress:
on_progress(len(content), len(content))
return filename
def start_print(self, host, filename, bed_leveling=False, ams_settings=None):
try:
return anycubic_lan.start_print(
host,
filename,
auto_leveling=bool(bed_leveling),
ams_settings=ams_settings,
)
except anycubic_lan.LanError as exc:
raise PrinterError(exc.message) from exc
def command(self, host, action):
mapped = {"pause": "pause", "fortsetzen": "resume", "abbrechen": "stop", "resume": "resume", "cancel": "stop"}.get(action)
if not mapped:
raise PrinterError("Unbekannter Befehl.")
try:
if mapped == "stop":
return anycubic_lan.cancel_print(host)
return anycubic_lan.print_action(host, mapped)
except anycubic_lan.LanError as exc:
raise PrinterError(exc.message) from exc
def clear_print_error(self, host):
raise PrinterError("Fehler löschen gibt es an der Stock-Firmware in Grotti nicht.")
def heaters_off(self, host):
return None
def _upload_timeout(size):
size = max(0, int(size or 0))
return max(180, min(600, 90 + size // (80 * 1024)))
def _multipart(filename, content):
boundary = "grotti-%s" % uuid.uuid4().hex
sep = ("--%s\r\n" % boundary).encode()
end = ("--%s--\r\n" % boundary).encode()
name = sep + b'Content-Disposition: form-data; name="filename"\r\n\r\n' + filename.encode() + b"\r\n"
payload = (
sep
+ ('Content-Disposition: form-data; name="gcode"; filename="%s"\r\n' % filename).encode()
+ b"Content-Type: application/octet-stream\r\n\r\n"
+ content
+ b"\r\n"
)
return name + payload + end, boundary
+224
View File
@@ -0,0 +1,224 @@
"""Marlin-Treiber. Spricht den lokalen Dienst, hält selbst keine Leitung.
Host ist die Schnittstelle, optional mit Baud: ``COM5`` oder ``COM5@250000``.
Familie ``tenlog`` hat zwei Köpfe (T0, T1). Die Karte führt sie unter ``tools``.
``slots`` bleibt leer, damit ein Start keine ACE-Zuordnung verlangt.
"""
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from grotti.marlin_link import split_port
from grotti.moonraker import PrinterError
_DOWN = "Der Marlin-Dienst läuft nicht."
def service_url():
return (os.environ.get("GROTTI_MARLIN_URL") or "http://127.0.0.1:8771").rstrip("/")
def _offline(printer, message):
host = (printer or {}).get("host") or ""
return {
"name": (printer or {}).get("name") or host,
"host": host,
"family": (printer or {}).get("family") or "",
"family_label": (printer or {}).get("family_label") or "",
"ok": False,
"state": "offline",
"message": message,
"filename": "",
"progress": None,
"slots": [],
"tools": [],
"has_camera": False,
"camera": "",
}
def heads_for(printer):
family = (printer or {}).get("family") or ""
return 2 if family == "tenlog" else 1
def card_from_status(printer, status):
temps = (status or {}).get("temps") or {}
heads = int((status or {}).get("heads") or heads_for(printer))
tools = []
for index in range(heads):
key = "T%d" % index
cur, target = _pair(temps.get(key) or temps.get("T") or [None, None])
tools.append({
"index": index,
"name": "Kopf %d" % (index + 1),
"temp": cur,
"target": target,
})
nozzle = tools[0]["temp"] if tools else None
nozzle_target = tools[0]["target"] if tools else None
bed, bed_target = _pair(temps.get("B") or [None, None])
host = (printer or {}).get("host") or ""
return {
"name": (printer or {}).get("name") or (status or {}).get("name") or host,
"host": host,
"family": (printer or {}).get("family") or "",
"family_label": (printer or {}).get("family_label") or "",
"ok": bool((status or {}).get("ok")),
"state": (status or {}).get("state") or "offline",
"message": (status or {}).get("message") or "",
"filename": (status or {}).get("filename") or "",
"progress": (status or {}).get("progress"),
"nozzle": nozzle,
"nozzle_target": nozzle_target,
"bed": bed,
"bed_target": bed_target,
"slots": [],
"tools": tools,
"has_camera": False,
"camera": "",
}
def _pair(value):
if isinstance(value, (list, tuple)) and len(value) >= 2:
return value[0], value[1]
return None, None
class MarlinDriver:
transport = "marlin"
rinkhals = False
firmware_restart = False
def __init__(self, base=None):
self.base = (base or service_url()).rstrip("/")
def snapshot(self, printers):
printers = list(printers or [])
if printers:
self._daemon()
cards = []
for printer in printers:
try:
self._ensure(printer)
device, _baud = split_port(printer.get("host") or "")
status = self._json("GET", "/status?device=%s" % urllib.parse.quote(device))
cards.append(card_from_status(printer, status))
except PrinterError as exc:
cards.append(_offline(printer, exc.message))
return cards
def query(self, host):
device, _baud = split_port(host)
try:
status = self._json("GET", "/status?device=%s" % urllib.parse.quote(device))
except PrinterError:
raise
return card_from_status({"host": host}, status)
def slots(self, host, status=None):
return []
def upload(self, host, filename, content, on_progress=None):
device, _baud = split_port(host)
data = content if isinstance(content, bytes) else (content or "").encode("utf-8")
result = self._json(
"POST",
"/datei?device=%s" % urllib.parse.quote(device),
data,
{"X-Dateiname": filename or "druck.gcode", "Content-Type": "application/octet-stream"},
)
if on_progress:
on_progress(len(data), len(data) or 1)
return result.get("filename") or filename
def start_print(self, host, filename, bed_leveling=False, ams_settings=None, mode="stream"):
del bed_leveling, ams_settings
device, _baud = split_port(host)
chosen = (mode or "stream").strip().lower()
if chosen not in ("stream", "sd"):
raise PrinterError("Marlin-Start ist Strom oder SD-Karte.")
result = self._json("POST", "/start", {
"device": device,
"filename": filename,
"mode": chosen,
})
return result.get("filename") or filename
def command(self, host, action):
device, _baud = split_port(host)
name = (action or "").strip().lower()
mapped = {
"pause": "pause",
"fortsetzen": "fortsetzen",
"resume": "fortsetzen",
"abbrechen": "abbrechen",
"cancel": "abbrechen",
}.get(name)
if not mapped:
raise PrinterError("Unbekannte Aktion.")
self._json("POST", "/befehl", {"device": device, "action": mapped})
def clear_print_error(self, host):
device, _baud = split_port(host)
self._json("POST", "/befehl", {"device": device, "action": "fehler_loeschen"})
def heaters_off(self, host):
device, _baud = split_port(host)
self._json("POST", "/befehl", {"device": device, "action": "heizungen_aus"})
def attach(self, printer):
self._daemon()
self._ensure(printer)
def _daemon(self):
try:
from grotti.marlin_supervisor import ensure_if_enabled
ensure_if_enabled()
except Exception:
return
def _ensure(self, printer):
device, baud = split_port(printer.get("host") or "")
self._json("POST", "/oeffnen", {
"device": device,
"baud": baud,
"heads": heads_for(printer),
"name": printer.get("name") or "",
})
def _json(self, method, path, body=None, headers=None):
data = None
hdrs = dict(headers or {})
if isinstance(body, (dict, list)):
data = json.dumps(body).encode("utf-8")
hdrs.setdefault("Content-Type", "application/json")
elif isinstance(body, bytes):
data = body
request = urllib.request.Request(self.base + path, data=data, headers=hdrs, method=method)
try:
with urllib.request.urlopen(request, timeout=1.5) as response:
raw = response.read()
except urllib.error.HTTPError as exc:
detail = _error_message(exc)
raise PrinterError(detail) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise PrinterError(_DOWN) from exc
if not raw:
return {}
try:
return json.loads(raw.decode("utf-8"))
except json.JSONDecodeError as exc:
raise PrinterError("Der Marlin-Dienst hat kein JSON geliefert.") from exc
def _error_message(exc):
try:
payload = json.loads(exc.read().decode("utf-8"))
except (OSError, json.JSONDecodeError, UnicodeError):
payload = {}
return payload.get("message") or _DOWN
+44
View File
@@ -0,0 +1,44 @@
"""Moonraker-Treiber. Rinkhals ist eine Richtlinie darauf, kein zweiter Client.
Kobra 3 und S1: kein Firmware-Restart (steckt in moonraker.steuern),
ACE-Start über MQTT, Kamera als MJPEG unter /webcam/, Bettnetz aus bed_mesh.
"""
from grotti import moonraker
class MoonrakerDriver:
transport = "moonraker"
rinkhals = True
firmware_restart = False
def snapshot(self, printers):
return moonraker.snapshot(printers)
def query(self, host):
return moonraker.query(host)
def slots(self, host, status=None):
return moonraker.slots(host, status)
def upload(self, host, filename, content, on_progress=None):
return moonraker.upload(host, filename, content, on_progress=on_progress)
def start_print(self, host, filename, bed_leveling=False, ams_settings=None):
return moonraker.start_print(
host, filename, bed_leveling=bed_leveling, ams_settings=ams_settings,
)
def command(self, host, action):
name = (action or "").strip().lower()
if name == "cancel":
name = "abbrechen"
elif name == "resume":
name = "fortsetzen"
return moonraker.command(host, name)
def clear_print_error(self, host):
return moonraker.clear_print_error(host)
def heaters_off(self, host):
return moonraker.heaters_off(host)
+6 -2
View File
@@ -111,7 +111,7 @@ def build(conn, watcher_latest, printers=None, send_jobs=None):
"SELECT DISTINCT host FROM print_queue WHERE status = 'running'"
).fetchall()
}
queue = store.list_print_queue(conn, limit=80)
queue = store.list_print_queue(conn, limit=80)["items"]
waiting = [j for j in queue if j.get("status") == "waiting"]
errors = [j for j in queue if j.get("status") == "error"]
running_q = [j for j in queue if j.get("status") == "running"]
@@ -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")),
})
+674 -137
View File
@@ -5,10 +5,11 @@ import urllib.error
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from grotti import acm, catalog, eject, library, moonraker, store, usage
from grotti import acm, catalog, eject, library, logutil, moonraker, store, usage
from grotti.cost import energy_cost
from grotti.errors import GrottiError
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
from grotti.drivers import for_printer
from grotti.printers import FAMILY_LABELS
TERMINAL = {"completed", "cancelled", "error", "interrupted"}
@@ -39,12 +40,12 @@ def _ace_map_of(sent):
return []
def _targets_from_ace(conn, host, ace):
def _targets_from_ace(conn, host, ace, ended_at=None):
targets = []
for item in ace:
slot = int(item["ams_index"]) + 1
row = store.spool_at_slot(conn, host, slot)
if row:
if row and _spool_valid_for_end(row, ended_at):
targets.append(row)
return targets
@@ -55,6 +56,398 @@ def unix_to_dt(value):
return datetime.fromtimestamp(float(value), timezone.utc)
# Anycubic schließt bei Filament-Pause den History-Job (cancelled) und öffnet
# beim Fortsetzen eine neue Job-ID. Zusammengelegt wird nur mit Beweis:
# print_duration läuft weiter und die Lücke ist klein, oder der Watcher
# hat paused→printing gesehen. Ein Neustart mit Dauer nahe 0 bleibt getrennt.
RESUME_GAP_S = 5
RESUME_DURATION_TOL_S = 30
RESUME_FRESH_MAX_S = 60
# Abwärtskompatibel für alte Aufrufe / Docs.
RESUME_JOIN_S = RESUME_GAP_S
LIVE_DEDUCT_FALLBACK_S = 600
SYNC_LOCK_KEY = 748291053
def _as_utc(value):
if value is None:
return None
if getattr(value, "tzinfo", None) is None:
return value.replace(tzinfo=timezone.utc)
return value
def history_shell_is_empty(run_or_job):
"""History-Job ohne Ende und ohne Dauer. Nie als eigenen Lauf speichern."""
if not run_or_job:
return False
duration = float(
run_or_job.get("duration_s")
or run_or_job.get("print_duration")
or run_or_job.get("total_duration")
or 0
)
ended = run_or_job.get("ended_at") or run_or_job.get("end_time")
return duration <= 0 and not ended
def is_filament_resume(
prev_end,
new_start,
prev_duration_s=None,
new_duration_s=None,
watcher_resume=False,
gap_s=RESUME_GAP_S,
):
"""True nur mit Beweis: Dauer fortgesetzt + kleine Lücke, oder Watcher-Signal.
Aufruf mit zwei Zeitpunkten (alt): nur Lücke — gilt nicht mehr als Resume.
"""
prev_end = _as_utc(prev_end)
new_start = _as_utc(new_start)
if prev_end is None or new_start is None:
return False
gap = (new_start - prev_end).total_seconds()
if not (-30 <= gap <= max(float(gap_s), RESUME_GAP_S)):
# Watcher-Signal: Lücke darf länger sein (Pause vor dem Resume-Ende).
if not watcher_resume or gap > 24 * 3600 or gap < -30:
return False
return True
if watcher_resume:
return True
# Zwei-Argument-Aufruf (Tests/alt): ohne Dauer kein Beweis → kein Resume.
if prev_duration_s is None and new_duration_s is None:
return False
prev_d = float(prev_duration_s or 0)
new_d = float(new_duration_s or 0)
# Frischer Neustart: Dauer beginnt nahe 0 — nie zusammenlegen.
if new_d <= RESUME_FRESH_MAX_S:
return False
if new_d < max(0.0, prev_d - RESUME_DURATION_TOL_S):
return False
return True
def undo_deduction(conn, run):
"""Spulenabzug eines Laufs zurücknehmen.
Letzten Abschnitt wieder öffnen — nach Filamentpause schließt ein
vorzeitiger History-Abgleich den Abschnitt, bevor der Druck weiterläuft.
"""
if not run or not run.get("deducted"):
return False
rows = conn.execute(
"SELECT spool_id, weight_g FROM run_spools WHERE run_id = %s",
(run["id"],),
).fetchall()
for row in rows:
grams = float(row["weight_g"] or 0)
if grams and row["spool_id"]:
conn.execute(
"UPDATE spools SET remaining_g = remaining_g + %s WHERE id = %s",
(grams, row["spool_id"]),
)
conn.execute("DELETE FROM run_spools WHERE run_id = %s", (run["id"],))
conn.execute(
"UPDATE runs SET deducted = false, filament_cost = 0 WHERE id = %s",
(run["id"],),
)
conn.execute(
"""
UPDATE run_segments
SET mm_end = NULL, closed_at = NULL
WHERE id = (
SELECT id FROM run_segments
WHERE run_id = %s
ORDER BY id DESC
LIMIT 1
)
AND mm_end IS NOT NULL
""",
(run["id"],),
)
return True
def fold_filament_resume(
conn,
host,
filename,
job_id,
start_dt,
print_duration_s=0,
watcher_resume=False,
):
"""Fortsetzung nach Filament-Pause auf den bestehenden Lauf legen.
Gibt die Lauf-ID zurück, deren moonraker_job_id jetzt job_id ist.
Leere Hüllen (Dauer 0, kein Ende) werden vorher übersprungen — nicht hier.
"""
pred = store.find_resume_predecessor(
conn, host, filename, job_id, start_dt, watcher_resume=watcher_resume
)
if not pred:
return None
if not is_filament_resume(
pred.get("ended_at"),
start_dt,
prev_duration_s=pred.get("duration_s"),
new_duration_s=print_duration_s,
watcher_resume=watcher_resume,
):
return None
other = conn.execute(
"""
SELECT * FROM runs
WHERE printer_host = %s AND moonraker_job_id = %s AND id <> %s
""",
(host, str(job_id), pred["id"]),
).fetchone()
store.remember_run_job(conn, host, pred.get("moonraker_job_id"), pred["id"], replace=True)
undo_deduction(conn, pred)
kept = None
if other:
undo_deduction(conn, other)
conn.execute(
"UPDATE events SET run_id = %s WHERE run_id = %s",
(pred["id"], other["id"]),
)
if not (pred.get("verdict") or "").strip() and (other.get("verdict") or "").strip():
conn.execute(
"""
UPDATE runs
SET verdict = %s, verdict_reason = %s, pieces_ok = %s, pieces_bad = %s
WHERE id = %s
""",
(
other.get("verdict") or "",
other.get("verdict_reason") or "",
other.get("pieces_ok") or 0,
other.get("pieces_bad") or 0,
pred["id"],
),
)
kept = {
"state": other.get("state") or "completed",
"ended_at": other.get("ended_at"),
"duration_s": float(other.get("duration_s") or 0),
"weight_used_g": float(other.get("weight_used_g") or 0),
"weight_source": other.get("weight_source") or "",
"filament_used_mm": float(other.get("filament_used_mm") or 0),
"filament_total_mm": float(other.get("filament_total_mm") or 0),
"progress_last": float(other.get("progress_last") or 0),
}
for table in ("run_samples", "run_segments"):
conn.execute(
"UPDATE %s SET run_id = %%s WHERE run_id = %%s" % table,
(pred["id"], other["id"]),
)
try:
conn.execute(
"""
DELETE FROM run_labels AS src
WHERE src.run_id = %s
AND EXISTS (
SELECT 1 FROM run_labels AS dst
WHERE dst.run_id = %s AND dst.object_name = src.object_name
)
""",
(other["id"], pred["id"]),
)
conn.execute(
"UPDATE run_labels SET run_id = %s WHERE run_id = %s",
(pred["id"], other["id"]),
)
except Exception:
logutil.get("grotti.jobs").warning(
"Etiketten von Lauf %s nicht übernommen.", other["id"]
)
conn.execute("DELETE FROM run_spools WHERE run_id = %s", (other["id"],))
store.move_run_jobs(conn, host, other["id"], pred["id"])
# Job-ID freigeben, sonst Unique (printer_host, moonraker_job_id) beim Pred.
conn.execute(
"UPDATE runs SET moonraker_job_id = %s WHERE id = %s",
("live:merged:%s" % other["id"], other["id"]),
)
conn.execute("DELETE FROM runs WHERE id = %s", (other["id"],))
# Der Druck läuft weiter. Das Ende kommt vom Folgejob.
conn.execute(
"""
UPDATE runs
SET moonraker_job_id = %s,
ended_at = NULL,
state = CASE
WHEN %s IN ('in_progress', 'printing', 'paused') THEN %s
WHEN state IN ('cancelled', 'interrupted', 'error') THEN 'in_progress'
ELSE state
END
WHERE id = %s
""",
(
str(job_id),
(kept or {}).get("state") or "in_progress",
(kept or {}).get("state") or "in_progress",
pred["id"],
),
)
store.remember_run_job(conn, host, job_id, pred["id"], replace=True)
store.set_printer_active_run(conn, host, pred["id"])
if kept and kept.get("ended_at"):
conn.execute(
"""
UPDATE runs SET
state = %s,
ended_at = COALESCE(%s, ended_at),
duration_s = GREATEST(COALESCE(duration_s, 0), %s),
weight_used_g = GREATEST(COALESCE(weight_used_g, 0), %s),
weight_source = CASE WHEN %s <> '' THEN %s ELSE weight_source END,
filament_used_mm = GREATEST(COALESCE(filament_used_mm, 0), %s),
filament_total_mm = CASE WHEN %s > 0 THEN %s ELSE filament_total_mm END,
progress_last = GREATEST(COALESCE(progress_last, 0), %s),
deducted = false
WHERE id = %s
""",
(
kept["state"],
kept["ended_at"],
kept["duration_s"],
kept["weight_used_g"],
kept["weight_source"],
kept["weight_source"],
kept["filament_used_mm"],
kept["filament_total_mm"],
kept["filament_total_mm"],
kept["progress_last"],
pred["id"],
),
)
elif print_duration_s:
conn.execute(
"""
UPDATE runs
SET duration_s = GREATEST(COALESCE(duration_s, 0), %s),
deducted = false
WHERE id = %s
""",
(float(print_duration_s), pred["id"]),
)
return pred["id"]
def history_in_order(items):
"""Älteste zuerst. Der abgebrochene Teil muss stehen, bevor die Fortsetzung kommt."""
return sorted(
items or [],
key=lambda job: (float(job.get("start_time") or 0), str(job.get("job_id") or "")),
)
def manual_merge_runs(conn, keep_id, absorb_id, confirm=False):
"""Zwei Läufe manuell verbinden. keep überlebt, absorb wird übernommen."""
if not confirm:
raise GrottiError("Verbinden braucht eine Bestätigung.", "bestaetigung")
keep = store.get_run_row(conn, keep_id)
absorb = store.get_run_row(conn, absorb_id)
if not keep or not absorb:
raise GrottiError("Der Lauf fehlt.", "lauf", 404)
if keep["id"] == absorb["id"]:
raise GrottiError("Gleicher Lauf.", "lauf")
if (keep.get("printer_host") or "") != (absorb.get("printer_host") or ""):
raise GrottiError("Nur Läufe desselben Druckers.", "drucker")
if (keep.get("filename") or "") != (absorb.get("filename") or ""):
raise GrottiError("Nur Läufe derselben Datei.", "datei")
# Früherer Start bleibt der überlebende Lauf.
if keep.get("started_at") and absorb.get("started_at") and absorb["started_at"] < keep["started_at"]:
keep, absorb = absorb, keep
host = keep["printer_host"]
undo_deduction(conn, keep)
undo_deduction(conn, absorb)
snapshot = {
"keep_id": keep["id"],
"absorb_id": absorb["id"],
"absorb_job_id": absorb.get("moonraker_job_id"),
}
for table in ("run_samples", "run_segments", "run_spools", "events"):
conn.execute(
"UPDATE %s SET run_id = %%s WHERE run_id = %%s" % table,
(keep["id"], absorb["id"]),
)
try:
conn.execute(
"""
DELETE FROM run_labels AS src
WHERE src.run_id = %s
AND EXISTS (
SELECT 1 FROM run_labels AS dst
WHERE dst.run_id = %s AND dst.object_name = src.object_name
)
""",
(absorb["id"], keep["id"]),
)
conn.execute(
"UPDATE run_labels SET run_id = %s WHERE run_id = %s",
(keep["id"], absorb["id"]),
)
except Exception:
pass
store.move_run_jobs(conn, host, absorb["id"], keep["id"])
job_id = absorb.get("moonraker_job_id") or keep.get("moonraker_job_id")
if job_id and not str(job_id).startswith("live:"):
store.remember_run_job(conn, host, job_id, keep["id"], replace=True)
# Job-ID am absorb freigeben, sonst Unique beim UPDATE auf keep.
conn.execute(
"UPDATE runs SET moonraker_job_id = %s WHERE id = %s",
("live:merged:%s" % absorb["id"], absorb["id"]),
)
absorb_terminal = (absorb.get("state") or "") in TERMINAL
keep_open = (keep.get("state") or "") in ("in_progress", "printing", "paused")
new_state = absorb["state"] if absorb_terminal and not keep_open else (
absorb["state"] if not keep_open else keep["state"]
)
if keep_open or (absorb.get("state") or "") in ("in_progress", "printing", "paused"):
new_state = absorb["state"] if (absorb.get("state") or "") in ("in_progress", "printing", "paused") else "in_progress"
ended = None
else:
ended = absorb.get("ended_at") or keep.get("ended_at")
conn.execute(
"""
UPDATE runs SET
moonraker_job_id = COALESCE(NULLIF(%s, ''), moonraker_job_id),
state = %s,
ended_at = %s,
duration_s = GREATEST(COALESCE(duration_s, 0), COALESCE(%s, 0)),
weight_used_g = GREATEST(COALESCE(weight_used_g, 0), COALESCE(%s, 0)),
filament_used_mm = GREATEST(COALESCE(filament_used_mm, 0), COALESCE(%s, 0)),
progress_last = GREATEST(COALESCE(progress_last, 0), COALESCE(%s, 0)),
deducted = false
WHERE id = %s
""",
(
str(job_id or ""),
new_state,
ended,
float(absorb.get("duration_s") or 0),
float(absorb.get("weight_used_g") or 0),
float(absorb.get("filament_used_mm") or 0),
float(absorb.get("progress_last") or 0),
keep["id"],
),
)
try:
conn.execute(
"UPDATE print_queue SET run_id = %s WHERE run_id = %s",
(keep["id"], absorb["id"]),
)
except Exception:
pass
conn.execute("DELETE FROM runs WHERE id = %s", (absorb["id"],))
store.set_printer_active_run(
conn, host, keep["id"] if new_state in ("in_progress", "printing", "paused") else None
)
fresh = store.get_run_row(conn, keep["id"])
return {"run": store.run_out(fresh), "undo": snapshot}
def choose_spool(conn, host, material, hinted_id, ended_at=None):
"""Spule für History-Abzug. Ohne Hint nur Rollen, die vor Druckende existierten."""
at_host = [
@@ -196,23 +589,28 @@ def apply_deduction(conn, run, energy, metadata=None):
store.close_open_segments(conn, run["id"], None, run.get("filament_used_mm") or 0)
segments = store.list_run_segments(conn, run["id"])
ended = run.get("ended_at")
entries = []
targets = _targets_from_ace(conn, run["printer_host"], ace) if ace else []
targets = _targets_from_ace(conn, run["printer_host"], ace, ended_at=ended) if ace else []
if not targets and run.get("spool_id"):
spool = store.get_spool_row(conn, run["spool_id"])
if spool:
if spool and _spool_valid_for_end(spool, ended):
targets = [spool]
# Abschnitte ohne ACE-Map: externe Rollen nacheinander
if segments and not ace:
final_mm = float(run.get("filament_used_mm") or 0)
total_mm = float(run.get("filament_total_mm") or 0)
deltas = usage.segment_deltas(segments, final_mm)
# Messwert (oder Soll) anteilig nach mm — nicht Soll gegen Soll-Gesamtlänge,
# sonst fehlen Gramm, wenn ein Abschnitt zu früh geschlossen wurde.
base_g = used if used > 0 else planned
split_mm = sum(d for _, _, d in deltas) or final_mm or total_mm
for spool_id, ace_slot, delta_mm in deltas:
spool = store.get_spool_row(conn, spool_id)
if not spool:
continue
grams = usage.grams_from_mm(delta_mm, total_mm, planned)
grams = usage.grams_from_mm(delta_mm, split_mm, base_g)
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
entries.append({
"spool_id": spool_id,
@@ -291,8 +689,10 @@ def apply_deduction(conn, run, energy, metadata=None):
filament = sum(item["cost"] for item in entries)
samples = store.list_run_samples(conn, run["id"])
power = energy_cost(run["duration_s"], energy, samples)
# run_spools vor deducted=true, sonst bleibt die Akte leer bei Abbruch dazwischen
store.replace_run_spools(conn, run["id"], entries)
weight_source = source or (
"estimate" if any(e["is_estimate"] for e in entries) else "measured"
)
# Zuerst deducted=true — nur der Gewinner schreibt Akte und zieht ab.
marked = store.mark_deducted(
conn,
run["id"],
@@ -300,11 +700,11 @@ def apply_deduction(conn, run, energy, metadata=None):
filament,
power,
weight_used_g=used_total,
weight_source=source or ("estimate" if any(e["is_estimate"] for e in entries) else "measured"),
weight_source=weight_source,
)
if not marked:
store.replace_run_spools(conn, run["id"], [])
return False
store.replace_run_spools(conn, run["id"], entries)
for item in entries:
store.deduct_spool(conn, item["spool_id"], item["weight_g"])
store.ensure_run_chargen(conn, run["id"], used_total)
@@ -318,141 +718,160 @@ def apply_deduction(conn, run, energy, metadata=None):
return True
def sync_history(conn, cfg):
def _sync_one_job(conn, cfg, printer, job, watcher_resume=False):
"""Einen History-Job verarbeiten. Gibt 1 zurück wenn abgezogen wurde."""
job_id = str(job.get("job_id") or "")
if not job_id or str(job_id).startswith("live:"):
return 0
filename = job.get("filename") or ""
start_dt = unix_to_dt(job.get("start_time"))
end_dt = unix_to_dt(job.get("end_time"))
duration_s = float(job.get("print_duration") or job.get("total_duration") or 0)
state = job.get("status") or ""
# Leere Hülle: nie speichern. Sobald Dauer da ist, gilt die Beweis-Regel.
if history_shell_is_empty({
"duration_s": duration_s,
"ended_at": end_dt,
"print_duration": duration_s,
"end_time": end_dt,
}) and state not in TERMINAL:
return 0
if store.history_job_already_folded(conn, printer["host"], filename, job_id, end_dt):
return 0
if store.history_job_retired(conn, printer["host"], job_id):
return 0
fold_filament_resume(
conn,
printer["host"],
filename,
job_id,
start_dt,
print_duration_s=duration_s,
watcher_resume=watcher_resume,
)
metadata = job.get("metadata") or {}
planned = weight_of(metadata)
material = material_of(metadata)
used_mm = usage.filament_used_mm(job)
total_mm = usage.filament_total_mm(metadata, job)
estimated = float(metadata.get("estimated_time") or 0)
sent = store.latest_send(conn, printer["host"], filename)
known = store.file_by_name(conn, filename)
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
project_id = (sent or {}).get("project_id") or (known or {}).get("project_id")
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
ended_at = end_dt
if not sent and not hinted:
spool = None
else:
spool = choose_spool(conn, printer["host"], material, hinted, ended_at=ended_at)
terminal = state in TERMINAL
live = store.find_live_run(conn, printer["host"], filename)
progress = float((live or {}).get("progress_last") or 0)
if used_mm <= 0 and live:
used_mm = float(live.get("filament_used_mm") or 0)
if total_mm <= 0 and live:
total_mm = float(live.get("filament_total_mm") or 0)
if planned <= 0 and live:
planned = float(live.get("weight_planned_g") or 0)
objects = (live or {}).get("objects") or []
if isinstance(objects, str):
try:
objects = json.loads(objects)
except json.JSONDecodeError:
objects = []
used_g, source = usage.resolve_used_grams(
planned, used_mm, total_mm, progress, state, duration_s
)
row = store.upsert_run(conn, {
"printer_host": printer["host"],
"printer_name": printer["name"],
"filename": filename,
"file_id": file_id,
"project_id": project_id,
"spool_id": spool["id"] if spool else None,
"state": state,
"started_at": start_dt,
"ended_at": ended_at,
"duration_s": duration_s,
"weight_g": planned,
"weight_planned_g": planned,
"weight_used_g": used_g,
"weight_source": source,
"filament_used_mm": used_mm,
"filament_total_mm": total_mm,
"progress_last": progress,
"estimated_s": estimated,
"energy_cost": energy_cost(duration_s, cfg["energy"]),
"moonraker_job_id": job_id,
"needs_spool": bool(terminal and used_g > 0 and not spool),
"material_type": material,
"message": (job.get("message") or "") if state == "error" else "",
"objects": objects,
})
store.adopt_live_run(conn, printer["host"], filename, row["id"])
row = store.get_run_row(conn, row["id"])
if not terminal:
store.set_printer_active_run(conn, printer["host"], row["id"])
else:
active = store.find_live_run(conn, printer["host"])
if not active:
ended = row.get("ended_at") or ended_at
store.ensure_bed_after_print(
conn, printer["host"], ended_at=ended, message=filename or state
)
store.set_printer_active_run(conn, printer["host"], None)
# Resume nach Filamentpause: History kann den alten Teil schon als „fertig“
# melden. Abzug und Segment-Schluss erst beim echten Druckende.
if watcher_resume:
return 0
if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
return 1
return 0
def deduct_stale_live_runs(conn, cfg, older_than_s=LIVE_DEDUCT_FALLBACK_S):
"""Live-Läufe ohne History nach Frist abziehen (Fertig-Pfad ohne History)."""
rows = store.list_stale_live_runs(conn, older_than_s=older_than_s)
n = 0
for row in rows:
if apply_deduction(conn, row, cfg["energy"]):
n += 1
return n
def sync_history(conn, cfg, host=None, watcher_resume=False):
"""History abgleichen. Serialisiert über Session-Advisory-Lock."""
printers = store.list_printers(conn)
if host:
printers = [p for p in printers if p.get("host") == host]
def fetch(printer):
return printer, moonraker.history(printer["host"])
with ThreadPoolExecutor(max_workers=max(1, len(printers))) as pool:
fetched = list(pool.map(fetch, printers))
with ThreadPoolExecutor(max_workers=max(1, len(printers) or 1)) as pool:
fetched = list(pool.map(fetch, printers)) if printers else []
collected = []
for printer, items in fetched:
for job in items:
for job in history_in_order(items):
collected.append((printer, job))
deducted = 0
for printer, job in collected:
job_id = str(job.get("job_id") or "")
if not job_id or str(job_id).startswith("live:"):
continue
filename = job.get("filename") or ""
metadata = job.get("metadata") or {}
planned = weight_of(metadata)
material = material_of(metadata)
state = job.get("status") or ""
used_mm = usage.filament_used_mm(job)
total_mm = usage.filament_total_mm(metadata, job)
estimated = float(metadata.get("estimated_time") or 0)
sent = store.latest_send(conn, printer["host"], filename)
known = store.file_by_name(conn, filename)
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
project_id = (sent or {}).get("project_id") or (known or {}).get("project_id")
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
ended_at = unix_to_dt(job.get("end_time"))
# Ohne Send-Eintrag keine Rate — sonst zieht eine neue Spule alle alten Jobs ab.
if not sent and not hinted:
spool = None
else:
spool = choose_spool(conn, printer["host"], material, hinted, ended_at=ended_at)
terminal = state in TERMINAL
live = store.find_live_run(conn, printer["host"], filename)
progress = float((live or {}).get("progress_last") or 0)
if used_mm <= 0 and live:
used_mm = float(live.get("filament_used_mm") or 0)
if total_mm <= 0 and live:
total_mm = float(live.get("filament_total_mm") or 0)
if planned <= 0 and live:
planned = float(live.get("weight_planned_g") or 0)
objects = (live or {}).get("objects") or []
if isinstance(objects, str):
try:
objects = json.loads(objects)
except json.JSONDecodeError:
objects = []
used_g, source = usage.resolve_used_grams(
planned, used_mm, total_mm, progress, state, job.get("print_duration") or 0
)
row = store.upsert_run(conn, {
"printer_host": printer["host"],
"printer_name": printer["name"],
"filename": filename,
"file_id": file_id,
"project_id": project_id,
"spool_id": spool["id"] if spool else None,
"state": state,
"started_at": unix_to_dt(job.get("start_time")),
"ended_at": unix_to_dt(job.get("end_time")),
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0),
"weight_g": planned,
"weight_planned_g": planned,
"weight_used_g": used_g,
"weight_source": source,
"filament_used_mm": used_mm,
"filament_total_mm": total_mm,
"progress_last": progress,
"estimated_s": estimated,
"energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]),
"moonraker_job_id": job_id,
"needs_spool": bool(terminal and used_g > 0 and not spool),
"material_type": material,
"message": (job.get("message") or "") if state == "error" else "",
"objects": objects,
})
# Live sofort auf History hängen — auch solange noch in_progress (eine Protokoll-Zeile)
store.adopt_live_run(conn, printer["host"], filename, row["id"])
row = store.get_run_row(conn, row["id"])
if terminal:
# Nachzug nicht während eines neuen Live-Drucks — sonst „Platte belegt“ zu Unrecht
active = store.find_live_run(conn, printer["host"])
if not active:
ended = row.get("ended_at") or unix_to_dt(job.get("end_time"))
store.ensure_bed_after_print(
conn, printer["host"], ended_at=ended, message=filename or state
)
if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
deducted += 1
conn.execute("SELECT pg_advisory_lock(%s)", (SYNC_LOCK_KEY,))
try:
for printer, job in collected:
deducted += _sync_one_job(
conn, cfg, printer, job, watcher_resume=watcher_resume
)
if not watcher_resume:
deducted += deduct_stale_live_runs(conn, cfg)
finally:
conn.execute("SELECT pg_advisory_unlock(%s)", (SYNC_LOCK_KEY,))
return {"gelesen": len(collected), "abgezogen": deducted}
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):
@@ -490,6 +909,105 @@ def prepare_send(conn, file_id, host, confirm, override, spool_id, ace_map=None)
return printer, row, chosen
def _send_via_driver(
conn, cfg, drv, printer, row, chosen, host, ace_map, bed_leveling, progress,
marlin_mode="stream",
):
"""Start über einen Treiber ohne Moonraker. Die Datei bleibt ohne Rinkhals-Kopf."""
attach = getattr(drv, "attach", None)
if attach:
try:
attach(printer)
except moonraker.PrinterError as exc:
raise GrottiError(exc.message or "Der Drucker ist nicht erreichbar.", "offline") from exc
try:
live = drv.query(host)
except moonraker.PrinterError as exc:
raise GrottiError(exc.message or "Der Drucker ist nicht erreichbar.", "offline") from exc
if live.get("state") in ("printing", "paused"):
raise GrottiError("Der Drucker druckt schon.", "belegt", 409)
if (live.get("state") or "").lower() == "error":
raise GrottiError(
"Drucker im Fehlerzustand — auf der Wand „Fehler löschen“, dann nochmal starten.",
"fehler",
409,
)
try:
slots = live.get("slots") or drv.slots(host)
except moonraker.PrinterError:
slots = live.get("slots") or []
mapping = acm.normalize_mapping(ace_map)
filaments = list(row.get("filaments") or [])
if slots and not mapping:
raise GrottiError(
"Dieser Drucker hat ACE/MMU-Plätze. Bitte Farbe der Datei einem ACE-Platz zuordnen.",
"ace",
)
if slots and mapping and not filaments:
filaments = [{"type": "PLA", "color": ""}]
progress("vorbereiten", 30, "Datei vorbereiten…")
filename, content, plate = library.printer_file(
row, conn=conn, data_dir=(cfg or {}).get("data_dir")
)
filename = moonraker.safe_upload_name(filename)
if slots and mapping:
content, mapping, filaments, _ = acm.align_tools_for_ace(content, mapping, filaments)
ace_payload = acm.build_acm(filaments, mapping, slots) if slots and mapping else None
has_trailer = content_has_ace_trailer(content, kind=row.get("kind") or "", plate=plate)
warnings = []
if slots and mapping and not has_trailer and not ace_payload:
warnings.append(acm.TRAILER_WARN)
def upload_progress(sent, total):
if total <= 0:
return
progress(
"hochladen",
45 + int(min(1.0, sent / total) * 19),
"Hochladen zu %s…" % printer["name"],
)
progress("hochladen", 45, "Hochladen zu %s…" % printer["name"])
try:
stored = drv.upload(host, filename, content, on_progress=upload_progress)
progress("starten", 80, "Druck starten…")
start_kwargs = {}
if drv.transport == "marlin":
start_kwargs["mode"] = marlin_mode if marlin_mode in ("stream", "sd") else "stream"
drv.start_print(
host,
stored,
bed_leveling=bool(bed_leveling),
ams_settings=ace_payload,
**start_kwargs,
)
except moonraker.PrinterError as exc:
try:
drv.heaters_off(host)
except Exception:
pass
raise GrottiError(exc.message, "drucker") from exc
progress("buchen", 95, "Auftrag speichern…")
store.add_send(
conn, host, stored, row["id"], row["project_id"], chosen,
ace_map=mapping, bed_leveling=bool(bed_leveling),
)
note = "Gestartet auf %s: %s" % (printer["name"], stored)
if warnings:
note += " · " + " · ".join(warnings)
progress("fertig", 100, note)
return {
"printer": printer["name"],
"filename": stored,
"plate": plate,
"ace": bool(slots and mapping),
"ace_trailer": has_trailer,
"warnings": warnings,
"bed_leveling": bool(bed_leveling),
"bed_leveling_wired": drv.transport == "moonraker",
}
def send_file(
conn,
cfg,
@@ -503,6 +1021,7 @@ def send_file(
ace_map=None,
bed_leveling=False,
keep_motors=False,
marlin_mode="stream",
):
def progress(step, percent, message):
if on_progress:
@@ -525,6 +1044,12 @@ def send_file(
assert_filament_enough(conn, row, chosen, ace_map, host)
progress("pruefen", 15, "Drucker prüfen…")
drv = for_printer(printer)
if drv.transport != "moonraker":
return _send_via_driver(
conn, cfg, drv, printer, row, chosen, host, ace_map, bed_leveling, progress,
marlin_mode=marlin_mode,
)
try:
live = moonraker.query(host)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError, ValueError) as exc:
@@ -627,16 +1152,16 @@ def send_file(
warnings.append(acm.TRAILER_WARN)
progress("ace", 42, acm.TRAILER_WARN)
try:
stored = moonraker.upload(host, filename, content, on_progress=upload_progress)
stored = drv.upload(host, filename, content, on_progress=upload_progress)
if slots and mapping:
ace_payload = acm.build_acm(filaments, mapping, slots)
if ace_payload:
progress("ace", 65, "ACE-Zuordnung hochladen…")
body = acm.acm_bytes(ace_payload)
for acm_name in acm.acm_upload_names(stored):
moonraker.upload(host, acm_name, body)
drv.upload(host, acm_name, body)
progress("starten", 80, "Druck starten…")
moonraker.start_print(
drv.start_print(
host,
stored,
bed_leveling=bool(bed_leveling),
@@ -644,7 +1169,7 @@ def send_file(
)
except moonraker.PrinterError as exc:
try:
moonraker.heaters_off(host)
drv.heaters_off(host)
except Exception:
pass
raise GrottiError(exc.message, "drucker") from exc
@@ -684,7 +1209,7 @@ def control(conn, host, action):
if not printer:
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
try:
moonraker.command(host, action)
for_printer(printer).command(host, action)
except moonraker.PrinterError as exc:
raise GrottiError(exc.message, "drucker") from exc
return {"printer": printer["name"], "action": action}
@@ -694,6 +1219,18 @@ def steuern(conn, host, action, params=None):
printer = store.printer_by_host(conn, host)
if not printer:
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
drv = for_printer(printer)
if action == "clear_error":
try:
drv.clear_print_error(host)
except moonraker.PrinterError as exc:
raise GrottiError(exc.message, "drucker") from exc
return {"printer": printer["name"], "action": action}
if drv.transport != "moonraker":
raise GrottiError(
"Heizung und Plätze laufen an diesem Drucker über den G-code.",
"treiber",
)
params = dict(params) if isinstance(params, dict) else {}
if action == "slot_setzen":
try:
+70 -9
View File
@@ -46,7 +46,14 @@ def sync_watch(conn, cfg, force=False):
now = time.monotonic()
if not force and _LAST_WATCH_SYNC and now - _LAST_WATCH_SYNC < _WATCH_MIN_INTERVAL_S:
return {"gelesen": 0, "hinweis": "", "skipped": True}
known = {}
if not force:
for row in conn.execute(
"SELECT stored_path, size_bytes, mtime FROM files WHERE source = 'watch'"
).fetchall():
known[row["stored_path"]] = (int(row["size_bytes"] or 0), float(row["mtime"] or 0))
seen = []
gelesen = 0
for path in folder.rglob("*"):
if not path.is_file() or skip_path(path, cfg["data_dir"]):
continue
@@ -54,14 +61,26 @@ def sync_watch(conn, cfg, force=False):
if not (lower.endswith(".gcode") or lower.endswith(".3mf")):
continue
try:
resolved = str(path.resolve())
seen.append(resolved)
if not force:
st = path.stat()
prev = known.get(resolved)
if prev and prev[0] == st.st_size and prev[1] == float(st.st_mtime):
continue
store.upsert_scanned(conn, meta_for(path), force=force)
seen.append(str(path.resolve()))
gelesen += 1
except (OSError, zipfile.BadZipFile):
continue
store.forget_missing_watch(conn, seen)
link_watch_files_to_projects(conn, cfg)
dropped = drop_upload_watch_duplicates(conn, cfg)
_LAST_WATCH_SYNC = now
return {"gelesen": len(seen), "hinweis": ""}
result = {"gelesen": gelesen, "hinweis": ""}
if dropped:
result["duplikate"] = dropped
result["hinweis"] = "%d doppelte Upload-Zeile(n) entfernt." % dropped
return result
def _apply_meta(conn, file_id, row, meta):
@@ -77,6 +96,7 @@ def _apply_meta(conn, file_id, row, meta):
"kind": meta.get("kind") or row.get("kind"),
"plate": meta.get("plate") or "",
"slice_meta": meta.get("slice_meta") or {},
"ace_trailer": bool(meta.get("ace_trailer")),
},
)
@@ -95,7 +115,7 @@ def refresh_one_file(conn, file_id, watch_dir=None):
row = store.get_file_row(conn, file_id) or row
except (OSError, zipfile.BadZipFile):
pass
return store.file_out(row, watch_dir=watch_dir)
return store.file_out(row, watch_dir=watch_dir, conn=conn)
def filaments_need_paint_index(filaments):
@@ -276,7 +296,7 @@ def move_file(conn, cfg, file_id, folder):
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / src.name
if dest.resolve() == src.resolve() and source == "watch":
return store.file_out(row, watch_dir=root)
return store.file_out(row, watch_dir=root, conn=conn)
if dest.exists() and dest.resolve() != src.resolve():
dest = unique_path(dest_dir, src.name)
try:
@@ -295,7 +315,7 @@ def move_file(conn, cfg, file_id, folder):
)
_link_file_to_project_folder(conn, file_id, str(dest.resolve()), root)
row = store.get_file_row(conn, file_id)
return store.file_out(row, watch_dir=root)
return store.file_out(row, watch_dir=root, conn=conn)
def path_allowed_for_delete(stored_path, cfg):
@@ -365,7 +385,7 @@ def rename_library_file(conn, cfg, file_id, new_name):
raise GrottiError("Ungültiger Dateiname.", "datei")
dest = src.with_name(final)
if dest.resolve() == src.resolve():
return store.file_out(row, watch_dir=cfg.get("watch_dir"))
return store.file_out(row, watch_dir=cfg.get("watch_dir"), conn=conn)
if dest.exists():
raise GrottiError("Unter diesem Namen liegt schon eine Datei.", "datei")
try:
@@ -382,7 +402,7 @@ def rename_library_file(conn, cfg, file_id, new_name):
mtime=stat.st_mtime,
)
row = store.get_file_row(conn, file_id)
return store.file_out(row, watch_dir=cfg.get("watch_dir"))
return store.file_out(row, watch_dir=cfg.get("watch_dir"), conn=conn)
_PRINT_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
@@ -541,15 +561,56 @@ def delete_project_disk_folder(cfg, folder_rel, force=False):
def save_upload(conn, filename, content, cfg, project_id=None):
name = safe_filename(filename)
twin = store.find_watch_twin(conn, name, len(content or b""))
if twin:
# Orca hat schon in den Slice-Ordner geschrieben — kein zweiter Upload-Eintrag
if project_id and not twin.get("project_id"):
store.set_file_project(conn, twin["id"], project_id)
twin = store.get_file_row(conn, twin["id"]) or twin
return store.file_out(twin, watch_dir=cfg.get("watch_dir"), conn=conn)
directory = cfg["data_dir"] / "eingang"
directory.mkdir(parents=True, exist_ok=True)
path = unique_path(directory, filename)
path = unique_path(directory, name)
path.write_bytes(content)
meta = meta_for(path)
meta["project_id"] = project_id
return store.insert_upload(conn, meta)
def drop_upload_watch_duplicates(conn, cfg):
"""Upload-Zeilen entfernen, die dieselbe Datei schon als Watch (mit Projekt) haben."""
rows = conn.execute(
"""
SELECT u.id AS upload_id, w.id AS watch_id, u.stored_path, u.project_id AS upload_project,
w.project_id AS watch_project
FROM files u
JOIN files w
ON w.source = 'watch'
AND w.name = u.name
AND w.size_bytes = u.size_bytes
WHERE u.source = 'upload'
ORDER BY u.id
"""
).fetchall()
removed = 0
for row in rows:
upload_id = row["upload_id"]
watch_id = row["watch_id"]
if row.get("upload_project") and not row.get("watch_project"):
store.set_file_project(conn, watch_id, row["upload_project"])
store.retarget_file_id(conn, upload_id, watch_id)
stored = row.get("stored_path") or ""
store.delete_file(conn, upload_id)
if stored:
try:
Path(stored).unlink(missing_ok=True)
except OSError:
pass
removed += 1
return removed
def ensure_library_gcode(conn, cfg, host, filename):
"""Wenn die laufende Datei fehlt: einmal vom Drucker nach Grotti ziehen."""
base = (filename or "").replace("\\", "/").split("/")[-1]
@@ -559,7 +620,7 @@ def ensure_library_gcode(conn, cfg, host, filename):
if known:
path = Path(known.get("stored_path") or "")
if path.is_file():
return store.file_out(known)
return store.file_out(known, conn=conn)
from grotti import moonraker
pulled = moonraker.download_gcode(host, base)
+52
View File
@@ -0,0 +1,52 @@
"""Zentraler Logger für Dienst und journald (stdout)."""
from __future__ import annotations
import logging
import sys
_CONFIGURED = False
LEVELS = {
"critical": logging.CRITICAL,
"error": logging.ERROR,
"warning": logging.WARNING,
"warn": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
}
def parse_level(value, default=logging.INFO):
if isinstance(value, int):
return value
text = str(value or "").strip().lower()
return LEVELS.get(text, default)
def setup(level="INFO"):
"""Root-Logger einmalig auf stdout (systemd → journald)."""
global _CONFIGURED
resolved = parse_level(level)
root = logging.getLogger()
if _CONFIGURED:
root.setLevel(resolved)
return root
root.handlers.clear()
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)s [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root.addHandler(handler)
root.setLevel(resolved)
logging.getLogger("waitress").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
_CONFIGURED = True
return root
def get(name="grotti"):
return logging.getLogger(name)
+145
View File
@@ -0,0 +1,145 @@
"""Marlin-Dienst. Lauscht nur auf dem eigenen Rechner.
Start::
python -m grotti.marlin_daemon
Port über ``GROTTI_MARLIN_PORT`` (Standard 8771). Die Web-App spricht ihn
unter ``GROTTI_MARLIN_URL`` an. Der Prozess hält die USB-Ports, auch wenn
Grotti neu startet.
"""
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from grotti.marlin_link import MarlinError, MarlinService
def _read_json(handler):
length = int(handler.headers.get("Content-Length") or 0)
raw = handler.rfile.read(length) if length else b""
if not raw:
return {}
data = json.loads(raw.decode("utf-8"))
if not isinstance(data, dict):
raise MarlinError("Der Aufruf ist kein Objekt.")
return data
def _send(handler, code, payload):
body = json.dumps(payload).encode("utf-8")
handler.send_response(code)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.end_headers()
handler.wfile.write(body)
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
return
def do_GET(self):
service = self.server.service
path = self.path.split("?", 1)[0]
try:
if path == "/gesundheit":
_send(self, 200, {"ok": True})
return
if path == "/status":
device = _query_device(self.path)
_send(self, 200, service.session(device).status())
return
_send(self, 404, {"message": "Unbekannt."})
except MarlinError as exc:
_send(self, 404, {"message": exc.message})
def do_POST(self):
service = self.server.service
path = self.path.split("?", 1)[0]
try:
if path == "/oeffnen":
data = _read_json(self)
service.connect(
data.get("device") or "",
baud=data.get("baud") or 115200,
heads=data.get("heads") or 1,
name=data.get("name") or "",
)
_send(self, 200, service.session(data.get("device")).status())
return
if path == "/datei":
device = _query_device(self.path)
name = self.headers.get("X-Dateiname") or "druck.gcode"
length = int(self.headers.get("Content-Length") or 0)
content = self.rfile.read(length) if length else b""
stored = service.store_file(device, name, content)
_send(self, 200, {"filename": stored})
return
if path == "/start":
data = _read_json(self)
shown = service.start(
data.get("device") or "",
data.get("filename") or "",
(data.get("mode") or "stream").strip().lower(),
)
_send(self, 200, {"filename": shown})
return
if path == "/befehl":
data = _read_json(self)
_befehl(service.session(data.get("device") or ""), data.get("action") or "")
_send(self, 200, service.session(data.get("device")).status())
return
_send(self, 404, {"message": "Unbekannt."})
except MarlinError as exc:
code = 409 if "druckt schon" in exc.message else 400
_send(self, code, {"message": exc.message})
except json.JSONDecodeError:
_send(self, 400, {"message": "Der Aufruf ist kein JSON."})
def _query_device(path):
from urllib.parse import parse_qs, urlsplit
query = parse_qs(urlsplit(path).query)
device = (query.get("device") or [""])[0].strip()
if not device:
raise MarlinError("Die serielle Schnittstelle fehlt.")
return device
def _befehl(session, action):
name = (action or "").strip().lower()
if name in ("pause",):
session.pause()
return
if name in ("fortsetzen", "resume"):
session.resume()
return
if name in ("abbrechen", "cancel"):
session.cancel()
return
if name in ("heizungen_aus", "heaters_off"):
session.heaters_off()
return
if name in ("fehler_loeschen", "clear_error"):
session.clear_error()
return
raise MarlinError("Unbekannte Aktion.")
def serve(port, service=None):
httpd = ThreadingHTTPServer(("127.0.0.1", int(port)), Handler)
httpd.service = service or MarlinService()
return httpd
def main():
port = int(os.environ.get("GROTTI_MARLIN_PORT") or 8771)
httpd = serve(port)
httpd.serve_forever()
if __name__ == "__main__":
main()
+372
View File
@@ -0,0 +1,372 @@
"""Marlin über eine serielle Leitung.
Der Dienst hält den Port. Ein Neustart der Web-App schließt ihn nicht.
Start ist Strom (Zeile, dann ``ok``) oder SD-Karte (``M28``/``M29``, dann ``M24``).
Zwei Köpfe, wie am Tenlog, sind T0 und T1. Die Leitung kennt kein ACE.
"""
import re
import threading
import time
from pathlib import Path
class MarlinError(Exception):
def __init__(self, message):
super().__init__(message)
self.message = message
_TEMP = re.compile(
r"(?<![A-Z])(T\d+|T|B):(-?\d+(?:\.\d+)?)(?:\s*/\s*(-?\d+(?:\.\d+)?))?",
re.IGNORECASE,
)
_SD_BYTE = re.compile(r"SD printing byte\s+(\d+)\s*/\s*(\d+)", re.IGNORECASE)
def split_port(host):
"""``COM5`` oder ``COM5@250000``. Baud sonst 115200."""
text = (host or "").strip()
if "@" in text:
device, raw = text.rsplit("@", 1)
try:
baud = int(raw)
except ValueError as exc:
raise MarlinError("Baudrate hinter @ ist keine Zahl.") from exc
if baud <= 0:
raise MarlinError("Baudrate hinter @ ist keine Zahl.")
return device.strip(), baud
return text, 115200
def gcode_lines(content):
"""Druckzeilen ohne Kommentare und leere Zeilen. ``T0``/``T1`` bleiben."""
if isinstance(content, str):
raw = content
else:
raw = (content or b"").decode("utf-8", "replace")
lines = []
for row in raw.splitlines():
line = row.split(";", 1)[0].strip()
if line:
lines.append(line)
return lines
def sd_filename(name):
"""8.3, wie es ältere Marlin-Karten erwarten."""
stem = Path(name or "").stem
chars = [char for char in stem.upper() if char.isalnum()]
base = "".join(chars)[:8] or "GROTTI"
return base + ".GCO"
def parse_temps(text):
found = {}
for key, cur, target in _TEMP.findall(text or ""):
found[key.upper()] = (float(cur), float(target or 0))
return found
def parse_sd_progress(text):
match = _SD_BYTE.search(text or "")
if not match:
return None
total = int(match.group(2))
if total <= 0:
return None
return int(match.group(1)) / total
class SerialPort:
"""Dünne Hülle um pyserial. Tests geben eine eigene Leitung hinein."""
def __init__(self, raw):
self.raw = raw
def write(self, data):
self.raw.write(data)
def readline(self):
return self.raw.readline()
def close(self):
close = getattr(self.raw, "close", None)
if close:
close()
def open_pyserial(device, baud):
try:
import serial
except ImportError as exc:
raise MarlinError("pyserial fehlt. pip install pyserial") from exc
try:
raw = serial.Serial(device, baudrate=baud, timeout=2)
except Exception as exc:
raise MarlinError(
"Serielle Schnittstelle %s geht nicht auf: %s" % (device, exc)
) from exc
return SerialPort(raw)
class MarlinSession:
def __init__(self, device, port, heads=1, name=""):
self.device = device
self.port = port
self.heads = 2 if int(heads or 1) > 1 else 1
self.name = name or device
self.state = "standby"
self.message = ""
self.filename = ""
self.mode = ""
self.progress = None
self.temps = {}
self._pause = threading.Event()
self._stop = False
self._lock = threading.RLock()
self._thread = None
def alive(self):
return self.port is not None
def status(self):
return {
"ok": self.state not in ("offline", "error"),
"state": self.state,
"message": self.message,
"filename": self.filename,
"mode": self.mode,
"progress": self.progress,
"temps": {key: [cur, target] for key, (cur, target) in self.temps.items()},
"heads": self.heads,
"name": self.name,
}
def transact(self, line, timeout=8):
payload = (line.strip() + "\n").encode("ascii", "replace")
self.port.write(payload)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
raw = self.port.readline()
if not raw:
continue
text = raw.decode("utf-8", "replace").strip()
if not text:
continue
low = text.lower()
temps = parse_temps(text)
if temps:
self.temps.update(temps)
done = parse_sd_progress(text)
if done is not None and self.mode == "sd":
self.progress = done
if "done printing file" in low:
self.state = "complete"
self.progress = 1
if low.startswith("ok"):
return text
if low.startswith("error") or low.startswith("!!") or "unknown command" in low:
raise MarlinError(text)
raise MarlinError("Keine Antwort auf: %s" % line.strip())
def handshake(self):
self.transact("M115", timeout=3)
self.transact("M105", timeout=3)
self.state = "standby"
self.message = ""
def begin(self, filename, lines, mode):
if mode not in ("stream", "sd"):
raise MarlinError("Start ist Strom oder SD-Karte.")
if self.state in ("printing", "paused"):
raise MarlinError("Der Drucker druckt schon.")
if not lines:
raise MarlinError("Die Datei hat keine Druckzeilen.")
self.filename = filename
self.mode = mode
self.progress = 0
self.message = ""
self._stop = False
self._pause.clear()
self.state = "printing"
self._thread = threading.Thread(
target=self._run, args=(list(lines), mode), name="marlin-%s" % self.device, daemon=True,
)
self._thread.start()
def pause(self):
if self.state != "printing":
raise MarlinError("Es läuft kein Druck.")
self._pause.set()
self.state = "paused"
if self.mode == "sd":
with self._lock:
self.transact("M25")
def resume(self):
if self.state != "paused":
raise MarlinError("Der Druck ist nicht pausiert.")
if self.mode == "sd":
with self._lock:
self.transact("M24")
self._pause.clear()
self.state = "printing"
def cancel(self):
self._stop = True
self._pause.clear()
with self._lock:
if self.mode == "sd":
try:
self.transact("M524")
except MarlinError:
try:
self.transact("M25")
except MarlinError:
pass
self.heaters_off()
self.state = "standby"
self.filename = ""
self.progress = None
self.mode = ""
self.message = ""
def heaters_off(self):
with self._lock:
if self.heads > 1:
self.transact("M104 T0 S0")
self.transact("M104 T1 S0")
else:
self.transact("M104 S0")
self.transact("M140 S0")
self.transact("M107")
def clear_error(self):
with self._lock:
self.transact("M999")
self.state = "standby"
self.message = ""
def _run(self, lines, mode):
try:
if mode == "sd":
self._run_sd(lines)
else:
self._run_stream(lines)
except MarlinError as exc:
self.state = "error"
self.message = exc.message
def _wait_if_paused(self):
while self._pause.is_set() and not self._stop:
time.sleep(0.05)
def _run_stream(self, lines):
total = len(lines)
polled = time.monotonic()
for index, line in enumerate(lines, start=1):
self._wait_if_paused()
if self._stop:
return
with self._lock:
self.transact(line)
if time.monotonic() - polled > 2:
self.transact("M105")
polled = time.monotonic()
self.progress = index / total
if not self._stop:
self.state = "complete"
self.progress = 1
def _run_sd(self, lines):
stored = sd_filename(self.filename)
with self._lock:
self.transact("M21")
self.transact("M28 %s" % stored)
for line in lines:
if self._stop:
with self._lock:
self.transact("M29")
return
with self._lock:
self.transact(line)
if self._stop:
return
with self._lock:
self.transact("M29")
self.transact("M23 %s" % stored)
self.transact("M24")
while not self._stop and self.state == "printing":
self._wait_if_paused()
if self._stop or self.state != "printing":
return
time.sleep(2)
with self._lock:
if self._stop or self.state != "printing":
return
text = self.transact("M27")
if self.state == "complete":
return
if "not sd printing" in text.lower() and (self.progress or 0) > 0:
self.state = "complete"
self.progress = 1
return
self.transact("M105")
class MarlinService:
def __init__(self, open_port=None):
self._open_port = open_port or open_pyserial
self.machines = {}
self.files = {}
self._lock = threading.Lock()
def connect(self, device, baud=115200, heads=1, name=""):
device = (device or "").strip()
if not device:
raise MarlinError("Die serielle Schnittstelle fehlt.")
with self._lock:
current = self.machines.get(device)
if current and current.alive():
if heads:
current.heads = 2 if int(heads) > 1 else 1
if name:
current.name = name
return current
port = self._open_port(device, int(baud or 115200))
session = MarlinSession(device, port, heads=heads, name=name)
try:
session.handshake()
except MarlinError:
session.port.close()
raise
with self._lock:
self.machines[device] = session
return session
def session(self, device):
with self._lock:
found = self.machines.get((device or "").strip())
if not found:
raise MarlinError("Dieser Drucker ist am Marlin-Dienst nicht angemeldet.")
return found
def store_file(self, device, filename, content):
self.session(device)
name = Path(filename or "druck.gcode").name
if not name:
raise MarlinError("Der Dateiname fehlt.")
self.files[(device, name)] = content or b""
return name
def start(self, device, filename, mode):
session = self.session(device)
name = Path(filename or "").name
content = self.files.get((device, name))
if content is None:
raise MarlinError("Die Datei liegt nicht im Marlin-Dienst.")
lines = gcode_lines(content)
shown = sd_filename(name) if mode == "sd" else name
session.begin(shown if mode == "sd" else name, lines, mode)
return shown
+194
View File
@@ -0,0 +1,194 @@
"""Startet den Marlin-Dienst und lässt ihn laufen.
Grotti beendet den Prozess nicht. Ein Neustart oder Absturz der Web-App
schließt die USB-Leitung nicht. Lauscht der Dienst schon, wird kein zweiter
gestartet.
"""
import os
import shutil
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
UNIT = "grotti-marlin.service"
_CREATE_BREAKAWAY = 0x01000000
_DETACHED = 0x00000008
_NEW_GROUP = 0x00000200
_NO_WINDOW = 0x08000000
_enabled = False
_supervisor = None
_guard = threading.Lock()
def service_url():
return (os.environ.get("GROTTI_MARLIN_URL") or "http://127.0.0.1:8771").rstrip("/")
def health_check(url=None):
target = (url or service_url()) + "/gesundheit"
try:
with urllib.request.urlopen(target, timeout=0.4) as response:
return response.status == 200
except (OSError, urllib.error.URLError):
return False
def unit_installed(run=None):
if os.name == "nt" or not shutil.which("systemctl"):
return False
run = run or subprocess.run
try:
probe = run(
["systemctl", "cat", UNIT],
capture_output=True,
timeout=3,
check=False,
)
except (OSError, subprocess.SubprocessError):
return False
return getattr(probe, "returncode", 1) == 0
def start_unit(run=None):
"""Eigene systemd-Unit, außerhalb von grotti.service. Rückgabe: gestartet versucht."""
if not unit_installed(run):
return False
systemctl = shutil.which("systemctl") or "systemctl"
run = run or subprocess.run
cmd = [systemctl, "start", UNIT]
if shutil.which("sudo"):
cmd = ["sudo", "-n", *cmd]
try:
run(cmd, capture_output=True, timeout=20, check=False)
except (OSError, subprocess.SubprocessError):
return False
return True
def spawn_detached(data_dir, popen=None):
"""Prozess ohne Konsole und ohne Sitzung von Grotti. Grotti-Ende lässt ihn leben."""
folder = Path(data_dir)
folder.mkdir(parents=True, exist_ok=True)
log_path = folder / "marlin-dienst.log"
log_file = open(log_path, "ab", buffering=0)
env = os.environ.copy()
env["PYTHONPATH"] = str(ROOT) + os.pathsep + env.get("PYTHONPATH", "")
env["PYTHONUNBUFFERED"] = "1"
args = [sys.executable, "-m", "grotti.marlin_daemon"]
popen = popen or subprocess.Popen
kwargs = {
"args": args,
"cwd": str(ROOT),
"env": env,
"stdin": subprocess.DEVNULL,
"stdout": log_file,
"stderr": subprocess.STDOUT,
"close_fds": os.name != "nt",
}
try:
if os.name == "nt":
flags = _DETACHED | _NEW_GROUP | _NO_WINDOW
try:
proc = popen(creationflags=flags | _CREATE_BREAKAWAY, **kwargs)
except OSError:
proc = popen(creationflags=flags, **kwargs)
else:
proc = popen(start_new_session=True, **kwargs)
finally:
log_file.close()
return proc.pid
def launch(data_dir, run=None, popen=None):
if start_unit(run):
return "systemd"
spawn_detached(data_dir, popen=popen)
return "prozess"
class MarlinSupervisor:
def __init__(self, data_dir, healthy=None, start=None, wait_s=4):
self.data_dir = Path(data_dir)
self.healthy = healthy or health_check
self.start = start or (lambda folder: launch(folder))
self.wait_s = wait_s
self.error = ""
self._last_attempt = 0.0
self._lock = threading.Lock()
def status(self):
running = bool(self.healthy())
if running:
self.error = ""
return {"running": running, "message": "" if running else self.error}
def ensure(self):
if self.healthy():
self.error = ""
return {"running": True, "started": False, "message": ""}
with self._lock:
if self.healthy():
self.error = ""
return {"running": True, "started": False, "message": ""}
now = time.monotonic()
if self._last_attempt and now - self._last_attempt < 8:
return {"running": False, "started": False, "message": self.error}
self._last_attempt = now
try:
self.start(self.data_dir)
except OSError as exc:
self.error = "Der Marlin-Dienst ließ sich nicht starten: %s" % exc
return {"running": False, "started": False, "message": self.error}
if self._wait():
self.error = ""
return {"running": True, "started": True, "message": ""}
self.error = "Der Marlin-Dienst ist nicht hochgekommen. Log: %s" % (
self.data_dir / "marlin-dienst.log"
)
return {"running": False, "started": True, "message": self.error}
def _wait(self):
deadline = time.monotonic() + self.wait_s
while time.monotonic() < deadline:
if self.healthy():
return True
time.sleep(0.05)
return bool(self.healthy())
def disable():
global _enabled, _supervisor
with _guard:
_enabled = False
_supervisor = None
def enable(data_dir):
global _enabled, _supervisor
with _guard:
_supervisor = MarlinSupervisor(data_dir)
_enabled = True
return _supervisor
def ensure_if_enabled():
with _guard:
supervisor = _supervisor if _enabled else None
if supervisor:
return supervisor.ensure()
return None
def current_status():
with _guard:
supervisor = _supervisor if _enabled else None
if supervisor:
return supervisor.status()
return {"running": health_check(), "message": ""}
+52 -13
View File
@@ -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.")
+148
View File
@@ -0,0 +1,148 @@
"""Problem-Meldungen: lokal speichern und an den Empfänger auf git.gundel.dev schicken."""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from grotti.version import __version__
DEFAULT_RELAY = "https://git.gundel.dev/hooks/grotti-meldung"
def relay_url(cfg):
raw = (cfg or {}).get("_raw") or {}
return (raw.get("meldung_relay") or DEFAULT_RELAY).strip().rstrip("/")
def create(conn, cfg, *, text, page="", printer_host="", error_text="", user=None):
body = (text or "").strip()
if not body:
raise ValueError("Bitte beschreiben, was nicht stimmt.")
if len(body) > 4000:
body = body[:4000]
user = user or {}
payload = {
"text": body,
"page": (page or "")[:200],
"version": __version__,
"printer_host": (printer_host or "")[:120],
"error_text": (error_text or "")[:2000],
}
relay_ok, relay_message = send_relay(cfg, payload)
row = conn.execute(
"""
INSERT INTO meldungen (
text, page, version, printer_host, error_text,
user_id, username, relay_ok, relay_message
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
""",
(
payload["text"],
payload["page"],
payload["version"],
payload["printer_host"],
payload["error_text"],
user.get("id"),
(user.get("username") or "")[:80],
relay_ok,
(relay_message or "")[:500],
),
).fetchone()
return out(row)
def send_relay(cfg, payload):
url = relay_url(cfg)
if not url:
return False, "Kein Empfänger konfiguriert."
data = json.dumps({
"text": payload.get("text") or "",
"page": payload.get("page") or "",
"version": payload.get("version") or "",
"printer_host": payload.get("printer_host") or "",
"error_text": payload.get("error_text") or "",
}).encode("utf-8")
request = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json", "User-Agent": "Grotti/%s" % __version__},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=12) as response:
raw = response.read().decode("utf-8", "replace")
try:
info = json.loads(raw) if raw else {}
except json.JSONDecodeError:
info = {}
issue = info.get("url") or info.get("html_url") or ""
return True, issue or "Empfänger hat die Meldung angenommen."
except urllib.error.HTTPError as exc:
detail = ""
try:
detail = exc.read().decode("utf-8", "replace")[:200]
except OSError:
detail = str(exc)
return False, "Empfänger: HTTP %s %s" % (exc.code, detail)
except (urllib.error.URLError, TimeoutError, OSError) as exc:
return False, "Empfänger nicht erreichbar: %s" % exc
def list_rows(conn, status=None, limit=50):
limit = max(1, min(200, int(limit or 50)))
if status in ("offen", "erledigt"):
rows = conn.execute(
"""
SELECT * FROM meldungen
WHERE status = %s
ORDER BY created_at DESC
LIMIT %s
""",
(status, limit),
).fetchall()
else:
rows = conn.execute(
"""
SELECT * FROM meldungen
ORDER BY created_at DESC
LIMIT %s
""",
(limit,),
).fetchall()
return [out(row) for row in rows]
def close(conn, meldung_id):
row = conn.execute(
"""
UPDATE meldungen
SET status = 'erledigt', closed_at = now()
WHERE id = %s
RETURNING *
""",
(int(meldung_id),),
).fetchone()
return out(row) if row else None
def out(row):
if not row:
return None
return {
"id": row["id"],
"created_at": row["created_at"].isoformat() if row.get("created_at") else "",
"closed_at": row["closed_at"].isoformat() if row.get("closed_at") else "",
"status": row.get("status") or "offen",
"text": row.get("text") or "",
"page": row.get("page") or "",
"version": row.get("version") or "",
"printer_host": row.get("printer_host") or "",
"error_text": row.get("error_text") or "",
"username": row.get("username") or "",
"relay_ok": row.get("relay_ok"),
"relay_message": row.get("relay_message") or "",
}
+16
View File
@@ -0,0 +1,16 @@
-- Jede Moonraker-Job-ID bleibt einem Lauf zugeordnet, auch nach dem Zusammenlegen.
CREATE TABLE IF NOT EXISTS run_jobs (
printer_host text NOT NULL,
job_id text NOT NULL,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
PRIMARY KEY (printer_host, job_id)
);
CREATE INDEX IF NOT EXISTS run_jobs_run ON run_jobs (run_id);
INSERT INTO run_jobs (printer_host, job_id, run_id)
SELECT printer_host, moonraker_job_id, id
FROM runs
WHERE moonraker_job_id <> ''
AND moonraker_job_id NOT LIKE 'live:%'
ON CONFLICT DO NOTHING;
+6
View File
@@ -0,0 +1,6 @@
-- Last active run per printer (survives Grotti restart during pause)
ALTER TABLE printers
ADD COLUMN IF NOT EXISTS active_run_id bigint REFERENCES runs (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS printers_active_run ON printers (active_run_id)
WHERE active_run_id IS NOT NULL;
+10
View File
@@ -0,0 +1,10 @@
-- Queue item points at the run it started. At most one running job per host.
ALTER TABLE print_queue
ADD COLUMN IF NOT EXISTS run_id bigint REFERENCES runs (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS print_queue_run ON print_queue (run_id)
WHERE run_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS print_queue_one_running_per_host
ON print_queue (host)
WHERE status = 'running' AND host <> '';
+3
View File
@@ -0,0 +1,3 @@
-- ACE-Trailer-Flag einmalig speichern, damit die Dateiliste die Datei nicht erneut öffnet.
ALTER TABLE files
ADD COLUMN IF NOT EXISTS ace_trailer boolean;
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS meldungen (
id bigserial PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
status text NOT NULL DEFAULT 'offen',
text text NOT NULL,
page text NOT NULL DEFAULT '',
version text NOT NULL DEFAULT '',
printer_host text NOT NULL DEFAULT '',
error_text text NOT NULL DEFAULT '',
user_id uuid,
username text NOT NULL DEFAULT '',
relay_ok boolean,
relay_message text NOT NULL DEFAULT '',
closed_at timestamptz
);
CREATE INDEX IF NOT EXISTS meldungen_status_idx ON meldungen (status, created_at DESC);
+4 -1
View File
@@ -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",
+260 -37
View File
@@ -10,7 +10,8 @@ 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.drivers import for_printer
from grotti.db import connect
from grotti.errors import GrottiError
from grotti import time_window
@@ -18,6 +19,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,8 +31,13 @@ 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
# Anycubic meldet kurz cancelled/standby bei LeviQ und Filament-Resume.
TERMINAL_CONFIRM_HITS = 3
AWAIT_CANCEL_HITS = 3
def _ablauf_needs_motors(item):
@@ -111,7 +119,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):
@@ -120,7 +128,8 @@ class PrintQueueDispatcher:
age = self.watcher.age_s()
if cached and age is not None and age <= 30:
return cached
return {row["host"]: row for row in moonraker.snapshot(printers)}
from grotti.drivers import snapshot
return {row["host"]: row for row in snapshot(printers)}
def _auto_enabled(self):
return bool((self.cfg or {}).get("auto_queue", True))
@@ -147,10 +156,20 @@ class PrintQueueDispatcher:
def _auto_retry_enabled(self):
return bool((self.cfg or {}).get("auto_retry", True))
def _retryable_message(self, message):
text = (message or "").lower()
if "abgebrochen" in text:
return False
if "druck abgebrochen" in text:
return False
return True
def _try_auto_retry(self, conn, item, message):
"""Ein automatischer Neuversuch; sonst False."""
"""Ein automatischer Neuversuch; sonst False. Nicht nach Abbruch."""
if not self._auto_retry_enabled():
return False
if not self._retryable_message(message):
return False
retries = int(item.get("retry_count") or 0)
if retries >= 1:
return False
@@ -168,6 +187,23 @@ class PrintQueueDispatcher:
)
return True
def _terminal_hits(self, item_id, kind="end"):
key = "%s:%s" % (item_id, kind)
if not hasattr(self, "_term_hits"):
self._term_hits = {}
self._term_hits[key] = self._term_hits.get(key, 0) + 1
return self._term_hits[key]
def _clear_terminal_hits(self, item_id, kind=None):
if not hasattr(self, "_term_hits"):
return
if kind:
self._term_hits.pop("%s:%s" % (item_id, kind), None)
return
for key in list(self._term_hits):
if key.startswith("%s:" % item_id):
self._term_hits.pop(key, None)
def _fail_stuck_simple_start(self, conn, item, printer):
host = printer["host"]
msg = "Start abgebrochen (Neustart)"
@@ -207,7 +243,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:
@@ -273,18 +309,27 @@ class PrintQueueDispatcher:
lives[host] = live
state = (live.get("state") or "").lower()
if state in BUSY:
# Laufenden Einfach-Druck mitziehen. Sonst bleibt await_print
# stehen, bis der Drucker frei ist — und der Start gilt als Timeout,
# obwohl er längst druckt.
running = store.running_print_on_host(conn, host)
if running and not _has_ablauf(running):
self._advance_simple(conn, running, printer, live)
conn.commit()
continue
if state not in FREE:
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):
@@ -412,22 +457,31 @@ class PrintQueueDispatcher:
bed_leveling=bed_leveling,
)
name = (result or {}).get("filename") or ""
# Abbruch während Upload: Status nicht überschreiben, Druck stoppen.
current = store.get_print_queue_row(wconn, item_id)
if current and (current.get("status") or "") in ("cancelled", "dismissed"):
try:
for_printer(printer_copy).command(host, "cancel")
except Exception:
pass
return
oid = item.get("order_item_id")
if oid:
from grotti import orders
orders.attach_order_item_to_live_run(wconn, host, name, oid)
store.update_print_queue(
wconn,
item_id,
{
"status": "done",
"message": "Gestartet auf %s: %s" % (printer_copy["name"], name),
"host": host,
"phase": "",
"phase_started_at": None,
},
)
live_run = store.find_active_run(wconn, host, name)
fields = {
"status": "running",
"message": "Datei ist auf %s. Druckstart kann etwa eine Minute dauern."
% printer_copy["name"],
"host": host,
"phase": "await_print",
"phase_started_at": "now",
}
if live_run:
fields["run_id"] = live_run["id"]
store.update_print_queue(wconn, item_id, fields)
self._event(
wconn,
host,
@@ -477,6 +531,147 @@ 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"):
self._clear_terminal_hits(item["id"])
store.update_print_queue(
conn,
item["id"],
{
"phase": "print_active",
"phase_started_at": "now",
"message": "Druckt…",
},
)
return
if phase == "await_print" and state == "cancelled":
hits = self._terminal_hits(item["id"], "await_cancel")
if hits >= AWAIT_CANCEL_HITS:
self._clear_terminal_hits(item["id"])
store.update_print_queue(
conn,
item["id"],
{
"status": "error",
"message": "Druck abgebrochen.",
"phase": "",
"phase_started_at": None,
},
)
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"):
self._clear_terminal_hits(item["id"])
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
# Nur complete = fertig. cancelled/standby erst nach Entprellen.
if state == "complete":
self._clear_terminal_hits(item["id"])
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 "",
)
return
if state in ("cancelled", "standby"):
hits = self._terminal_hits(item["id"], "end")
if hits < TERMINAL_CONFIRM_HITS:
return
live2 = live
try:
live2 = moonraker.query(printer["host"])
except Exception:
pass
state2 = (live2.get("state") or "").lower()
if state2 in ("printing", "paused", "heating") or live2.get("print_active"):
self._clear_terminal_hits(item["id"])
return
self._clear_terminal_hits(item["id"])
if state2 == "complete":
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 "",
)
return
# Echter Abbruch — nicht „Fertig.“ und kein Auto-Retry.
store.update_print_queue(
conn,
item["id"],
{
"status": "cancelled",
"message": "Druck abgebrochen.",
"phase": "",
"phase_started_at": None,
},
)
self._event(
conn,
printer["host"],
printer["name"],
"abbruch",
"%s: Druck abgebrochen (%s)"
% (printer["name"], item.get("filename") or "ohne Datei"),
item.get("filename") or "",
)
def _begin_ablauf(self, conn, item, printer, live):
try:
eject.require_kobra_3(printer)
@@ -713,9 +908,31 @@ class PrintQueueDispatcher:
live.get("message") or "Druckerfehler während des Drucks.",
)
return
# Erst weiter, wenn der Job wirklich gelaufen ist — sonst Race
# (noch standby/complete vom vorherigen Auftrag).
if phase == "print_active" and state in ("complete", "standby"):
# Nur complete = Druckende. standby/cancelled entprellen (Filament-Resume).
if phase == "print_active" and state == "complete":
self._clear_terminal_hits(item["id"])
self._next_step(conn, item, printer, live)
return
if phase == "print_active" and state in ("cancelled", "standby"):
hits = self._terminal_hits(item["id"], "ablauf_end")
if hits < TERMINAL_CONFIRM_HITS:
return
try:
live = moonraker.query(printer["host"])
state = (live.get("state") or "").lower()
except Exception:
pass
if state in ("printing", "paused", "heating") or live.get("print_active"):
self._clear_terminal_hits(item["id"])
return
self._clear_terminal_hits(item["id"])
if state == "complete":
self._next_step(conn, item, printer, live)
return
if state == "cancelled":
self._fail_ablauf(conn, item, printer, "Druck abgebrochen.")
return
# standby nach Entprellen: Job wirklich zu Ende
self._next_step(conn, item, printer, live)
return
# Idle schon während des Drucks verlängern — sonst greift M84/Default zu früh.
@@ -835,16 +1052,22 @@ class PrintQueueDispatcher:
)
name = (result or {}).get("filename") or ""
row = store.get_print_queue_row(wconn, item_id) or item
store.update_print_queue(
wconn,
item_id,
{
"phase": "printing",
"phase_started_at": "now",
"file_id": step_copy["file_id"],
"message": self._ablauf_message(row, "druckt %s" % name),
},
)
if (row.get("status") or "") in ("cancelled", "dismissed"):
try:
for_printer(printer_copy).command(printer_copy["host"], "cancel")
except Exception:
pass
return
fields = {
"phase": "printing",
"phase_started_at": "now",
"file_id": step_copy["file_id"],
"message": self._ablauf_message(row, "druckt %s" % name),
}
live_run = store.find_active_run(wconn, printer_copy["host"], name)
if live_run:
fields["run_id"] = live_run["id"]
store.update_print_queue(wconn, item_id, fields)
self._step_event(wconn, row, printer_copy, "druckt %s" % name)
wconn.commit()
except GrottiError as exc:
+33 -2
View File
@@ -1,21 +1,52 @@
"""Die Drucker: Familie aus dem Namen, die Liste selbst liegt in PostgreSQL."""
FAMILY_LABELS = {"kobra_s1": "Kobra S1", "kobra_3": "Kobra 3"}
FAMILY_LABELS = {
"kobra_s1": "Kobra S1",
"kobra_3": "Kobra 3",
"kobra_4": "Kobra 4",
"anycubic_i3": "Anycubic i3",
"tenlog": "Tenlog",
}
# Vorhandene Farm bleibt Moonraker/Rinkhals. Stock und Marlin sind eigene Treiber.
FAMILY_DRIVER = {
"kobra_s1": "moonraker",
"kobra_3": "moonraker",
"kobra_4": "anycubic_lan",
"anycubic_i3": "marlin",
"tenlog": "marlin",
}
def family_from_name(name):
low = name.lower()
low = (name or "").lower()
if "kobra 4" in low or "kobra4" in low:
return "kobra_4"
if "s1" in low:
return "kobra_s1"
if "kobra 3" in low or "kobra3" in low:
return "kobra_3"
if "tenlog" in low:
return "tenlog"
if "i3" in low:
return "anycubic_i3"
return ""
def family_from_text(text):
low = (text or "").lower()
if "kobra 4" in low or "kobra4" in low:
return "kobra_4"
if "kobra s1" in low:
return "kobra_s1"
if "kobra 3" in low or "kobra3" in low:
return "kobra_3"
if "tenlog" in low:
return "tenlog"
if "anycubic i3" in low or re_i3(low):
return "anycubic_i3"
return ""
def re_i3(low):
return " i3" in low or low.startswith("i3")
+7
View File
@@ -57,6 +57,7 @@ class SendQueue:
spool_id,
ace_map=None,
bed_leveling=False,
marlin_mode="stream",
):
jobs_out = self.enqueue_many(
conn,
@@ -67,6 +68,7 @@ class SendQueue:
spool_id,
{host: ace_map or []} if ace_map else {},
bed_leveling=bed_leveling,
marlin_mode=marlin_mode,
)
return jobs_out[0]
@@ -80,6 +82,7 @@ class SendQueue:
spool_id,
ace_maps=None,
bed_leveling=False,
marlin_mode="stream",
):
if not confirm:
raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung")
@@ -96,6 +99,7 @@ class SendQueue:
ace_maps = ace_maps if isinstance(ace_maps, dict) else {}
# Eine Batch-Option für alle Drucker im Multi-Send (nicht pro Host).
want_level = bool(bed_leveling)
mode = marlin_mode if marlin_mode in ("stream", "sd") else "stream"
created = []
for host in unique:
# Mapping zuerst, damit Materialprüfung die ACE-Spulen sieht.
@@ -120,6 +124,7 @@ class SendQueue:
"override_machine": bool(override),
"ace_map": mapping,
"bed_leveling": want_level,
"marlin_mode": mode,
"result": None,
"error": None,
"created_at": time.time(),
@@ -137,6 +142,7 @@ class SendQueue:
"spool_id": chosen,
"ace_map": mapping,
"bed_leveling": want_level,
"marlin_mode": mode,
}
)
created.append(self.snapshot(job_id))
@@ -207,6 +213,7 @@ class SendQueue:
prepared=True,
ace_map=item.get("ace_map"),
bed_leveling=bool(item.get("bed_leveling")),
marlin_mode=item.get("marlin_mode") or "stream",
)
conn.commit()
done_msg = "Gestartet auf %s: %s" % (result["printer"], result["filename"])
+1023 -147
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -1,6 +1,7 @@
"""G-code zu einem dünnen 3D-Pfad für den Viewer."""
import re
import threading
import zipfile
from pathlib import Path
@@ -10,6 +11,8 @@ AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
# Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben)
TOOLPATH_VERSION = 8
# Die volle Bahn liegt kurz im Speicher. Parallel auf einem Pi mit 2 GB füllt das den Swap.
_build_lock = threading.Lock()
def _gcode_text(path: Path) -> str:
@@ -111,6 +114,11 @@ def _thin_points(points, layer_breaks, max_points):
def build_toolpath(path: Path, max_points=220000):
with _build_lock:
return _build_toolpath(path, max_points)
def _build_toolpath(path: Path, max_points=220000):
text = _gcode_text(path)
if not text:
return {
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "3.4.0"
__version__ = "3.7.0"
+199 -79
View File
@@ -6,14 +6,31 @@ import threading
import time
from datetime import datetime, timezone
from grotti import jobs, moonraker, notify, store, usage
from grotti import drivers, 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
def contact_gap_keeps_run(prev_filename, next_filename, next_state):
"""Nach einem Kontaktverlust bleibt der Lauf, solange kein anderer Auftrag läuft.
Ein Timeout ist kein Druckende. Ohne Dateiname gibt es keinen Beweis
für einen Wechsel, der offene Lauf bleibt.
"""
if next_state not in ("printing", "paused"):
return False
prev_filename = prev_filename or ""
next_filename = next_filename or ""
if not prev_filename or not next_filename:
return True
return prev_filename == next_filename
class StateWatcher:
def __init__(self, database_url, cfg, interval=10):
self.database_url = database_url
@@ -21,6 +38,7 @@ class StateWatcher:
self.interval = max(5, int(interval))
self._prev = {}
self._offline_hits = {}
self._cancel_hits = {}
self.latest = []
self._lock = threading.Lock()
self._updated_at = 0.0
@@ -59,7 +77,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):
@@ -69,7 +87,7 @@ class StateWatcher:
if not printers:
conn.commit()
return
lives = moonraker.snapshot(printers)
lives = drivers.snapshot(printers)
with self._lock:
self.latest = lives
self._updated_at = time.monotonic()
@@ -93,40 +111,43 @@ class StateWatcher:
if offline:
hits = self._offline_hits.get(host, 0) + 1
self._offline_hits[host] = hits
if hits >= 2 and not prev_offline:
# Ein Aussetzer ist noch kein Offline. Erst der zweite Poll.
if hits < 2:
return
if not prev_offline:
self._emit(conn, host, name, "offline", "%s ist offline." % name, filename)
# Nach Crash/Shutdown (503/gklib) bleibt sonst „druckt“ im Protokoll.
if hits >= 2:
dangling = store.find_live_run(conn, host)
if dangling:
closed = store.complete_open_run(
conn, dangling["id"], state="interrupted"
)
if closed:
label = dangling.get("filename") or filename or "offline"
self._emit(
conn,
host,
name,
"abbruch",
"%s: Druck unterbrochen (Drucker offline)." % name,
label,
closed["id"],
)
try:
from grotti import jobs
jobs.apply_deduction(conn, closed, self.cfg["energy"])
except Exception as exc:
print("Watcher Abzug offline %s: %s" % (host, exc), flush=True)
self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
# Kontakt weg ist kein Druckende. Der letzte echte Zustand bleibt
# gemerkt, damit fertig/Abbruch/Fehler beim nächsten Poll greifen.
remembered = prev_state if prev_state and prev_state != "offline" else ""
self._prev[host] = {
"state": remembered,
"offline": True,
"heating": bool(prev.get("heating")),
"filename": prev.get("filename") or filename or "",
"spool_warn": bool(prev.get("spool_warn")) if remembered == "printing" else False,
"run_id": prev.get("run_id"),
}
return
self._offline_hits[host] = 0
file_changed = False
if prev_offline and state in ("printing", "paused"):
file_changed = self._release_replaced_run(conn, host, name, filename)
elif state == "printing" and filename and prev_state in (
"printing", "paused", "cancelled", "standby",
):
# Abbruch verpasst oder sofort neu gestartet: alte Datei nicht umbenennen.
existing = store.find_live_run(conn, host)
old_name = (existing or {}).get("filename") or ""
if old_name and old_name != filename:
file_changed = self._release_replaced_run(conn, host, name, filename)
starting = (
bool(prev_state)
and prev_state not in ("printing", "paused")
and state == "printing"
(
bool(prev_state)
and prev_state not in ("printing", "paused")
and state == "printing"
)
or (file_changed and state == "printing")
)
if starting:
# Aufheiz-Proben behalten: Live-Lauf gleicher Datei nicht verwerfen
@@ -152,9 +173,21 @@ class StateWatcher:
force = starting or (heating and not prev.get("heating"))
run = self._track_print(conn, host, name, filename, live, state, force_sample=force)
run_id = run["id"] if run else None
if run_id:
store.set_printer_active_run(conn, host, run_id)
if state not in ("cancelled", "standby"):
self._cancel_hits[host] = 0
if prev_offline:
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename, run_id)
if file_changed and state == "printing":
self._emit(
conn, host, name, "start",
"%s druckt %s" % (name, filename or "einen Auftrag"),
filename, run_id,
)
self._ensure_start_segment(conn, host, filename, live, run_id)
elif starting:
self._emit(
conn, host, name, "start",
@@ -175,9 +208,16 @@ class StateWatcher:
self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
elif prev_state == "paused" and state == "printing":
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id)
try:
jobs.sync_history(conn, self.cfg, host=host, watcher_resume=True)
except Exception as exc:
log.warning("Watcher Abgleich Resume %s: %s", host, exc)
# Nach Fold: aktiven History-Lauf weiter tracken.
open_run = store.find_active_run(conn, host, filename)
if open_run:
run_id = open_run["id"]
store.set_printer_active_run(conn, host, run_id)
elif prev_state == "printing" and state == "complete":
# Live-Übergang: immer belegen (ensure_bed_after_print ohne Endzeit
# würde nach Quittierung eines älteren Drucks stehen bleiben).
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
self._emit(
conn,
@@ -189,53 +229,77 @@ class StateWatcher:
run_id or (prev.get("run_id")),
)
try:
from grotti import jobs
jobs.sync_history(conn, self.cfg)
jobs.sync_history(conn, self.cfg, host=host)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
# History oft ohne Endzeit (interrupted) — Live trotzdem schließen.
log.warning("Watcher Abgleich %s: %s", host, exc)
# Live schließen ohne Abzug — History bucht; Fallback nach Frist.
dangling = store.find_live_run(conn, host)
if dangling:
closed = store.complete_open_run(conn, dangling["id"], state="completed")
if closed:
try:
from grotti import jobs
jobs.apply_deduction(conn, closed, self.cfg["energy"])
except Exception as exc:
print("Watcher Abzug %s: %s" % (host, exc), flush=True)
store.complete_open_run(conn, dangling["id"], state="completed")
store.set_printer_active_run(conn, host, None)
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).
dangling = store.find_live_run(conn, host)
from_active = prev_state in ("printing", "paused")
recover = bool(dangling) and (prev_offline or not prev_state)
if from_active or recover:
hits = self._cancel_hits.get(host, 0) + 1
self._cancel_hits[host] = hits
# Anycubic flackert kurz cancelled/standby während Pause/Resume —
# erst nach 3 Polls aufgeben. Nach Offline-Lücke sofort.
need_hits = 1 if recover else 3
if (from_active or recover) and hits >= need_hits:
file_label = filename or (dangling or {}).get("filename") or state
store.set_bed_needs_clear(conn, host, True, file_label)
store.abandon_live_runs(conn, host)
self._emit(
conn,
host,
name,
"abbruch",
"%s: Druck abgebrochen (%s)" % (
name, file_label if file_label != state else "ohne Datei",
),
file_label if file_label != state else "",
run_id or prev.get("run_id") or (dangling or {}).get("id"),
)
try:
from grotti import jobs
jobs.sync_history(conn, self.cfg)
jobs.sync_history(conn, self.cfg, host=host)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
log.warning("Watcher Abgleich %s: %s", host, exc)
# Nach Sync evtl. schon Fortsetzung — dann nicht aufgeben.
still = store.find_live_run(conn, host) or store.find_open_history_run(
conn, host, filename or (dangling or {}).get("filename") or ""
)
if still and (still.get("state") or "") in ("in_progress", "printing", "paused"):
self._cancel_hits[host] = 0
run_id = still["id"]
store.set_printer_active_run(conn, host, run_id)
else:
store.set_bed_needs_clear(conn, host, True, file_label)
store.abandon_live_runs(conn, host)
store.set_printer_active_run(conn, host, None)
emit_id = run_id or prev.get("run_id") or (dangling or {}).get("id")
label = file_label if file_label != state else ""
if emit_id and not store.get_run_row(conn, emit_id):
# History-Abgleich hat den Live-Lauf übernommen und gelöscht.
emit_id = store.latest_run_id(conn, host, label)
self._emit(
conn,
host,
name,
"abbruch",
"%s: Druck abgebrochen (%s)" % (
name, label or "ohne Datei",
),
label,
emit_id,
)
# prev-Zustand behalten, bis die Hits reichen.
if hits < 3 and from_active:
self._prev[host] = {
"state": prev_state,
"offline": False,
"heating": heating,
"filename": prev.get("filename") or filename,
"spool_warn": bool(prev.get("spool_warn")),
"run_id": run_id or prev.get("run_id"),
}
return
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")
try:
jobs.sync_history(conn, self.cfg, host=host)
except Exception as exc:
log.warning("Watcher Abgleich %s: %s", host, exc)
store.abandon_live_runs(conn, host)
store.set_printer_active_run(conn, host, None)
msg = (live.get("message") or "").strip()
if not msg or msg.lower() in ("error", "fehler"):
msg = "Ursache unbekannt — in Mainsail prüfen"
@@ -249,18 +313,10 @@ class StateWatcher:
filename,
run_id or prev.get("run_id"),
)
try:
from grotti import jobs
jobs.sync_history(conn, self.cfg)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
elif state == "error":
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
self._cool_heaters(host)
elif state == "complete" and (not prev_state or prev_offline):
# Erster Blick nach Neustart: nur mit Endzeit des letzten Laufs,
# sonst wird bereits Quittiertes wieder „Platte belegt“.
ended = conn.execute(
"""
SELECT ended_at FROM runs
@@ -278,6 +334,10 @@ class StateWatcher:
ended_at=ended["ended_at"] if ended else None,
message=filename or "fertig",
)
try:
jobs.sync_history(conn, self.cfg, host=host)
except Exception as exc:
log.warning("Watcher Abgleich %s: %s", host, exc)
spool_warn = bool(prev.get("spool_warn"))
if state == "printing" and not spool_warn:
@@ -292,6 +352,49 @@ class StateWatcher:
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") or heating else None),
}
def _release_replaced_run(self, conn, host, name, filename):
"""Anderer Auftrag nach der Lücke: den offenen Lauf loslassen.
History zuerst, damit Proben am echten Job hängen. Bleibt der alte
Live-Lauf, wird er beendet ohne Abzug — der History-Abgleich bucht
das Filament, sobald der Job da ist.
"""
existing = store.find_live_run(conn, host)
if not existing:
return False
if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"):
return False
label = existing.get("filename") or "ohne Datei"
old_id = existing["id"]
try:
jobs.sync_history(conn, self.cfg, host=host)
except Exception as exc:
log.warning("Watcher Abgleich nach Offline %s: %s", host, exc)
existing = store.find_live_run(conn, host)
if not existing:
# History hat den Live-Lauf übernommen. Ereignis an den überlebenden Lauf.
emit_id = old_id if store.get_run_row(conn, old_id) else store.latest_run_id(conn, host, label)
self._emit(
conn, host, name, "abbruch",
"%s: Druck unterbrochen (%s)" % (name, label),
label, emit_id,
)
return True
if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"):
return False
label = existing.get("filename") or "ohne Datei"
store.abandon_live_runs(conn, host)
self._emit(
conn,
host,
name,
"abbruch",
"%s: Druck unterbrochen (%s)" % (name, label),
label,
existing["id"],
)
return True
def _should_sample(self, conn, host, live, state):
"""Proben bei Druck/Pause und beim Aufheizen mit Bezug zu einem Auftrag."""
if state in ("printing", "paused"):
@@ -407,7 +510,11 @@ class StateWatcher:
})
def _ensure_start_segment(self, conn, host, filename, live, run_id):
"""Nur externe Einzelspule: ein Abschnitt. ACE-Farben ohne Wechsel → Anteil-Split."""
"""Startabschnitte: externe Spule oder ACE-Plätze mit eingelegter Spule.
Ohne Rollenwechsel bleibt ACE-Abzug Anteil-Split (ein Segment je Platz).
Mit Wechsel greift die mm-Aufteilung innerhalb der Farbe (0→jetzt).
"""
if not run_id:
return
existing = store.list_run_segments(conn, run_id)
@@ -418,9 +525,18 @@ class StateWatcher:
from grotti import acm
mapping = acm.normalize_mapping(ace) if ace else []
if mapping:
return
used_mm = usage.as_float(live.get("filament_used"))
if mapping:
seen = set()
for item in mapping:
slot = int(item["ams_index"]) + 1
if slot in seen:
continue
seen.add(slot)
row = store.spool_at_slot(conn, host, slot)
if row:
store.open_segment(conn, run_id, row["id"], slot, used_mm)
return
rows = conn.execute(
"""
SELECT id FROM spools
@@ -436,7 +552,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)
@@ -471,6 +587,10 @@ class StateWatcher:
return True
def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
# Eine gelöschte Lauf-ID (Live wurde gerade übernommen) darf den Tick
# nicht am Fremdschlüssel zurückrollen — sonst ist der Abbruch weg.
if run_id is not None and not store.get_run_row(conn, run_id):
run_id = None
notify.record(
conn, self.cfg, host, printer, kind, message, filename,
dedupe_s=60, run_id=run_id,
+94
View File
@@ -0,0 +1,94 @@
#!/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"
mkdir -p "$STAGE/usr/lib/grotti/scripts"
cp "$ROOT/scripts/pfade_umziehen.py" "$STAGE/usr/lib/grotti/scripts/pfade_umziehen.py"
# Symlink-Pfad für die Anleitung
mkdir -p "$STAGE/opt/grotti"
ln -sfn /usr/lib/grotti/scripts "$STAGE/opt/grotti/scripts"
ln -sfn /usr/lib/grotti/venv "$STAGE/opt/grotti/venv"
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/packaging/grotti-marlin.service" "$STAGE/lib/systemd/system/grotti-marlin.service"
mkdir -p "$STAGE/etc/sudoers.d"
cp "$ROOT/packaging/sudoers.d/grotti-marlin" "$STAGE/etc/sudoers.d/grotti-marlin"
chmod 440 "$STAGE/etc/sudoers.d/grotti-marlin"
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"
+57
View File
@@ -0,0 +1,57 @@
{
"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",
"http_threads": 8,
"auto_queue": true,
"auto_retry": true,
"oauth": {
"enabled": false,
"issuer": "",
"client_id": "",
"client_secret": "",
"redirect_uri": ""
},
"energy": {
"watts": 40,
"nozzle_watts": 60,
"bed_watts": 220,
"euro_per_kwh": 0.35,
"dryer_watts": 0,
"dry_hours": 0,
"waste_percent": 0,
"utilization": 50,
"tariff": {
"ht_euro": 0.35,
"nt_euro": 0.22,
"nt_from": "22:00",
"nt_to": "06:00"
}
},
"telegram": {
"enabled": false,
"bot_token": "",
"chat_id": ""
},
"ntfy": {
"enabled": false,
"server": "https://ntfy.sh",
"topic": "",
"token": ""
},
"alerts": {
"fertig": true,
"fehler": true,
"offline": true,
"queue": true,
"spule": true,
"ablauf": false,
"start": false
}
}
+1
View File
@@ -0,0 +1 @@
/etc/grotti/config.json
+51
View File
@@ -0,0 +1,51 @@
#!/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 getent group dialout >/dev/null 2>&1; then
usermod -aG dialout grotti || true
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
systemctl enable grotti.service grotti-marlin.service || true
# start, nicht restart: ein laufender USB-Druck bleibt.
systemctl start grotti-marlin.service || true
systemctl restart grotti.service || true
fi
;;
esac
exit 0
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
set -e
case "$1" in
purge)
rm -f /etc/grotti/config.json
rmdir /etc/grotti 2>/dev/null || true
# Daten und DB bleiben absichtlich (Backup). Manuell:
# sudo rm -rf /var/lib/grotti
# sudo -u postgres dropdb grotti; sudo -u postgres dropuser grotti
;;
remove)
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
fi
;;
esac
exit 0
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -e
case "$1" in
remove|upgrade|deconfigure)
if command -v systemctl >/dev/null 2>&1; then
systemctl stop grotti.service 2>/dev/null || true
systemctl disable grotti.service 2>/dev/null || true
fi
;;
esac
exit 0
View File
Whitespace-only changes.
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Grotti Marlin über USB
After=network.target
[Service]
Type=simple
User=grotti
Group=grotti
SupplementaryGroups=dialout
WorkingDirectory=/usr/lib/grotti
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONPATH=/usr/lib/grotti
ExecStart=/usr/lib/grotti/venv/bin/python -m grotti.marlin_daemon
Restart=on-failure
RestartSec=2
KillMode=process
[Install]
WantedBy=multi-user.target
+28
View File
@@ -0,0 +1,28 @@
[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
# USB-Marlin liegt in grotti-marlin.service und bleibt bei einem Neustart hier stehen.
# Stopp nicht 90 s hängen: nach TimeoutStopSec folgt SIGKILL
TimeoutStopSec=20
KillMode=mixed
# Logs → journald (journalctl -u grotti -f)
StandardOutput=journal
StandardError=journal
SyslogIdentifier=grotti
[Install]
WantedBy=multi-user.target
+1
View File
@@ -0,0 +1 @@
grotti ALL=(root) NOPASSWD: /usr/bin/systemctl start grotti-marlin.service, /bin/systemctl start grotti-marlin.service
+2 -1
View File
@@ -4,5 +4,6 @@ psycopg-pool==3.3.3
paho-mqtt==2.1.0
cryptography==50.0.2
markdown==3.11.1
mcp>=1.2
mcp==2.0.0
waitress==3.0.2
pyserial==3.5
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Alt-Paare und verwaiste Live-Läufe finden. Standard: Probelauf.
python scripts/laeufe_reparieren.py
python scripts/laeufe_reparieren.py --apply
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti import jobs, store # noqa: E402
from grotti.config import load # noqa: E402
from grotti.db import connect, prepare # noqa: E402
def find_pairs(conn):
return conn.execute(
"""
SELECT a.id AS pred_id, b.id AS succ_id,
a.printer_host, a.filename,
a.state AS pred_state, b.state AS succ_state,
a.duration_s AS pred_dur, b.duration_s AS succ_dur,
a.ended_at, b.started_at,
a.deducted AS pred_deducted, b.deducted AS succ_deducted,
a.chargen_nr AS pred_chargen, b.chargen_nr AS succ_chargen
FROM runs a
JOIN runs b
ON a.printer_host = b.printer_host
AND a.filename = b.filename
AND a.id <> b.id
AND b.started_at BETWEEN a.ended_at - interval '30 seconds'
AND a.ended_at + interval '5 seconds'
WHERE a.state IN ('cancelled', 'interrupted', 'error')
AND a.ended_at IS NOT NULL
AND a.moonraker_job_id NOT LIKE 'live:%%'
AND b.moonraker_job_id NOT LIKE 'live:%%'
AND COALESCE(b.duration_s, 0) > 60
AND COALESCE(b.duration_s, 0) >= COALESCE(a.duration_s, 0) - 30
ORDER BY a.ended_at DESC
"""
).fetchall()
def find_orphan_live(conn):
return conn.execute(
"""
SELECT id, printer_host, filename, state, started_at, ended_at, duration_s, chargen_nr
FROM runs
WHERE moonraker_job_id LIKE 'live:%%'
AND state IN ('interrupted', 'completed', 'cancelled')
ORDER BY COALESCE(ended_at, started_at) DESC
LIMIT 100
"""
).fetchall()
def main():
parser = argparse.ArgumentParser(description="Läufe nach Filament-Resume reparieren")
parser.add_argument("--apply", action="store_true", help="Wirklich zusammenlegen")
args = parser.parse_args()
cfg = load()
prepare(cfg["database_url"])
with connect(cfg["database_url"]) as conn:
pairs = find_pairs(conn)
orphans = find_orphan_live(conn)
print("=== Verdachtspaare (Beweis: Dauer fortgesetzt, Lücke ≤ 5 s) ===")
for row in pairs:
print(json.dumps({k: str(v) for k, v in dict(row).items()}, ensure_ascii=False))
print("%d Paare" % len(pairs))
print("=== Verwaiste live:-Läufe ===")
for row in orphans:
print(json.dumps({k: str(v) for k, v in dict(row).items()}, ensure_ascii=False))
print("%d orphan" % len(orphans))
if not args.apply:
print("Probelauf. Mit --apply werden Paare verbunden.")
conn.rollback()
return
merged = 0
for row in pairs:
try:
jobs.manual_merge_runs(
conn, row["pred_id"], row["succ_id"], confirm=True
)
merged += 1
print("verbunden", row["pred_id"], "+", row["succ_id"])
except Exception as exc:
print("Fehler", row["pred_id"], row["succ_id"], exc)
conn.rollback()
# Orphans nur an History derselben Datei hängen (kein Host-Fallback).
# adopt_live_run nimmt geschlossene live: nur ≤ 2 h — ältere bleiben liegen.
cleaned = 0
skipped = 0
for row in orphans:
fname = (row.get("filename") or "").strip()
if not fname:
skipped += 1
continue
hist = conn.execute(
"""
SELECT * FROM runs
WHERE printer_host = %s
AND filename = %s
AND moonraker_job_id NOT LIKE 'live:%%'
AND state IN ('in_progress', 'printing', 'paused')
ORDER BY id DESC
LIMIT 1
""",
(row["printer_host"], fname),
).fetchone()
if not hist:
skipped += 1
continue
store.adopt_live_run(conn, row["printer_host"], fname, hist["id"])
still = conn.execute(
"SELECT 1 FROM runs WHERE id = %s", (row["id"],)
).fetchone()
if still:
skipped += 1
else:
cleaned += 1
conn.commit()
print(
"verbunden=%d orphan_adopted=%d orphan_skipped=%d"
% (merged, cleaned, skipped)
)
if __name__ == "__main__":
main()
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Empfänger für Grotti-Meldungen → Issue auf Forgejo/Gitea.
Läuft getrennt von den Installationen. Der Token bleibt hier.
export FORGEJO_URL=https://git.gundel.dev
export FORGEJO_TOKEN=...
export FORGEJO_REPO=dg/grotti
python scripts/meldung_relay.py
Port über RELAY_PORT (Standard 8780). Vor Forgejo mit Reverse-Proxy unter
/hooks/grotti-meldung legen.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
def env(name, default=""):
return (os.environ.get(name) or default).strip()
def create_issue(title, body):
base = env("FORGEJO_URL", "https://git.gundel.dev").rstrip("/")
repo = env("FORGEJO_REPO", "dg/grotti")
token = env("FORGEJO_TOKEN")
if not token:
raise RuntimeError("FORGEJO_TOKEN fehlt.")
url = "%s/api/v1/repos/%s/issues" % (base, repo)
payload = json.dumps({"title": title[:120], "body": body}).encode("utf-8")
request = urllib.request.Request(
url,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": "token %s" % token,
},
method="POST",
)
with urllib.request.urlopen(request, timeout=20) as response:
data = json.loads(response.read().decode("utf-8"))
return data.get("html_url") or data.get("url") or ""
def issue_body(data):
lines = [
data.get("text") or "",
"",
"---",
"Version: %s" % (data.get("version") or "—"),
"Seite: %s" % (data.get("page") or "—"),
"Drucker: %s" % (data.get("printer_host") or "—"),
]
err = (data.get("error_text") or "").strip()
if err:
lines.extend(["", "Fehlertext:", "```", err[:2000], "```"])
return "\n".join(lines)
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
return
def do_GET(self):
if self.path.split("?", 1)[0] in ("/", "/gesundheit", "/hooks/grotti-meldung"):
self._json(200, {"ok": True})
return
self._json(404, {"message": "Unbekannt."})
def do_POST(self):
path = self.path.split("?", 1)[0]
if path not in ("/", "/hooks/grotti-meldung"):
self._json(404, {"message": "Unbekannt."})
return
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
data = json.loads(raw.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError):
self._json(400, {"message": "Kein JSON."})
return
if not isinstance(data, dict) or not (data.get("text") or "").strip():
self._json(400, {"message": "text fehlt."})
return
title = "Grotti: %s" % ((data.get("text") or "").strip().splitlines()[0][:80])
try:
url = create_issue(title, issue_body(data))
except Exception as exc:
self._json(502, {"message": str(exc)})
return
self._json(201, {"ok": True, "url": url})
def _json(self, code, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def main():
port = int(env("RELAY_PORT", "8780") or 8780)
httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
httpd.serve_forever()
if __name__ == "__main__":
main()
+25 -6
View File
@@ -94,7 +94,14 @@ def load_config(path: Path = CONFIG_PATH) -> dict[str, str]:
browser = str(data.get("browser") or "app").strip().lower() or "app"
if browser not in ("app", "tab"):
browser = "app"
return {"url": url, "token": token, "browser": browser}
# Windows-Pfad des Slice-Ordners, wenn Grotti auf dem Pi läuft (dort anderer Pfad)
watch_local = str(data.get("watch_dir_local") or data.get("watch_dir") or "").strip()
return {
"url": url,
"token": token,
"browser": browser,
"watch_dir_local": watch_local,
}
def is_gcode_path(path: Path) -> bool:
@@ -287,6 +294,16 @@ def fetch_watch_dir(base_url: str, token: str) -> Path | None:
return path if path.is_dir() else None
def resolve_watch_dir(config: dict) -> Path | None:
"""Lokaler Slice-Ordner: Config zuerst, sonst der Pfad von Grotti (gleiche Maschine)."""
local = str(config.get("watch_dir_local") or "").strip()
if local:
path = Path(local)
if path.is_dir():
return path
return fetch_watch_dir(config["url"], config["token"])
def under_watch(watch_dir: Path, target: Path) -> tuple[str, str] | None:
"""(folder_relativ, dateiname) wenn target unter watch_dir liegt."""
try:
@@ -450,9 +467,11 @@ def open_browser(
webbrowser.open(url)
def deliver(base_url: str, token: str, src: Path) -> int:
def deliver(config: dict, src: Path) -> int:
"""Datei in Slice-Unterordner legen oder hochladen; gibt file_id zurück."""
watch = fetch_watch_dir(base_url, token)
base_url = config["url"]
token = config["token"]
watch = resolve_watch_dir(config)
target = output_target()
if watch is not None and target is not None:
placed = under_watch(watch, target)
@@ -482,10 +501,10 @@ def main(argv: list[str] | None = None) -> int:
path = find_gcode_arg(argv)
write_debug(
argv,
"chosen=%s\nupload_as=%s\noutput=%s"
% (path, upload_name_for(path), output_target() or ""),
"chosen=%s\nupload_as=%s\noutput=%s\nwatch_local=%s"
% (path, upload_name_for(path), output_target() or "", config.get("watch_dir_local") or ""),
)
file_id = deliver(config["url"], config["token"], path)
file_id = deliver(config, path)
name = upload_name_for(path)
write_debug(argv, "file_id=%s\nupload_as=%s" % (file_id, name))
mode = str(config.get("browser") or "app").strip().lower()
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Absolute Windows-Pfade in files.stored_path und projects.folder auf Linux umschreiben.
Beispiel (Dry-Run):
python scripts/pfade_umziehen.py \\
--database-url postgresql:///grotti?host=/var/run/postgresql \\
--from "D:/Projekte/3DP/Slice" --to /var/lib/grotti/slice
Ohne --apply nur Anzeige. Mit --apply werden die Zeilen geschrieben.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def normalize_slash(path: str) -> str:
return (path or "").replace("\\", "/").rstrip("/")
def rewrite(value: str, old: str, new: str) -> str | None:
if not value:
return None
cur = value.replace("\\", "/")
old_n = normalize_slash(old)
new_n = normalize_slash(new)
# case-insensitive für Windows-Laufwerke
if cur.lower().startswith(old_n.lower()):
rest = cur[len(old_n) :]
if rest and not rest.startswith("/"):
rest = "/" + rest
return new_n + rest
return None
def main() -> int:
parser = argparse.ArgumentParser(description="Slice-Pfade Windows → Linux umschreiben")
parser.add_argument("--database-url", required=True)
parser.add_argument("--from", dest="old", required=True, help="Alter Prefix (Windows)")
parser.add_argument("--to", dest="new", required=True, help="Neuer Prefix (Linux)")
parser.add_argument("--apply", action="store_true", help="Wirklich schreiben")
args = parser.parse_args()
import psycopg
from psycopg.rows import dict_row
old = args.old
new = args.new
changed_files = 0
changed_projects = 0
with psycopg.connect(args.database_url, row_factory=dict_row) as conn:
files = conn.execute("SELECT id, stored_path FROM files ORDER BY id").fetchall()
for row in files:
nxt = rewrite(row["stored_path"] or "", old, new)
if nxt is None or nxt == row["stored_path"]:
continue
changed_files += 1
print("file %s: %s → %s" % (row["id"], row["stored_path"], nxt))
if args.apply:
conn.execute("UPDATE files SET stored_path = %s WHERE id = %s", (nxt, row["id"]))
projects = conn.execute("SELECT id, folder FROM projects ORDER BY id").fetchall()
for row in projects:
folder = row["folder"] or ""
# folder ist relativ — nur absolute Reste bereinigen
nxt = rewrite(folder, old, new)
if nxt is None:
continue
# relativ zum neuen Slice-Root speichern, falls möglich
new_n = normalize_slash(new)
if nxt.lower().startswith(new_n.lower()):
rel = nxt[len(new_n) :].lstrip("/")
nxt = rel
changed_projects += 1
print("project %s: %s → %s" % (row["id"], folder, nxt))
if args.apply:
conn.execute("UPDATE projects SET folder = %s WHERE id = %s", (nxt, row["id"]))
if args.apply:
conn.commit()
print("Geschrieben: %s Dateien, %s Projekte." % (changed_files, changed_projects))
else:
print(
"Dry-Run: %s Dateien, %s Projekte würden sich ändern. Mit --apply ausführen."
% (changed_files, changed_projects)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+19 -7
View File
@@ -7,10 +7,13 @@ import time
from waitress import serve
from grotti import db, jobs, library, store
from grotti import db, jobs, library, logutil, store
from grotti.marlin_supervisor import enable as enable_marlin
from grotti.config import load
from web.app import create_app
log = logutil.get("grotti.server")
def background(cfg):
def work():
@@ -20,10 +23,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 +42,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"])
@@ -60,6 +64,7 @@ def main():
watcher = app.extensions["watcher"]
dispatcher = app.extensions["print_queue"]
send_queue = app.extensions["send_queue"]
enable_marlin(cfg["data_dir"])
watcher.start()
dispatcher.start()
if hasattr(send_queue, "start"):
@@ -99,6 +104,7 @@ def main():
except Exception:
pass
db.close_pool()
# Der Marlin-Dienst bleibt. Ein Stopp hier würde den Druck abbrechen.
atexit.register(shutdown)
for sig in (signal.SIGINT, signal.SIGTERM):
@@ -107,8 +113,14 @@ def main():
except (ValueError, OSError):
pass
print("Grotti: http://%s:%s/" % (cfg["host"], cfg["port"]), flush=True)
serve(app, host=cfg["host"], port=cfg["port"], threads=16, channel_timeout=120)
log.info("Grotti: http://%s:%s/", cfg["host"], cfg["port"])
serve(
app,
host=cfg["host"],
port=cfg["port"],
threads=int(cfg.get("http_threads") or 16),
channel_timeout=120,
)
if __name__ == "__main__":
+128
View File
@@ -0,0 +1,128 @@
"""Treiberwahl und die LAN-Karte. Kein echter Drucker."""
import sys
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti.drivers import for_printer
from grotti.drivers.anycubic_lan import card_from_info, slots_from_report
from grotti.moonraker import PrinterError
from grotti.printers import family_from_name
class FamilyTests(unittest.TestCase):
def test_existing_names_stay_moonraker(self):
for name, family in (
("Kobra 3 A1", "kobra_3"),
("Kobra S1", "kobra_s1"),
):
self.assertEqual(family_from_name(name), family)
drv = for_printer({"name": name, "family": family, "host": "10.0.0.1"})
self.assertEqual(drv.transport, "moonraker")
self.assertTrue(drv.rinkhals)
self.assertFalse(drv.firmware_restart)
def test_kobra_4_is_lan(self):
self.assertEqual(family_from_name("Kobra 4"), "kobra_4")
drv = for_printer({"family": "kobra_4", "host": "10.0.0.4"})
self.assertEqual(drv.transport, "anycubic_lan")
self.assertFalse(drv.rinkhals)
def test_i3_is_marlin(self):
self.assertEqual(family_from_name("Anycubic i3 Mega"), "anycubic_i3")
drv = for_printer({"family": "anycubic_i3", "host": "usb"})
self.assertEqual(drv.transport, "marlin")
def test_tenlog_is_marlin_with_two_heads(self):
self.assertEqual(family_from_name("Tenlog"), "tenlog")
drv = for_printer({"family": "tenlog", "host": "COM5"})
self.assertEqual(drv.transport, "marlin")
self.assertFalse(drv.rinkhals)
class LanCardTests(unittest.TestCase):
def test_busy_with_file_is_printing(self):
card = card_from_info({
"state": "busy",
"temp": {"curr_nozzle_temp": 210, "curr_hotbed_temp": 60},
"project": {"filename": "teil.gcode", "progress": 40, "curr_layer": 3, "total_layers": 10},
})
self.assertEqual(card["state"], "printing")
self.assertEqual(card["filename"], "teil.gcode")
self.assertAlmostEqual(card["progress"], 0.4)
self.assertEqual(card["nozzle"], 210)
self.assertEqual(card["layer"], 3)
self.assertFalse(card["has_camera"])
self.assertIsNone(card["mesh"])
def test_free_is_standby(self):
card = card_from_info({"state": "free", "project": None})
self.assertEqual(card["state"], "standby")
self.assertEqual(card["filename"], "")
def test_slots_from_boxes(self):
slots = slots_from_report({
"multi_color_box": [{
"slots": [
{"type": "PETG"},
{"type": "PLA"},
{"type": ""},
{"type": "ABS"},
],
}],
})
self.assertEqual([item["type"] for item in slots], ["PETG", "PLA", "ABS"])
self.assertEqual([item["index"] for item in slots], [0, 1, 3])
class LanUploadTests(unittest.TestCase):
def test_posts_filename_and_gcode(self):
captured = {}
class Response:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self):
return b"{}"
def fake_urlopen(request, timeout=0):
captured["url"] = request.full_url
captured["body"] = request.data
captured["length"] = request.headers["X-file-length"]
return Response()
info = {"urls": {"fileUploadurl": "http://10.0.0.4:18910/gcode_upload?s=token"}}
drv = for_printer({"family": "kobra_4", "host": "10.0.0.4"})
with mock.patch("grotti.anycubic_lan.print_info", return_value=info), \
mock.patch("urllib.request.urlopen", fake_urlopen):
name = drv.upload("10.0.0.4", "teil.gcode", b"G1 X1")
self.assertEqual(name, "teil.gcode")
self.assertIn("/gcode_upload", captured["url"])
self.assertIn(b'name="filename"', captured["body"])
self.assertIn(b'name="gcode"', captured["body"])
self.assertIn(b"G1 X1", captured["body"])
self.assertEqual(captured["length"], "5")
def test_missing_url(self):
drv = for_printer({"family": "kobra_4"})
with mock.patch("grotti.anycubic_lan.print_info", return_value={"urls": {}}):
with self.assertRaises(PrinterError):
drv.upload("10.0.0.4", "teil.gcode", b"G1")
class MarlinStubTests(unittest.TestCase):
def test_start_says_service_missing(self):
drv = for_printer({"family": "anycubic_i3", "name": "i3", "host": "COM3"})
cards = drv.snapshot([{"name": "i3", "host": "COM3", "family": "anycubic_i3"}])
self.assertEqual(cards[0]["state"], "offline")
self.assertIn("läuft nicht", cards[0]["message"])
with self.assertRaises(PrinterError):
drv.start_print("COM3", "teil.gcode")
+107
View File
@@ -0,0 +1,107 @@
"""Leere History-Hülle, Resume-Beweis und Chargen-Tag."""
import unittest
from datetime import date, datetime, timedelta, timezone
from unittest.mock import MagicMock
from grotti.jobs import (
history_in_order,
history_shell_is_empty,
is_filament_resume,
)
from grotti.store import chargen_day, history_job_retired
class HistoryShellTests(unittest.TestCase):
def test_pause_shell_is_empty(self):
self.assertTrue(history_shell_is_empty({"duration_s": 0, "ended_at": None}))
def test_recorded_cancel_is_not_a_shell(self):
self.assertFalse(history_shell_is_empty({
"duration_s": 653,
"ended_at": datetime(2026, 10, 18, 13, 18, tzinfo=timezone.utc),
}))
def test_missing_row_is_not_a_shell(self):
self.assertFalse(history_shell_is_empty(None))
class FilamentResumeProofTests(unittest.TestCase):
def test_duration_continued_with_tiny_gap(self):
# 2026-10-25 Kobra 3 A1: 000040 → 000041, 0,04 s Lücke, gleiche print_duration
end = datetime(2026, 10, 25, 5, 48, 27, 569483, tzinfo=timezone.utc)
start = end + timedelta(milliseconds=40)
self.assertTrue(is_filament_resume(
end, start, prev_duration_s=11711, new_duration_s=11711
))
def test_fresh_restart_after_cancel_is_not_resume(self):
# Lauf 27941: 8 s abgebrochen, 45 s später Neustart mit Dauer ~0
end = datetime(2026, 10, 24, 11, 46, 8, tzinfo=timezone.utc)
start = end + timedelta(seconds=45)
self.assertFalse(is_filament_resume(
end, start, prev_duration_s=8, new_duration_s=5
))
def test_two_arg_call_without_proof_is_false(self):
end = datetime(2026, 10, 21, 19, 32, 20, tzinfo=timezone.utc)
start = end + timedelta(milliseconds=40)
self.assertFalse(is_filament_resume(end, start))
def test_watcher_signal_allows_longer_gap(self):
end = datetime(2026, 10, 25, 1, 0, 0, tzinfo=timezone.utc)
start = end + timedelta(hours=4)
self.assertTrue(is_filament_resume(
end, start, prev_duration_s=1000, new_duration_s=1000, watcher_resume=True
))
def test_duration_reset_is_not_resume(self):
end = datetime(2026, 10, 25, 5, 48, 27, tzinfo=timezone.utc)
start = end + timedelta(milliseconds=40)
self.assertFalse(is_filament_resume(
end, start, prev_duration_s=11711, new_duration_s=12
))
class HistoryOrderTests(unittest.TestCase):
def test_cancelled_part_before_resume(self):
items = [
{"job_id": "000041", "start_time": 1792907307.607},
{"job_id": "000040", "start_time": 1792879062.392},
]
self.assertEqual([j["job_id"] for j in history_in_order(items)], ["000040", "000041"])
def test_missing_start_first(self):
items = [{"job_id": "b", "start_time": 5}, {"job_id": "a"}]
self.assertEqual([j["job_id"] for j in history_in_order(items)], ["a", "b"])
class HistoryJobRetiredTests(unittest.TestCase):
def _conn(self, row):
conn = MagicMock()
conn.execute.return_value.fetchone.return_value = row
return conn
def test_folded_predecessor_is_retired(self):
conn = self._conn({"moonraker_job_id": "000041"})
self.assertTrue(history_job_retired(conn, "h", "000040"))
def test_own_job_is_not_retired(self):
conn = self._conn({"moonraker_job_id": "000041"})
self.assertFalse(history_job_retired(conn, "h", "000041"))
def test_unknown_job_is_not_retired(self):
self.assertFalse(history_job_retired(self._conn(None), "h", "000042"))
class ChargenDayTests(unittest.TestCase):
def test_old_print_keeps_its_day(self):
started = datetime(2026, 10, 18, 13, 4, 16, tzinfo=timezone.utc)
self.assertEqual(chargen_day(started, today=date(2026, 10, 24)), date(2026, 10, 18))
def test_without_start_uses_today(self):
self.assertEqual(chargen_day(None, today=date(2026, 10, 24)), date(2026, 10, 24))
if __name__ == "__main__":
unittest.main()
+25
View File
@@ -69,13 +69,36 @@ class ChooseSpoolTests(unittest.TestCase):
):
self.assertEqual(jobs.resolve_tracking_spool(conn, "h", 4), 14)
def test_ace_target_skips_spool_created_after_end(self):
end = datetime(2026, 10, 15, 22, 0, tzinfo=timezone.utc)
future = {"id": 18, "created_at": end + timedelta(days=6)}
conn = MagicMock()
with patch("grotti.jobs.store.spool_at_slot", return_value=future):
self.assertEqual(
jobs._targets_from_ace(conn, "h", [{"ams_index": 3}], ended_at=end),
[],
)
def test_spool_valid_for_end_naive(self):
end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
spool = {"created_at": datetime(2026, 9, 1, 12, 0)}
self.assertTrue(jobs._spool_valid_for_end(spool, end))
class UndoDeductionTests(unittest.TestCase):
def test_reopens_last_segment(self):
conn = MagicMock()
conn.execute.return_value.fetchall.return_value = [
{"spool_id": 7, "weight_g": 10},
]
run = {"id": 42, "deducted": True}
self.assertTrue(jobs.undo_deduction(conn, run))
sqls = [str(call.args[0]) for call in conn.execute.call_args_list]
self.assertTrue(any("UPDATE run_segments" in sql and "mm_end = NULL" in sql for sql in sqls))
class SteuernAliasTests(unittest.TestCase):
@patch("grotti.moonraker.gcode_script", return_value={"ok": True})
def test_value_alias_for_temp(self, gcode):
moonraker.steuern("10.0.0.1", "nozzle", {"value": 210})
@@ -96,6 +119,8 @@ class HostValidationTests(unittest.TestCase):
self.assertEqual(validate_printer_host("10.10.1.60"), "10.10.1.60")
self.assertEqual(validate_printer_host("kobra.local:80"), "kobra.local:80")
self.assertEqual(validate_printer_host("COM5@250000"), "COM5@250000")
self.assertEqual(validate_printer_host("/dev/ttyUSB0"), "/dev/ttyUSB0")
with self.assertRaises(GrottiError):
validate_printer_host("http://evil")
with self.assertRaises(GrottiError):
+22
View File
@@ -0,0 +1,22 @@
"""Logging-Hilfen."""
import logging
import unittest
from grotti import logutil
class LogutilTests(unittest.TestCase):
def test_parse_level(self):
self.assertEqual(logutil.parse_level("DEBUG"), logging.DEBUG)
self.assertEqual(logutil.parse_level("warn"), logging.WARNING)
self.assertEqual(logutil.parse_level("nope"), logging.INFO)
def test_setup_idempotent(self):
logutil.setup("INFO")
logutil.setup("DEBUG")
self.assertEqual(logging.getLogger().level, logging.DEBUG)
if __name__ == "__main__":
unittest.main()
+125
View File
@@ -0,0 +1,125 @@
"""Marlin-Dienst mit einer Leitung aus dem Test. Kein USB-Gerät."""
import sys
import threading
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti.drivers.marlin import MarlinDriver
from grotti.marlin_daemon import serve
from grotti.marlin_link import MarlinService, gcode_lines, sd_filename, split_port
from grotti.moonraker import PrinterError
class ScriptedPort:
def __init__(self):
self.written = []
self._queue = []
def write(self, data):
text = data.decode().strip()
self.written.append(text)
if text.startswith("M27"):
self._queue.append(b"Done printing file\n")
self._queue.append(b"ok\n")
elif text.startswith("M105"):
self._queue.append(
b"ok T:200.0 /210.0 T0:200.0 /210.0 T1:190.0 /180.0 B:60.0 /60.0\n"
)
else:
self._queue.append(b"ok\n")
def readline(self):
if self._queue:
return self._queue.pop(0)
return b""
def close(self):
pass
GCODE = b"; Kommentar\nT0\nM109 S210\nT1\nM109 S180\nG1 X1\n"
class MarlinTests(unittest.TestCase):
def setUp(self):
self.port = ScriptedPort()
self.service = MarlinService(open_port=lambda device, baud: self.port)
self.httpd = serve(0, self.service)
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.thread.start()
base = "http://127.0.0.1:%s" % self.httpd.server_address[1]
self.drv = MarlinDriver(base=base)
self.printer = {
"name": "Tenlog",
"host": "COM5@250000",
"family": "tenlog",
"family_label": "Tenlog",
}
def tearDown(self):
self.httpd.shutdown()
self.httpd.server_close()
def test_split_and_short_name(self):
self.assertEqual(split_port("COM5@250000"), ("COM5", 250000))
self.assertEqual(sd_filename("teil drucken.gcode"), "TEILDRUC.GCO")
self.assertEqual(gcode_lines(GCODE), ["T0", "M109 S210", "T1", "M109 S180", "G1 X1"])
def test_two_heads_and_stream(self):
card = self.drv.snapshot([self.printer])[0]
self.assertTrue(card["ok"])
self.assertEqual(card["state"], "standby")
self.assertEqual(card["slots"], [])
self.assertEqual(len(card["tools"]), 2)
self.assertEqual(card["tools"][1]["temp"], 190.0)
self.assertEqual(card["host"], "COM5@250000")
stored = self.drv.upload("COM5@250000", "teil.gcode", GCODE)
self.drv.start_print("COM5@250000", stored, mode="stream")
self.service.session("COM5")._thread.join(3)
sent = self.port.written
self.assertIn("T0", sent)
self.assertIn("T1", sent)
self.assertNotIn("M28 teil.gcode", sent)
status = self.service.session("COM5").status()
self.assertEqual(status["state"], "complete")
self.assertEqual(status["progress"], 1)
def test_sd_writes_then_starts(self):
self.drv.attach(self.printer)
stored = self.drv.upload("COM5", "teil.gcode", GCODE)
shown = self.drv.start_print("COM5@250000", stored, mode="sd")
self.assertEqual(shown, "TEIL.GCO")
self.service.session("COM5")._thread.join(5)
sent = self.port.written
self.assertIn("M28 TEIL.GCO", sent)
self.assertIn("M29", sent)
self.assertIn("M23 TEIL.GCO", sent)
self.assertIn("M24", sent)
self.assertLess(sent.index("M28 TEIL.GCO"), sent.index("T0"))
self.assertLess(sent.index("M29"), sent.index("M24"))
def test_pause_and_cancel_two_heaters(self):
self.drv.attach(self.printer)
session = self.service.session("COM5")
session.state = "printing"
session.mode = "sd"
self.drv.command("COM5", "pause")
self.assertIn("M25", self.port.written)
self.assertEqual(session.state, "paused")
session.cancel()
self.assertIn("M104 T0 S0", self.port.written)
self.assertIn("M104 T1 S0", self.port.written)
self.assertIn("M140 S0", self.port.written)
self.assertEqual(session.state, "standby")
def test_without_service_stays_offline(self):
drv = MarlinDriver(base="http://127.0.0.1:9")
card = drv.snapshot([{"name": "i3", "host": "COM9", "family": "anycubic_i3"}])[0]
self.assertFalse(card["ok"])
self.assertEqual(card["state"], "offline")
with self.assertRaises(PrinterError):
drv.start_print("COM9", "a.gcode")
+115
View File
@@ -0,0 +1,115 @@
"""Marlin-Dienst starten, ohne ihn zu beenden. Kein echtes USB-Gerät."""
import sys
import tempfile
import threading
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti import marlin_supervisor as sup
class SupervisorTests(unittest.TestCase):
def tearDown(self):
sup.disable()
def test_running_service_is_not_started_again(self):
starts = []
service = sup.MarlinSupervisor(
Path("."),
healthy=lambda: True,
start=lambda folder: starts.append(folder),
wait_s=0.1,
)
result = service.ensure()
self.assertTrue(result["running"])
self.assertFalse(result["started"])
self.assertEqual(starts, [])
def test_starts_once_when_down(self):
state = {"up": False}
starts = []
def healthy():
return state["up"]
def start(folder):
starts.append(folder)
state["up"] = True
service = sup.MarlinSupervisor(Path("."), healthy=healthy, start=start, wait_s=0.2)
first = service.ensure()
second = service.ensure()
self.assertTrue(first["started"])
self.assertEqual(len(starts), 1)
self.assertFalse(second["started"])
self.assertTrue(second["running"])
def test_failed_start_does_not_repeat_immediately(self):
starts = []
service = sup.MarlinSupervisor(
Path("."),
healthy=lambda: False,
start=lambda folder: starts.append(1),
wait_s=0.05,
)
service.ensure()
service.ensure()
self.assertEqual(len(starts), 1)
def test_parallel_ensure_starts_once(self):
gate = threading.Event()
starts = []
def start(folder):
starts.append(1)
gate.wait(1)
service = sup.MarlinSupervisor(
Path("."),
healthy=lambda: False,
start=start,
wait_s=0.05,
)
threads = [threading.Thread(target=service.ensure) for _ in range(4)]
for thread in threads:
thread.start()
gate.set()
for thread in threads:
thread.join(2)
self.assertEqual(len(starts), 1)
def test_spawn_is_detached_and_runs_the_module(self):
seen = {}
class Proc:
pid = 42
def popen(**kwargs):
seen.update(kwargs)
if kwargs.get("creationflags", 0) & sup._CREATE_BREAKAWAY:
raise OSError("breakaway")
return Proc()
with tempfile.TemporaryDirectory() as folder:
pid = sup.spawn_detached(folder, popen=popen)
self.assertEqual(pid, 42)
self.assertEqual(seen["args"][1:], ["-m", "grotti.marlin_daemon"])
self.assertIs(seen["stdin"], sup.subprocess.DEVNULL)
if sup.os.name == "nt":
flags = seen["creationflags"]
self.assertTrue(flags & sup._DETACHED)
self.assertFalse(flags & sup._CREATE_BREAKAWAY)
else:
self.assertTrue(seen["start_new_session"])
def test_enable_reuses_a_live_daemon(self):
sup.enable(Path("."))
sup._supervisor.healthy = lambda: True
sup._supervisor.start = lambda folder: (_ for _ in ()).throw(AssertionError("gestartet"))
result = sup.ensure_if_enabled()
self.assertTrue(result["running"])
self.assertFalse(result["started"])
+47 -2
View File
@@ -53,11 +53,56 @@ class MatchSpoolsTests(unittest.TestCase):
},
]
def test_base_sunlu_pla_matches_plain_pla_not_meta(self):
def test_base_sunlu_pla_matches_any_pla(self):
need = {"profile": "Sunlu PLA", "material_type": "pla", "color": "", "paint_index": 0}
matched = store._match_spools_for_need(need, self.have)
ids = {m["spool_id"] for m in matched}
self.assertEqual(ids, {3}) # Eryone PLA; Meta/Matte nicht
self.assertEqual(ids, {1, 2, 3})
def test_base_pla_matches_matte_alone(self):
need = {"profile": "Sunlu PLA", "material_type": "pla", "color": "#f2a900", "paint_index": 0}
have = [self.have[1]]
matched = store._match_spools_for_need(need, have)
self.assertEqual([m["spool_id"] for m in matched], [2])
def test_meta_falls_back_to_any_pla(self):
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
have = [self.have[1]]
matched = store._match_spools_for_need(need, have)
self.assertEqual([m["spool_id"] for m in matched], [2])
def test_petg_does_not_match_pla(self):
need = {"profile": "Sunlu PETG", "material_type": "petg", "color": "", "paint_index": 0}
self.assertEqual(store._match_spools_for_need(need, self.have), [])
def test_pla_lite_hex_matches_any_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, 1})
def test_picks_meta_not_matte(self):
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
+65
View File
@@ -0,0 +1,65 @@
"""Problem-Meldungen: Relay und Antwort, ohne echten Forgejo."""
import json
import sys
import unittest
from pathlib import Path
from unittest import mock
from urllib.error import HTTPError, URLError
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti import meldung
class RelayTests(unittest.TestCase):
def test_relay_ok(self):
cfg = {"_raw": {"meldung_relay": "http://127.0.0.1/hooks/grotti-meldung"}}
class Resp:
def read(self):
return b'{"url":"https://git.example/issues/1"}'
def __enter__(self):
return self
def __exit__(self, *args):
return False
with mock.patch("urllib.request.urlopen", return_value=Resp()) as opener:
ok, message = meldung.send_relay(cfg, {"text": "kaputt", "version": "1"})
self.assertTrue(ok)
self.assertIn("issues/1", message)
request = opener.call_args[0][0]
body = json.loads(request.data.decode("utf-8"))
self.assertEqual(body["text"], "kaputt")
self.assertNotIn("username", body)
def test_relay_down(self):
cfg = {"_raw": {"meldung_relay": "http://127.0.0.1:9/hooks"}}
with mock.patch("urllib.request.urlopen", side_effect=URLError("down")):
ok, message = meldung.send_relay(cfg, {"text": "x"})
self.assertFalse(ok)
self.assertIn("nicht erreichbar", message)
def test_relay_http_error(self):
cfg = {"_raw": {"meldung_relay": "http://example/hooks"}}
class Fake(HTTPError):
def __init__(self):
pass
def read(self):
return b"nope"
code = 502
with mock.patch("urllib.request.urlopen", side_effect=Fake()):
ok, message = meldung.send_relay(cfg, {"text": "x"})
self.assertFalse(ok)
self.assertIn("502", message)
if __name__ == "__main__":
unittest.main()
+20 -1
View File
@@ -155,12 +155,31 @@ class OrcaSendenHelpersTest(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "orca-senden.json"
path.write_text(
json.dumps({"url": "http://127.0.0.1:8770/", "token": "grt_abc"}),
json.dumps({
"url": "http://127.0.0.1:8770/",
"token": "grt_abc",
"watch_dir_local": "D:/Slice",
}),
encoding="utf-8",
)
cfg = orca.load_config(path)
self.assertEqual(cfg["url"], "http://127.0.0.1:8770")
self.assertEqual(cfg["token"], "grt_abc")
self.assertEqual(cfg["watch_dir_local"], "D:/Slice")
def test_resolve_watch_dir_prefers_local(self):
with tempfile.TemporaryDirectory() as tmp:
local = Path(tmp) / "slice"
local.mkdir()
cfg = {
"url": "http://127.0.0.1:8770",
"token": "grt_x",
"watch_dir_local": str(local),
}
with patch.object(orca, "fetch_watch_dir") as fetch:
resolved = orca.resolve_watch_dir(cfg)
self.assertEqual(resolved, local)
fetch.assert_not_called()
def test_load_config_missing(self):
with tempfile.TemporaryDirectory() as tmp:
+41 -1
View File
@@ -8,7 +8,15 @@ from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti.print_queue import PRINT_START_TIMEOUT_S, PrintQueueDispatcher, _cool_wait_text
from datetime import datetime, timedelta, timezone
from grotti.jobs import is_filament_resume
from grotti.print_queue import (
PRINT_START_TIMEOUT_S,
TERMINAL_CONFIRM_HITS,
PrintQueueDispatcher,
_cool_wait_text,
)
class CoolTextTests(unittest.TestCase):
@@ -20,9 +28,26 @@ class CoolTextTests(unittest.TestCase):
self.assertNotIn(" · ", text)
class FilamentResumeTests(unittest.TestCase):
def test_same_moment_with_duration_is_resume(self):
end = datetime(2026, 10, 21, 19, 32, 20, tzinfo=timezone.utc)
start = end + timedelta(milliseconds=40)
self.assertTrue(is_filament_resume(
end, start, prev_duration_s=500, new_duration_s=520
))
def test_later_reprint_is_not_resume(self):
end = datetime(2026, 10, 21, 19, 32, 20, tzinfo=timezone.utc)
start = end + timedelta(minutes=10)
self.assertFalse(is_filament_resume(
end, start, prev_duration_s=500, new_duration_s=10
))
class DispatcherHelpers(unittest.TestCase):
def test_timeout_constant(self):
self.assertGreaterEqual(PRINT_START_TIMEOUT_S, 300)
self.assertGreaterEqual(TERMINAL_CONFIRM_HITS, 2)
def test_worker_busy_empty(self):
d = PrintQueueDispatcher("postgresql://x", {}, watcher=None)
@@ -34,6 +59,21 @@ class DispatcherHelpers(unittest.TestCase):
d.cfg = {"auto_queue": False}
self.assertFalse(d._auto_enabled())
def test_no_retry_after_abort(self):
d = PrintQueueDispatcher("postgresql://x", {}, watcher=None)
self.assertFalse(d._retryable_message("Druck abgebrochen."))
self.assertTrue(d._retryable_message("Start-Timeout"))
def test_advance_simple_ignores_single_cancelled(self):
d = PrintQueueDispatcher("postgresql://x", {}, watcher=None)
item = {"id": 1, "phase": "print_active", "filename": "a.gcode"}
printer = {"host": "h", "name": "P"}
live = {"state": "cancelled", "print_active": False}
with mock.patch("grotti.print_queue.store.update_print_queue") as upd:
d._advance_simple(mock.Mock(), item, printer, live)
upd.assert_not_called()
self.assertEqual(d._term_hits.get("1:end"), 1)
class ResolveSpoolTests(unittest.TestCase):
def test_material_match(self):
+79
View File
@@ -0,0 +1,79 @@
"""Szenarien für einen physischen Druck = ein Lauf (ohne DB, mit Mocks)."""
import os
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
from grotti import jobs
class SyncOneJobShellTests(unittest.TestCase):
def test_empty_shell_skipped(self):
conn = MagicMock()
printer = {"host": "h", "name": "P"}
job = {
"job_id": "000041",
"filename": "a.gcode",
"status": "in_progress",
"start_time": 1000,
"end_time": None,
"print_duration": 0,
"total_duration": 0,
"metadata": {},
}
self.assertEqual(jobs._sync_one_job(conn, {"energy": {}}, printer, job), 0)
conn.execute.assert_not_called()
class FoldProofIntegrationLike(unittest.TestCase):
def test_fold_requires_duration_proof(self):
end = datetime(2026, 10, 25, 5, 48, 27, tzinfo=timezone.utc)
start = end + timedelta(milliseconds=40)
pred = {
"id": 1,
"ended_at": end,
"duration_s": 11711,
"moonraker_job_id": "000040",
"verdict": "",
}
conn = MagicMock()
conn.execute.return_value.fetchone.return_value = None
with patch("grotti.jobs.store.find_resume_predecessor", return_value=pred), \
patch("grotti.jobs.store.remember_run_job"), \
patch("grotti.jobs.store.move_run_jobs"), \
patch("grotti.jobs.store.set_printer_active_run"), \
patch("grotti.jobs.undo_deduction"):
# Frischer Neustart (Dauer 12) — kein Fold
self.assertIsNone(jobs.fold_filament_resume(
conn, "h", "a.gcode", "000041", start, print_duration_s=12
))
# Fortgesetzte Dauer — Fold
self.assertEqual(
jobs.fold_filament_resume(
conn, "h", "a.gcode", "000041", start, print_duration_s=11711
),
1,
)
@unittest.skipUnless(os.environ.get("GROTTI_TEST_DB"), "GROTTI_TEST_DB nicht gesetzt")
class LiveDbScenarios(unittest.TestCase):
"""Gegen echte Test-DB: GROTTI_TEST_DB=postgresql://…"""
def setUp(self):
import psycopg
from psycopg.rows import dict_row
self.conn = psycopg.connect(os.environ["GROTTI_TEST_DB"], row_factory=dict_row)
def tearDown(self):
self.conn.rollback()
self.conn.close()
def test_connection(self):
self.assertTrue(self.conn.execute("SELECT 1").fetchone())
if __name__ == "__main__":
unittest.main()
+127
View File
@@ -0,0 +1,127 @@
"""ACE-Startabschnitte und Spulenwechsel 0→jetzt."""
import unittest
from unittest import mock
from grotti import store
from grotti.watcher import StateWatcher
class PrevSpoolForSlotTests(unittest.TestCase):
def test_prefers_old_at_slot(self):
conn = mock.Mock()
live = {"id": 1, "filename": "a.gcode", "spool_id": 99}
got = store.prev_spool_id_for_slot(
conn, "h", 4, live, 21, old_rows=[{"id": 18}],
)
self.assertEqual(got, 18)
def test_ace_map_send_when_old_empty(self):
conn = mock.Mock()
live = {"id": 33805, "filename": "x.gcode", "spool_id": None}
with mock.patch(
"grotti.store.latest_send",
return_value={
"ace_map": [{"paint_index": 0, "ams_index": 3}],
"spool_id": 18,
},
):
got = store.prev_spool_id_for_slot(conn, "h", 4, live, 21, old_rows=[])
self.assertEqual(got, 18)
def test_last_segment_on_slot(self):
conn = mock.Mock()
live = {"id": 7, "filename": "x.gcode", "spool_id": None}
with mock.patch("grotti.store.latest_send", return_value={"ace_map": [], "spool_id": None}), \
mock.patch(
"grotti.store.list_run_segments",
return_value=[
{"spool_id": 10, "ace_slot": 2, "mm_start": 0, "mm_end": 100},
{"spool_id": 18, "ace_slot": 4, "mm_start": 0, "mm_end": 500},
],
):
got = store.prev_spool_id_for_slot(conn, "h", 4, live, 21, old_rows=[])
self.assertEqual(got, 18)
class EnsureStartSegmentAceTests(unittest.TestCase):
def test_opens_mapped_slots_with_spool(self):
watcher = StateWatcher("postgresql://x", {})
conn = mock.Mock()
live = {"filament_used": 0}
with mock.patch("grotti.watcher.store.list_run_segments", return_value=[]), \
mock.patch(
"grotti.watcher.store.latest_send",
return_value={"ace_map": [{"paint_index": 0, "ams_index": 3}]},
), \
mock.patch(
"grotti.watcher.store.spool_at_slot",
return_value={"id": 18},
) as at_slot, \
mock.patch("grotti.watcher.store.open_segment") as open_seg:
watcher._ensure_start_segment(conn, "h", "a.gcode", live, 33805)
at_slot.assert_called_once_with(conn, "h", 4)
open_seg.assert_called_once_with(conn, 33805, 18, 4, 0.0)
def test_ace_without_spool_opens_nothing(self):
watcher = StateWatcher("postgresql://x", {})
conn = mock.Mock()
with mock.patch("grotti.watcher.store.list_run_segments", return_value=[]), \
mock.patch(
"grotti.watcher.store.latest_send",
return_value={"ace_map": [{"paint_index": 0, "ams_index": 3}]},
), \
mock.patch("grotti.watcher.store.spool_at_slot", return_value=None), \
mock.patch("grotti.watcher.store.open_segment") as open_seg:
watcher._ensure_start_segment(conn, "h", "a.gcode", {"filament_used": 0}, 1)
open_seg.assert_not_called()
class SpoolLoadFallbackSegmentsTests(unittest.TestCase):
"""ACE ohne Segment + Einlegen ohne old → 0→jetzt + neuer offener Abschnitt."""
def test_creates_closed_and_open_segment(self):
conn = mock.Mock()
live = {"id": 33805, "filename": "x.gcode", "spool_id": None, "state": "in_progress"}
opened = []
closed = []
def open_segment(_c, run_id, spool_id, ace_slot, mm_start):
opened.append((run_id, spool_id, ace_slot, mm_start))
return True
def close_open_segments(_c, run_id, ace_slot, mm_end):
closed.append((run_id, ace_slot, mm_end))
# Erster Aufruf: nichts offen; zweiter: nach open_segment
return 1 if opened else 0
with mock.patch("grotti.store.close_open_segments", side_effect=close_open_segments), \
mock.patch("grotti.store.open_segment", side_effect=open_segment), \
mock.patch(
"grotti.store.prev_spool_id_for_slot",
return_value=18,
):
# Logik wie in spool_load (Auszug)
slot = 4
spool_id = 21
mm_now = 5291.0
old = []
closed_n = store.close_open_segments(conn, live["id"], slot, mm_now)
if not closed_n and old:
pass
elif not closed_n and mm_now > 0:
prev_id = store.prev_spool_id_for_slot(
conn, "h", slot, live, spool_id, old_rows=old,
)
if prev_id:
store.open_segment(conn, live["id"], prev_id, slot, 0)
store.close_open_segments(conn, live["id"], slot, mm_now)
store.open_segment(conn, live["id"], spool_id, slot, mm_now)
self.assertEqual(opened[0], (33805, 18, 4, 0))
self.assertEqual(closed[-1], (33805, 4, 5291.0))
self.assertEqual(opened[-1], (33805, 21, 4, 5291.0))
if __name__ == "__main__":
unittest.main()
+205
View File
@@ -0,0 +1,205 @@
"""Kontaktverlust beendet einen Druck nicht."""
import unittest
from unittest import mock
from grotti.watcher import StateWatcher, contact_gap_keeps_run
HOST = "10.10.1.88"
FILE = "boden_PETG_0.2_2h15m.gcode"
class ContactGapTests(unittest.TestCase):
def test_same_file_stays(self):
self.assertTrue(contact_gap_keeps_run(FILE, FILE, "printing"))
self.assertTrue(contact_gap_keeps_run(FILE, FILE, "paused"))
def test_other_file_does_not_stay(self):
self.assertFalse(contact_gap_keeps_run(FILE, "andere.gcode", "printing"))
def test_terminal_state_does_not_stay(self):
self.assertFalse(contact_gap_keeps_run(FILE, FILE, "standby"))
self.assertFalse(contact_gap_keeps_run(FILE, FILE, "complete"))
self.assertFalse(contact_gap_keeps_run(FILE, FILE, "error"))
def test_missing_name_is_not_a_change(self):
self.assertTrue(contact_gap_keeps_run("", FILE, "printing"))
self.assertTrue(contact_gap_keeps_run(FILE, "", "paused"))
class WatcherOfflineTests(unittest.TestCase):
def setUp(self):
self.watcher = StateWatcher("postgresql://x", {})
self.watcher._prev[HOST] = {
"state": "printing",
"offline": False,
"filename": FILE,
"run_id": 42,
}
def test_two_misses_keep_the_run(self):
live = {
"host": HOST,
"name": "Kobra S1",
"state": "offline",
"ok": False,
"filename": "",
}
with mock.patch.object(self.watcher, "_emit") as emit, \
mock.patch("grotti.watcher.store.complete_open_run") as close, \
mock.patch("grotti.watcher.jobs.apply_deduction") as deduct:
self.watcher._one(mock.Mock(), live)
self.watcher._one(mock.Mock(), live)
close.assert_not_called()
deduct.assert_not_called()
remembered = self.watcher._prev[HOST]
self.assertEqual(remembered["state"], "printing")
self.assertTrue(remembered["offline"])
self.assertEqual(remembered["run_id"], 42)
self.assertEqual(remembered["filename"], FILE)
self.assertEqual([call.args[3] for call in emit.call_args_list], ["offline"])
def test_same_print_after_gap_is_the_same_run(self):
self.watcher._prev[HOST]["offline"] = True
live = {
"host": HOST,
"name": "Kobra S1",
"state": "printing",
"ok": True,
"filename": FILE,
"weight": 0,
}
run = {"id": 42, "filename": FILE}
with mock.patch.object(self.watcher, "_emit") as emit, \
mock.patch.object(self.watcher, "_track_print", return_value=run) as track, \
mock.patch.object(self.watcher, "_check_spool", return_value=False), \
mock.patch("grotti.watcher.store.find_live_run", return_value=run), \
mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \
mock.patch("grotti.watcher.store.complete_open_run") as close, \
mock.patch("grotti.watcher.jobs.sync_history") as sync, \
mock.patch("grotti.watcher.jobs.apply_deduction") as deduct:
self.watcher._one(mock.Mock(), live)
close.assert_not_called()
deduct.assert_not_called()
abandon.assert_not_called()
sync.assert_not_called()
track.assert_called_once()
self.assertEqual([call.args[3] for call in emit.call_args_list], ["offline"])
self.assertIn("wieder erreichbar", emit.call_args_list[0].args[4])
self.assertEqual(self.watcher._prev[HOST]["state"], "printing")
self.assertFalse(self.watcher._prev[HOST]["offline"])
def test_standby_after_gap_closes(self):
self.watcher._prev[HOST]["offline"] = True
live = {
"host": HOST,
"name": "Kobra S1",
"state": "standby",
"ok": True,
"filename": "",
}
run = {"id": 42, "filename": FILE}
with mock.patch.object(self.watcher, "_emit") as emit, \
mock.patch.object(self.watcher, "_track_print", return_value=None), \
mock.patch.object(self.watcher, "_check_spool", return_value=False), \
mock.patch("grotti.watcher.store.find_live_run", return_value=run), \
mock.patch("grotti.watcher.store.running_print_on_host", return_value=None), \
mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \
mock.patch("grotti.watcher.store.set_bed_needs_clear"), \
mock.patch("grotti.watcher.jobs.sync_history") as sync:
self.watcher._one(mock.Mock(), live)
abandon.assert_called_once()
sync.assert_called_once()
self.assertIn("abbruch", [call.args[3] for call in emit.call_args_list])
def test_other_file_after_gap_releases_the_old_run(self):
self.watcher._prev[HOST]["offline"] = True
live = {
"host": HOST,
"name": "Kobra S1",
"state": "printing",
"ok": True,
"filename": "andere.gcode",
"weight": 0,
}
old = {"id": 42, "filename": FILE}
new = {"id": 99, "filename": "andere.gcode"}
with mock.patch.object(self.watcher, "_emit") as emit, \
mock.patch.object(self.watcher, "_track_print", return_value=new), \
mock.patch.object(self.watcher, "_ensure_start_segment"), \
mock.patch.object(self.watcher, "_check_spool", return_value=False), \
mock.patch("grotti.watcher.store.find_live_run", side_effect=[old, old, None]), \
mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \
mock.patch("grotti.watcher.jobs.sync_history") as sync:
self.watcher._one(mock.Mock(), live)
sync.assert_called_once()
self.assertGreaterEqual(abandon.call_count, 1)
kinds = [call.args[3] for call in emit.call_args_list]
self.assertEqual(kinds, ["abbruch", "offline", "start"])
def test_other_file_without_offline_releases_the_old_run(self):
live = {
"host": HOST,
"name": "Kobra S1",
"state": "printing",
"ok": True,
"filename": "andere.gcode",
"weight": 0,
}
old = {"id": 42, "filename": FILE}
new = {"id": 99, "filename": "andere.gcode"}
seen = {"n": 0}
def live_run(_conn, _host, filename=""):
seen["n"] += 1
if seen["n"] <= 3:
return old
return None
with mock.patch.object(self.watcher, "_emit") as emit, \
mock.patch.object(self.watcher, "_track_print", return_value=new), \
mock.patch.object(self.watcher, "_ensure_start_segment"), \
mock.patch.object(self.watcher, "_check_spool", return_value=False), \
mock.patch("grotti.watcher.store.find_live_run", side_effect=live_run), \
mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \
mock.patch("grotti.watcher.jobs.sync_history") as sync:
self.watcher._one(mock.Mock(), live)
sync.assert_called_once()
abandon.assert_called()
self.assertIn("abbruch", [call.args[3] for call in emit.call_args_list])
self.assertIn("start", [call.args[3] for call in emit.call_args_list])
def test_cancel_event_survives_deleted_live_run(self):
self.watcher._cancel_hits[HOST] = 2
live = {
"host": HOST,
"name": "Kobra S1",
"state": "cancelled",
"ok": True,
"filename": FILE,
}
dangling = {"id": 42, "filename": FILE, "state": "in_progress"}
def row(_conn, run_id):
if run_id == 77:
return {"id": 77, "filename": FILE}
return None
with mock.patch("grotti.watcher.store.find_live_run", side_effect=[dangling, None]), \
mock.patch("grotti.watcher.store.find_open_history_run", return_value=None), \
mock.patch("grotti.watcher.store.get_run_row", side_effect=row), \
mock.patch("grotti.watcher.store.latest_run_id", return_value=77), \
mock.patch("grotti.watcher.store.abandon_live_runs"), \
mock.patch("grotti.watcher.store.set_bed_needs_clear"), \
mock.patch("grotti.watcher.store.set_printer_active_run"), \
mock.patch("grotti.watcher.jobs.sync_history") as sync, \
mock.patch("grotti.watcher.notify.record") as record:
self.watcher._one(mock.Mock(), live)
sync.assert_called_once()
record.assert_called()
self.assertEqual(record.call_args.kwargs["run_id"], 77)
self.assertEqual(record.call_args.args[4], "abbruch")
if __name__ == "__main__":
unittest.main()
+333 -73
View File
@@ -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,20 +32,27 @@ 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"
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
VERDICTS = {"", "gut", "ausschuss"}
VERDICT_REASONS = sorted(grotti_verdict.VERDICT_REASONS)
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$")
SERIAL_RE = re.compile(
r"^(?:COM\d+|/dev/tty(?:USB|ACM)\d+|/dev/serial/by-id/[A-Za-z0-9._\-]+)(?:@\d{3,7})?$",
re.IGNORECASE,
)
TOKEN_QUERY_PATHS = (
"/api/v1/kamera",
"/api/v1/kamera/stream",
"/api/v1/vorschau",
"/api/v1/dateien/",
"/api/v1/projekte/",
)
STEER_ACTIONS = {
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
@@ -55,13 +62,11 @@ STEER_ACTIONS = {
def validate_printer_host(host):
host = (host or "").strip()
if not host or not HOST_RE.fullmatch(host):
if not host or ".." in host or not (HOST_RE.fullmatch(host) or SERIAL_RE.fullmatch(host)):
raise GrottiError(
"Host nur als Hostname oder IP, optional mit Port (z. B. 10.10.1.60).",
"Host als IP, Hostname, COM-Port (COM5 oder COM5@250000) oder /dev/tty…",
"host",
)
if ".." in host:
raise GrottiError("Ungültiger Host.", "host")
return host
@@ -97,6 +102,7 @@ def create_app(cfg):
"/setup",
"/api/v1/auth/status",
"/api/v1/auth/login",
"/api/v1/auth/token",
"/api/v1/auth/setup",
"/api/v1/version",
}
@@ -316,7 +322,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():
@@ -452,6 +458,39 @@ def create_app(cfg):
store.add_event(conn(), "", "", "auth", "Login %s" % row["username"])
return jsonify({"user": grotti_auth.public_user(row), "csrf": session["csrf"]})
@app.post("/api/v1/auth/token")
def auth_token():
"""Passwort → API-Token (für Android/App). Keine Session, Rate-Limit wie Login."""
if store.user_count(conn()) == 0:
raise GrottiError("Zuerst Admin anlegen.", "setup", 503)
key = request.remote_addr or "unknown"
if not grotti_auth.login_rate_ok(key):
raise GrottiError("Zu viele Versuche. Bitte warten.", "rate", 429)
data = body()
username = (data.get("username") or "").strip()
password = data.get("password") or ""
device = (data.get("device_name") or data.get("name") or "App").strip()[:80] or "App"
row = store.get_user_by_name(conn(), username)
if not row or not row.get("active") or not grotti_auth.verify_password(row["password_hash"], password):
grotti_auth.login_rate_hit(key)
raise GrottiError("Benutzer oder Passwort falsch.", "auth", 401)
store.touch_user_login(conn(), row["id"])
raw = grotti_auth.new_api_token()
role = grotti_auth.clamp_token_role(row["role"], data.get("role"))
token_row = store.create_api_token(
conn(),
row["id"],
device,
grotti_auth.hash_token(raw),
grotti_auth.token_prefix(raw),
role,
)
store.add_event(conn(), "", "", "auth", "Token „%s“ für %s" % (device, row["username"]))
item = grotti_auth.public_token(token_row)
item["token"] = raw
item["user"] = grotti_auth.public_user(row)
return jsonify(item), 201
@app.post("/api/v1/auth/logout")
def auth_logout():
session.clear()
@@ -582,6 +621,7 @@ def create_app(cfg):
ANLEITUNG_ORDER = (
"Anleitung-Einrichtung",
"Anleitung-Installation-Pi",
"Anleitung-Oberflaeche",
"Anleitung-Wand",
"Anleitung-Dateien",
@@ -605,6 +645,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",
@@ -692,6 +733,7 @@ def create_app(cfg):
token="",
spool=spool,
qr_url=qr_url,
etikett=cfg.get("etikett") or grotti_config._etikett({}),
)
@app.get("/protokoll")
@@ -752,6 +794,7 @@ def create_app(cfg):
sheets=sheets,
spools=spools,
target_g=target_g,
etikett=cfg.get("etikett") or grotti_config._etikett({}),
)
@app.get("/ideen")
@@ -807,7 +850,22 @@ def create_app(cfg):
@app.get("/api/v1/version")
def version_info():
return jsonify({"version": __version__})
return jsonify({
"name": "Grotti",
"version": __version__,
"api": 1,
"features": [
"auth_token",
"sse",
"queue",
"projects",
"orders",
"spools",
"camera",
"confirm",
"ntfy",
],
})
@app.get("/api/v1/einstellungen")
def settings():
@@ -824,6 +882,7 @@ def create_app(cfg):
auto_retry = data.get("auto_retry") if "auto_retry" in data else None
fqdn = data.get("fqdn") if "fqdn" in data else None
watch_dir = data.get("watch_dir") if "watch_dir" in data else None
etikett = data.get("etikett") if isinstance(data.get("etikett"), dict) else None
return jsonify(grotti_config.save_settings(
cfg,
energy=energy,
@@ -834,6 +893,7 @@ def create_app(cfg):
auto_retry=auto_retry,
fqdn=fqdn,
watch_dir=watch_dir,
etikett=etikett,
))
@app.post("/api/v1/telegram/test")
@@ -876,8 +936,12 @@ def create_app(cfg):
by_id = {row["host"]: row for row in rows}
if request.args.get("live", "1") == "0":
from grotti.drivers import for_printer
for row in rows:
row["mainsail"] = "http://%s/" % row["host"]
if for_printer(row).transport == "moonraker":
row["mainsail"] = "http://%s/" % row["host"]
else:
row["mainsail"] = ""
attach(row)
return jsonify(rows)
# Eine Live-Quelle: Watcher. Frischer als 60 s, sonst einmal selbst abfragen.
@@ -885,7 +949,8 @@ def create_app(cfg):
cached = watcher.by_host()
age = watcher.age_s()
if not cached or age is None or age > 60:
lives = moonraker.snapshot(rows)
from grotti.drivers import snapshot
lives = snapshot(rows)
else:
lives = []
for row in rows:
@@ -900,7 +965,11 @@ def create_app(cfg):
item = dict(row)
item["ok"] = False
item["state"] = "offline"
item["mainsail"] = "http://%s/" % row["host"]
from grotti.drivers import for_printer as _for_printer
if _for_printer(row).transport == "moonraker":
item["mainsail"] = "http://%s/" % row["host"]
else:
item["mainsail"] = ""
lives.append(item)
for live in lives:
meta = by_id.get(live.get("host") or "") or {}
@@ -908,6 +977,53 @@ def create_app(cfg):
attach(live)
return jsonify(lives)
@app.get("/api/v1/marlin-dienst")
def marlin_dienst():
from grotti.marlin_supervisor import current_status, ensure_if_enabled
ensure_if_enabled()
return jsonify(current_status())
@app.post("/api/v1/meldungen")
def meldung_create():
from grotti import meldung as grotti_meldung
require_role("operator")
data = body()
try:
row = grotti_meldung.create(
conn(),
cfg,
text=data.get("text") or "",
page=data.get("page") or "",
printer_host=data.get("printer_host") or "",
error_text=data.get("error_text") or "",
user=g.user,
)
conn().commit()
except ValueError as exc:
raise GrottiError(str(exc), "meldung") from exc
return jsonify(row), 201
@app.get("/api/v1/meldungen")
def meldung_list():
from grotti import meldung as grotti_meldung
require_role("admin")
status = (request.args.get("status") or "").strip() or None
return jsonify(grotti_meldung.list_rows(conn(), status=status))
@app.post("/api/v1/meldungen/<int:meldung_id>/erledigt")
def meldung_close(meldung_id):
from grotti import meldung as grotti_meldung
require_role("admin")
row = grotti_meldung.close(conn(), meldung_id)
if not row:
raise GrottiError("Die Meldung fehlt.", "meldung", 404)
conn().commit()
return jsonify(row)
@app.post("/api/v1/drucker")
def printer_create():
data = body()
@@ -965,6 +1081,8 @@ def create_app(cfg):
@app.post("/api/v1/drucker/<host>/<action>")
def printer_action(host, action):
require_host(host)
aliases = {"resume": "fortsetzen", "cancel": "abbrechen"}
action = aliases.get(action, action)
if action not in ("pause", "fortsetzen", "abbrechen"):
raise GrottiError("Unbekannter Befehl.", "befehl")
return jsonify(jobs.control(conn(), host, action))
@@ -985,7 +1103,7 @@ def create_app(cfg):
host = require_host(request.args.get("host", ""))
image = moonraker.camera_image(host)
if not image:
abort(404)
raise GrottiError("Kein Kamerabild.", "kamera", 404)
return image[1], 200, {"Content-Type": image[0], "Cache-Control": "no-store"}
@app.get("/api/v1/kamera/stream")
@@ -1044,7 +1162,8 @@ def create_app(cfg):
@app.get("/api/v1/projekte")
def project_list():
return jsonify(store.list_projects(conn()))
status = (request.args.get("status") or "").strip() or None
return jsonify(store.list_projects(conn(), status=status))
@app.post("/api/v1/projekte")
def project_create():
@@ -1079,11 +1198,83 @@ def create_app(cfg):
@app.get("/api/v1/projekte/<int:project_id>")
def project_get(project_id):
row = store.get_project(conn(), project_id)
row = store.get_project(conn(), project_id, watch_dir=cfg.get("watch_dir"))
if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
# Status „druckt“, wenn ein Lauf aktiv ist (nur Anzeige — kein Auto-Write ohne Wunsch)
return jsonify(row)
@app.get("/api/v1/projekte/<int:project_id>/laeufe")
def project_runs(project_id):
if not store.get_project(conn(), project_id):
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
try:
limit = max(1, min(200, int(request.args.get("limit") or 40)))
except (TypeError, ValueError):
limit = 40
try:
offset = max(0, int(request.args.get("offset") or 0))
except (TypeError, ValueError):
offset = 0
data = store.list_project_runs(conn(), project_id, limit=limit, offset=offset)
resp = jsonify(data["items"])
resp.headers["X-Total-Count"] = str(data["total"])
return resp
@app.post("/api/v1/projekte/<int:project_id>/einreihen")
def project_einreihen(project_id):
data = body()
if not bool(data.get("confirm")):
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
project = store.get_project(conn(), project_id, watch_dir=cfg.get("watch_dir"))
if not project:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
wanted = data.get("file_ids")
if isinstance(wanted, list) and wanted:
try:
wanted_ids = {int(x) for x in wanted}
except (TypeError, ValueError) as exc:
raise GrottiError("Ungültige Datei-IDs.", "datei") from exc
files = [f for f in (project.get("files") or []) if f["id"] in wanted_ids]
else:
files = list(project.get("files") or [])
if not files:
raise GrottiError("Keine Druckdateien zum Einreihen.", "datei")
try:
menge = max(1, min(50, int(data.get("menge") or 1)))
except (TypeError, ValueError):
menge = 1
try:
priority = int(data.get("priority", 100))
except (TypeError, ValueError):
priority = 100
host = optional_host(data.get("host") or "")
if host:
require_host(host)
bed = bool(data.get("bed_leveling"))
override = bool(data.get("override_machine"))
created = []
for file_row in files:
family = (data.get("family") or file_row.get("printer_family") or "").strip()
for _ in range(menge):
created.append(
store.enqueue_print(
conn(),
file_row["id"],
host,
family if not host else "",
priority,
optional_id(data.get("spool_id")),
data.get("ace_map") if isinstance(data.get("ace_map"), list) else [],
bed,
override,
)
)
# Status auf „druckt“ heben, wenn noch Idee/bereit
if project.get("status") in ("idee", "bereit"):
store.update_project(conn(), project_id, {"status": "druckt"})
return jsonify({"jobs": created, "count": len(created)}), 201
@app.patch("/api/v1/projekte/<int:project_id>")
def project_patch(project_id):
data = body()
@@ -1178,7 +1369,39 @@ def create_app(cfg):
@app.get("/api/v1/dateien")
def file_list():
library.sync_watch(conn(), cfg, force=False)
return jsonify(store.list_files(conn(), watch_dir=cfg.get("watch_dir")))
family = (request.args.get("family") or "").strip()
material = (request.args.get("material") or "").strip()
q = (request.args.get("q") or "").strip()
host = optional_host(request.args.get("host") or "")
project_id = optional_id(request.args.get("project_id") or request.args.get("project"))
limit_raw = request.args.get("limit")
offset_raw = request.args.get("offset")
limit = None
offset = 0
if limit_raw is not None and str(limit_raw).strip() != "":
try:
limit = max(1, min(500, int(limit_raw)))
except (TypeError, ValueError):
limit = 40
if offset_raw is not None:
try:
offset = max(0, int(offset_raw))
except (TypeError, ValueError):
offset = 0
data = store.list_files(
conn(),
watch_dir=cfg.get("watch_dir"),
family=family,
material=material,
project_id=project_id,
q=q,
host=host,
limit=limit,
offset=offset,
)
resp = jsonify(data["items"])
resp.headers["X-Total-Count"] = str(data["total"])
return resp
@app.post("/api/v1/dateien/<int:file_id>/meta")
def file_meta_refresh(file_id):
@@ -1263,10 +1486,10 @@ def create_app(cfg):
def file_preview(file_id):
row = store.get_file_row(conn(), file_id)
if not row:
abort(404)
raise GrottiError("Vorschau fehlt.", "vorschau", 404)
png = library.preview_png(row, conn=conn(), data_dir=cfg["data_dir"])
if not png:
abort(404)
raise GrottiError("Vorschau fehlt.", "vorschau", 404)
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
@app.get("/api/v1/dateien/<int:file_id>/pfad")
@@ -1393,6 +1616,7 @@ def create_app(cfg):
optional_id(data.get("spool_id")),
ace_maps,
bed_leveling=bool(data.get("bed_leveling")),
marlin_mode=(data.get("marlin_mode") or "stream"),
)
return jsonify({"jobs": created, "count": len(created), "mode": "now"}), 202
@@ -1407,9 +1631,12 @@ def create_app(cfg):
file_id = int(request.args.get("file_id") or 0)
except (TypeError, ValueError):
file_id = 0
if not host or not file_id:
raise GrottiError("Host und Datei nötig.", "senden")
row = store.latest_send_for_file(conn(), host, file_id)
if not host:
raise GrottiError("Host nötig.", "senden")
if file_id:
row = store.latest_send_for_file(conn(), host, file_id)
else:
row = store.latest_send_for_host(conn(), host)
if not row:
return jsonify(None)
return jsonify(row)
@@ -1504,7 +1731,18 @@ def create_app(cfg):
limit = max(1, min(200, int(request.args.get("limit") or 40)))
except (TypeError, ValueError):
limit = 40
return jsonify(store.list_print_queue(conn(), limit=limit))
try:
offset = max(0, int(request.args.get("offset") or 0))
except (TypeError, ValueError):
offset = 0
host = optional_host(request.args.get("host") or "")
status = (request.args.get("status") or "").strip()
data = store.list_print_queue(
conn(), limit=limit, offset=offset, host=host, status=status
)
resp = jsonify(data["items"])
resp.headers["X-Total-Count"] = str(data["total"])
return resp
@app.get("/api/v1/laeuft")
def running_now():
@@ -1586,7 +1824,10 @@ def create_app(cfg):
for item in running:
rem = float(item.get("remain") or 0)
item["free_at"] = jetzt_mod._iso_remain(rem) if rem > 0 else None
waiting = [j for j in store.list_print_queue(conn(), limit=80) if j.get("status") == "waiting"]
waiting = [
j for j in store.list_print_queue(conn(), limit=80)["items"]
if j.get("status") == "waiting"
]
return jsonify({
"running": running,
"waiting_count": len(waiting),
@@ -1690,15 +1931,14 @@ def create_app(cfg):
except ValueError as exc:
raise GrottiError(str(exc), "urteil") from exc
run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n)
# Live-Lauf schließen: Moonraker-History bleibt oft ohne Endzeit hängen,
# sonst bleibt der Druck im Protokoll ewig „in_progress“ trotz Urteil.
# Live-Lauf schließen ohne Abzug — History bucht; Fallback nach Frist.
if run_row and (run_row.get("state") or "") in ("in_progress", "printing", "paused"):
closed = store.complete_open_run(c, run_row["id"], state="completed")
if closed:
try:
jobs.apply_deduction(c, closed, cfg["energy"])
jobs.sync_history(c, cfg, host=host)
except Exception as exc:
print("Abräumen Abzug %s: %s" % (host, exc), flush=True)
log.warning("Abräumen Abgleich %s: %s", host, exc)
row = store.get_run_row(c, closed["id"]) or closed
run_json = store.run_out(row)
reprint = None
@@ -1795,8 +2035,13 @@ def create_app(cfg):
# laufenden Druck auf dem Host best-effort stoppen
if was_running and row.get("host"):
try:
moonraker.command(row["host"], "abbrechen")
stopped = ", Druck gestoppt"
from grotti.drivers import for_printer
printer = store.printer_by_host(conn(), row["host"])
if printer:
for_printer(printer).command(row["host"], "abbrechen")
stopped = ", Druck gestoppt"
else:
stopped = ", Druck nicht gestoppt"
except Exception:
stopped = ", Druck nicht gestoppt"
store.add_event(
@@ -1964,47 +2209,6 @@ def create_app(cfg):
def color_list_api():
return jsonify(color_catalog())
@app.get("/api/v1/ideen")
def idea_list():
return jsonify(store.list_ideas(conn()))
@app.post("/api/v1/ideen")
def idea_create():
data = body()
title = (data.get("title") or "").strip()
if not title:
raise GrottiError("Die Idee braucht einen Titel.", "titel")
status = data.get("status") or "offen"
if status not in IDEA_STATUS:
raise GrottiError("Unbekannter Status.", "status")
return jsonify(store.create_idea(conn(), title, (data.get("body") or "").strip(), status)), 201
@app.patch("/api/v1/ideen/<int:idea_id>")
def idea_patch(idea_id):
data = body()
fields = {}
if "title" in data:
title = (data.get("title") or "").strip()
if not title:
raise GrottiError("Die Idee braucht einen Titel.", "titel")
fields["title"] = title
if "body" in data:
fields["body"] = (data.get("body") or "").strip()
if "status" in data:
if data.get("status") not in IDEA_STATUS:
raise GrottiError("Unbekannter Status.", "status")
fields["status"] = data.get("status")
row = store.update_idea(conn(), idea_id, fields)
if not row:
raise GrottiError("Die Idee fehlt.", "idee", 404)
return jsonify(row)
@app.delete("/api/v1/ideen/<int:idea_id>")
def idea_delete(idea_id):
if not store.delete_idea(conn(), idea_id):
raise GrottiError("Die Idee fehlt.", "idee", 404)
return "", 204
@app.get("/api/v1/kunden")
def kunden_list():
return jsonify(grotti_orders.list_customers(conn()))
@@ -2192,7 +2396,28 @@ def create_app(cfg):
@app.get("/api/v1/spulen")
def spool_list():
return jsonify(store.list_spools(conn()))
aktiv_raw = (request.args.get("aktiv") or "").strip().lower()
aktiv = None
if aktiv_raw in ("1", "true", "yes"):
aktiv = True
elif aktiv_raw in ("0", "false", "no"):
aktiv = False
limit_raw = request.args.get("limit")
limit = None
offset = 0
if limit_raw is not None and str(limit_raw).strip() != "":
try:
limit = max(1, min(500, int(limit_raw)))
except (TypeError, ValueError):
limit = 100
try:
offset = max(0, int(request.args.get("offset") or 0))
except (TypeError, ValueError):
offset = 0
data = store.list_spools(conn(), aktiv=aktiv, limit=limit, offset=offset)
resp = jsonify(data["items"])
resp.headers["X-Total-Count"] = str(data["total"])
return resp
@app.get("/api/v1/spulen/by-code/<path:code>")
def spool_by_code(code):
@@ -2286,6 +2511,15 @@ def create_app(cfg):
for row in old:
store.open_segment(conn(), live_run["id"], row["id"], slot, 0)
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
elif not closed and mm_now > 0:
# ACE ohne Startabschnitt oder alte Rolle schon im Regal —
# slot-genau: Platz / ACE-Map / letzter Abschnitt / Lauf-Hinweis.
prev_id = store.prev_spool_id_for_slot(
conn(), host, slot, live_run, spool_id, old_rows=old,
)
if prev_id:
store.open_segment(conn(), live_run["id"], prev_id, slot, 0)
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
for row in old:
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
store.add_event(
@@ -2451,7 +2685,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():
@@ -2487,8 +2725,18 @@ def create_app(cfg):
run = store.run_out(row)
if run.get("chargen_nr") and run.get("objects"):
store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=1)
if run.get("order_item_id") and not run.get("order_id"):
oi = conn().execute(
"SELECT order_id FROM order_items WHERE id = %s",
(run["order_item_id"],),
).fetchone()
if oi:
run["order_id"] = oi["order_id"]
file_row = store.get_file_row(conn(), run["file_id"]) if run.get("file_id") else None
project = store.get_project(conn(), run["project_id"]) if run.get("project_id") else None
project = (
store.get_project(conn(), run["project_id"], watch_dir=cfg.get("watch_dir"))
if run.get("project_id") else None
)
samples = store.list_run_samples(conn(), run_id)
from grotti.cost import energy_estimate
@@ -2504,7 +2752,7 @@ def create_app(cfg):
"samples": samples,
"labels": store.list_run_labels(conn(), run_id),
"events": store.list_run_events(conn(), run_id),
"file": store.file_out(file_row) if file_row else None,
"file": store.file_out(file_row, watch_dir=cfg.get("watch_dir"), conn=conn()) if file_row else None,
"project": project,
"energy": energy,
})
@@ -2514,6 +2762,18 @@ def create_app(cfg):
library.sync_watch(conn(), cfg, force=False)
return jsonify(jobs.sync_history(conn(), cfg))
@app.post("/api/v1/laeufe/<int:run_id>/verbinden")
def run_merge(run_id):
"""Zwei Protokoll-Zeilen desselben physischen Drucks verbinden (admin)."""
require_role("admin")
data = body()
other = int(data.get("other_id") or data.get("mit") or 0)
if not other:
raise GrottiError("other_id fehlt.", "lauf")
return jsonify(jobs.manual_merge_runs(
conn(), run_id, other, confirm=bool(data.get("confirm"))
))
@app.patch("/api/v1/laeufe/<int:run_id>")
def run_patch(run_id):
data = body()
+1 -1
View File
@@ -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) {
+253 -26
View File
@@ -64,8 +64,8 @@
--st-error-soft: rgba(248, 113, 113, 0.14);
--st-offline: #71717a;
--st-offline-soft: rgba(255, 255, 255, 0.04);
--st-waiting: #f97316;
--st-waiting-soft: rgba(249, 115, 22, 0.14);
--st-waiting: #f59e0b;
--st-waiting-soft: rgba(245, 158, 11, 0.16);
--st-idle: #a1a1aa;
--st-idle-soft: rgba(255, 255, 255, 0.05);
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
@@ -105,8 +105,8 @@ html[data-theme="light"] {
--st-error-soft: rgba(220, 38, 38, 0.1);
--st-offline: #73737c;
--st-offline-soft: rgba(0, 0, 0, 0.04);
--st-waiting: #ea580c;
--st-waiting-soft: rgba(234, 88, 12, 0.1);
--st-waiting: #b45309;
--st-waiting-soft: rgba(180, 83, 9, 0.12);
--st-idle: #5c5c66;
--st-idle-soft: rgba(0, 0, 0, 0.04);
}
@@ -124,14 +124,14 @@ body {
font: 14.5px/1.45 var(--font);
color: var(--ink);
background:
radial-gradient(900px 420px at 0% 0%, rgba(249, 115, 22, 0.07), transparent 55%),
radial-gradient(1100px 520px at 0% 0%, rgba(249, 115, 22, 0.14), transparent 58%),
var(--bg);
-webkit-font-smoothing: antialiased;
font-feature-settings: "cv11", "ss01";
}
html[data-theme="light"] body {
background:
radial-gradient(900px 420px at 0% 0%, rgba(234, 88, 12, 0.06), transparent 55%),
radial-gradient(1100px 520px at 0% 0%, rgba(234, 88, 12, 0.11), transparent 58%),
var(--bg);
}
.tabular, .num, .hero-main, .hero-side, .stat b, .chip, .count, .run-line-text, time {
@@ -298,6 +298,8 @@ html.shelf .hero-main,
html.shelf [data-role="hero"],
body.shelf .hero-main,
body.shelf [data-role="hero"] { font-size: 1.35rem; }
html.shelf article.machine.printing .hero-main,
body.shelf article.machine.printing .hero-main { font-size: 2.4rem; }
.mobile-nav .nav-shelf { display: none; }
@media (max-height: 900px) {
@@ -396,6 +398,9 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
min-width: 0;
}
.live-page {
margin-left: auto;
margin-right: auto;
max-width: 36rem;
}
.jetzt-eta {
@@ -548,7 +553,16 @@ button.warn:hover { background: var(--st-error-soft); filter: none; }
background: rgba(255, 255, 255, 0.025);
overflow: hidden;
}
.queue-box.is-empty { display: none; }
.live-pop .queue-box.is-empty { display: none; }
.live-page .queue-box.is-empty { display: block; }
.queue-empty {
list-style: none;
margin: 0;
padding: 0.85rem 0.7rem 1rem;
color: var(--muted);
font-size: var(--t-sm);
text-align: center;
}
.queue-box[open] { border-color: var(--line); }
.run-stack {
flex: none;
@@ -613,6 +627,8 @@ h2.queue-title { font-size: var(--t-xs); }
}
.icon-btn:hover { color: var(--ink); background: rgba(255, 255, 255, 0.06); filter: none; }
.icon-btn.danger:hover { color: var(--st-error); background: var(--st-error-soft); }
.ghost.tiny-btn.danger { color: var(--st-error); }
.ghost.tiny-btn.danger:hover { background: var(--st-error-soft); }
.queue-list { list-style: none; margin: 0; padding: 0 0.4rem 0.45rem; display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.35rem; }
.queue-list > li { min-width: 0; }
.queue-empty {
@@ -895,6 +911,22 @@ h2 { font-size: 0.95rem; margin: 0 0 0.65rem; letter-spacing: -0.02em; font-weig
flex: 0 1 auto;
width: auto;
}
.stat-more {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.35rem 0.65rem 0.55rem;
background: rgba(255, 255, 255, 0.02);
}
.stat-more > summary {
list-style: none;
cursor: pointer;
color: var(--muted);
font-size: var(--t-sm);
font-weight: 650;
padding: 0.25rem 0;
}
.stat-more > summary::-webkit-details-marker { display: none; }
.stat-more[open] > summary { margin-bottom: 0.45rem; }
.stat-filters {
display: flex;
flex-wrap: wrap;
@@ -985,7 +1017,7 @@ article.machine {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 40%), var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), var(--shadow);
overflow: hidden;
display: flex;
flex-direction: column;
@@ -993,6 +1025,9 @@ article.machine {
cursor: pointer;
transition: border-color 0.3s var(--ease), box-shadow 0.3s var(--ease), transform 0.2s var(--ease);
}
html[data-theme="light"] article.machine {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), var(--shadow);
}
article.machine::before {
content: "";
position: absolute;
@@ -1002,12 +1037,31 @@ article.machine::before {
}
article.machine:hover {
border-color: var(--line-strong);
box-shadow: var(--shadow), 0 0 0 1px rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), var(--shadow), 0 0 0 1px rgba(255, 255, 255, 0.04);
transform: translateY(-1px);
}
article.machine.printing { border-color: rgba(61, 206, 124, 0.35); box-shadow: var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.06), 0 18px 40px -24px rgba(61, 206, 124, 0.45); }
article.machine.printing::before { background: linear-gradient(90deg, transparent, var(--st-printing), transparent); opacity: 0.7; }
article.machine.printing:hover { border-color: rgba(61, 206, 124, 0.55); box-shadow: var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.12), 0 18px 40px -20px rgba(61, 206, 124, 0.55); }
html[data-theme="light"] article.machine:hover {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.95), var(--shadow), 0 0 0 1px rgba(0, 0, 0, 0.04);
}
article.machine.printing {
border-color: rgba(61, 206, 124, 0.35);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.06), 0 18px 40px -24px rgba(61, 206, 124, 0.45);
}
article.machine.printing::before {
background: linear-gradient(90deg, transparent, var(--st-printing), transparent);
opacity: 0.7;
animation: print-strip-breathe 2.8s var(--ease) infinite;
}
article.machine.printing:hover {
border-color: rgba(61, 206, 124, 0.55);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.12), 0 18px 40px -20px rgba(61, 206, 124, 0.55);
}
article.machine.printing .hero-main {
font-size: 2.4rem;
letter-spacing: -0.05em;
font-variant-numeric: tabular-nums;
line-height: 1.05;
}
article.machine.paused { border-color: rgba(251, 191, 36, 0.35); }
article.machine.paused:hover { border-color: rgba(251, 191, 36, 0.55); }
article.machine.heating { border-color: rgba(251, 191, 36, 0.28); }
@@ -1017,6 +1071,10 @@ article.machine.error::before { background: var(--st-error); opacity: 0.7; }
article.machine.error:hover { border-color: rgba(248, 113, 113, 0.6); }
article.machine.offline { opacity: 0.62; }
article.machine.offline:hover { opacity: 1; }
@keyframes print-strip-breathe {
0%, 100% { opacity: 0.45; }
50% { opacity: 0.9; }
}
.machine-head {
padding: 0.85rem 0.95rem 0.3rem;
@@ -1328,6 +1386,15 @@ article.printer {
}
main > .card { margin-bottom: 0.85rem; }
.split { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); gap: 1rem; align-items: start; }
.edit-split { grid-template-columns: minmax(0, 1.15fr) minmax(18rem, 0.95fr); }
.edit-split.is-idle { grid-template-columns: 1fr; }
.edit-panel { position: sticky; top: calc(var(--topbar-h) + 0.75rem); }
.cell-path {
max-width: 14rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.55rem 0.75rem; }
.field { display: grid; gap: 0.28rem; min-width: 0; }
.field > label, .field-label {
@@ -1441,7 +1508,7 @@ td select { width: auto; min-width: 8.5rem; padding: 0.3rem 0.45rem; min-height:
place-items: center;
padding: 1.5rem;
background:
radial-gradient(ellipse at 20% 0%, rgba(61, 206, 124, 0.12), transparent 45%),
radial-gradient(ellipse at 20% 0%, rgba(249, 115, 22, 0.16), transparent 50%),
var(--bg);
}
.auth-card {
@@ -1450,6 +1517,10 @@ td select { width: auto; min-width: 8.5rem; padding: 0.3rem 0.45rem; min-height:
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.4rem 1.35rem 1.5rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), var(--shadow);
}
html[data-theme="light"] .auth-card {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), var(--shadow);
}
.auth-card h1 { margin: 0 0 0.35rem; font-size: 1.45rem; }
.auth-brand {
@@ -2150,7 +2221,7 @@ body.desk-on main {
.chip.complete { --c: var(--st-done); --cs: var(--st-done-soft); }
.chip.error { --c: var(--st-error); --cs: var(--st-error-soft); }
.chip.offline { --c: var(--st-offline); --cs: var(--st-offline-soft); }
.chip.waiting { --c: var(--st-waiting); --cs: transparent; box-shadow: inset 0 0 0 1px rgba(249, 115, 22, 0.45); }
.chip.waiting { --c: var(--st-waiting); --cs: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--st-waiting) 45%, transparent); }
.chip.cancelled { --c: var(--muted); --cs: var(--st-idle-soft); }
.chip.sm { font-size: 0.6rem; padding: 0.12rem 0.45rem 0.12rem 0.38rem; gap: 0.3rem; }
.chip.sm::before { width: 0.36rem; height: 0.36rem; }
@@ -2290,7 +2361,46 @@ body.desk-on main {
.strip.stopped .strip-actions button:not(.ghost) { background: var(--accent); color: var(--accent-ink); }
.strip.stopped .strip-actions button.ghost { color: #fecaca; border-color: rgba(248, 113, 113, 0.4); }
.waiting-note { font-size: var(--t-xs); color: var(--muted); display: flex; align-items: center; gap: 0.35rem; }
.waiting-note b { color: var(--accent); }
.waiting-note b { color: var(--st-waiting); }
.desk-next {
margin-top: 0.65rem;
padding: 0.55rem 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.02);
display: grid;
gap: 0.45rem;
}
.desk-next-head {
display: flex;
align-items: center;
gap: 0.35rem;
font-weight: 650;
font-size: var(--t-sm);
}
.desk-next-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.desk-next-row .grow {
display: flex;
flex-direction: column;
gap: 0.05rem;
min-width: 0;
}
.desk-next-row .grow span,
.desk-next-row .grow b {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.desk-next-files {
display: grid;
gap: 0.3rem;
padding-top: 0.25rem;
border-top: 1px solid var(--line);
}
.segmented {
display: inline-grid;
@@ -2461,7 +2571,7 @@ article.machine.flash { animation: flash 2s var(--ease); }
margin: 0;
padding: 0.35rem 0.5rem;
}
@media (min-width: 1100px) {
@media (min-width: 1280px) {
.lib-tools { flex-wrap: nowrap; }
.lib-tools .search-wrap { max-width: 12rem; }
}
@@ -2510,21 +2620,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); }
@@ -2637,11 +2748,49 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
text-decoration: none;
color: inherit;
}
.proj-file { flex-direction: column; align-items: flex-start; gap: 0.15rem; }
.proj-file { flex-direction: column; align-items: flex-start; gap: 0.15rem; flex: 1 1 auto; min-width: 0; }
.proj-file:hover, .proj-asset:hover { border-color: var(--line-strong); }
.proj-asset a { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
.proj-asset .tiny { flex: none; color: var(--muted); }
.proj-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr));
gap: 0.45rem;
margin: 0.75rem 0 0.35rem;
}
.proj-stat {
padding: 0.45rem 0.55rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.02);
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.proj-stat strong { font-size: var(--t-sm); }
.proj-file-row {
display: flex;
align-items: center;
gap: 0.45rem;
}
.proj-file-row .ghost { flex: none; }
.focus-flash {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
animation: focus-flash-fade 2.4s var(--ease) forwards;
}
@keyframes focus-flash-fade {
0% { outline-color: var(--accent); }
70% { outline-color: var(--accent); }
100% { outline-color: transparent; }
}
a.lib-proj {
color: var(--accent);
text-decoration: none;
}
a.lib-proj:hover { text-decoration: underline; }
.lib-card { position: relative; transition: box-shadow 0.2s var(--ease); }
.lib-card.drop { box-shadow: 0 0 0 2px var(--accent), var(--shadow); }
.drop-hint { margin: 0.6rem 0 0; font-size: var(--t-xs); color: var(--faint); text-align: center; }
@@ -2789,11 +2938,17 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
@media (max-width: 1080px) {
.send-layout { grid-template-columns: 1fr; }
.send-panel { position: static; }
.split, .edit-split, .mat-split { grid-template-columns: 1fr; }
.edit-panel, .mat-form { position: static; }
.doc-layout { grid-template-columns: 1fr; }
.doc-toc { position: static; display: flex; flex-wrap: wrap; gap: 0.3rem; padding-bottom: 0.9rem; border-bottom: 1px solid var(--line); }
.doc-toc p { display: none; }
.doc-toc a { border: 1px solid var(--line-strong); border-radius: 99px; padding: 0.22rem 0.65rem; font-size: var(--t-xs); }
.doc-toc a.on { border-radius: 99px; border-color: rgba(249, 115, 22, 0.55); }
.proto-table th:nth-child(4),
.proto-table th:nth-child(5),
.proto-table td:nth-child(4),
.proto-table td:nth-child(5) { display: none; }
}
@media (max-width: 960px) {
@@ -2831,7 +2986,11 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
.send-panel, .doc-toc { top: 1rem; }
.sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
.live-bar { display: none; }
.split, .grid-2 { grid-template-columns: 1fr; }
.grid-2 { grid-template-columns: 1fr; }
.proto-table th:nth-child(6),
.proto-table th:nth-child(8),
.proto-table td:nth-child(6),
.proto-table td.proto-spool { display: none; }
.fleet { grid-template-columns: 1fr; }
main { padding: 1rem 0.85rem 2rem; }
.hero-main { font-size: 1.4rem; }
@@ -3048,6 +3207,16 @@ ul.plain li:last-child { border-bottom: 0; }
gap: 0.35rem;
margin-bottom: 0.75rem;
}
.mobile-more-sheet .nav-label {
margin: 0.45rem 0 0.1rem;
padding: 0 0.25rem;
font-size: var(--t-xs);
color: var(--faint);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 700;
}
.mobile-more-sheet .nav-label:first-child { margin-top: 0; }
.mobile-more-sheet a {
display: block;
padding: 0.65rem 0.75rem;
@@ -3479,6 +3648,36 @@ td .cell-file {
margin-top: 0.15rem;
opacity: 0.85;
}
.proto-table td.proto-file .warn-text {
color: var(--warn, #b45309);
margin-top: 0.2rem;
}
.proto-actions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
justify-content: flex-end;
}
.proto-help {
margin: 0.35rem 0 0;
max-width: 36rem;
}
.proto-help summary {
cursor: pointer;
color: var(--muted);
}
.proto-help p { margin: 0.35rem 0 0; color: var(--muted); }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.proto-table td.proto-spool {
overflow: hidden;
text-overflow: ellipsis;
@@ -3492,6 +3691,13 @@ td.actions-cell {
text-align: right;
}
td.actions-cell .icon-btn { vertical-align: middle; }
td.actions-cell .act-label { margin-left: 0.15rem; font-size: var(--t-xs); }
td.actions-cell .ghost.tiny-btn.with-label {
display: inline-flex;
align-items: center;
gap: 0.2rem;
vertical-align: middle;
}
button.is-busy { position: relative; color: transparent !important; pointer-events: none; }
button.is-busy::after {
@@ -3644,8 +3850,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; }
+94
View File
@@ -333,3 +333,97 @@ 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);
}
/**
* Einheitliche Querverlinkung zwischen Seiten.
* @param {"datei"|"projekt"|"spule"|"auftrag"|"lauf"|"drucker"} kind
* @param {string|number} id
* @param {string} [label]
* @returns {string} HTML-Anker oder escaped Text
*/
function linkTo(kind, id, label) {
const text = escapeHtml(label == null || label === "" ? String(id ?? "") : String(label));
if (id == null || id === "") return text;
const enc = encodeURIComponent(String(id));
let href = "";
switch (kind) {
case "datei":
href = "/dateien?file=" + enc;
break;
case "projekt":
href = "/projekte#" + enc;
break;
case "spule":
href = "/material?spule=" + enc;
break;
case "auftrag":
href = "/auftraege#" + enc;
break;
case "lauf":
href = "/protokoll/" + enc;
break;
case "drucker":
href = "/#" + enc;
break;
default:
return text;
}
return '<a href="' + href + '">' + text + "</a>";
}
/** Hash/Query-Fokus: Element hervorheben und scrollen. */
function focusById(id, opts) {
if (!id) return null;
const el = document.getElementById(id) || document.querySelector('[data-id="' + CSS.escape(String(id)) + '"]');
if (!el) return null;
el.classList.add("focus-flash");
el.scrollIntoView({ behavior: (opts && opts.behavior) || "smooth", block: "center" });
setTimeout(() => el.classList.remove("focus-flash"), 2400);
return el;
}
window.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
window.pageSlice = pageSlice;
window.renderPager = renderPager;
window.linkTo = linkTo;
window.focusById = focusById;
+93
View File
@@ -0,0 +1,93 @@
{# Gemeinsames Druck-CSS für Spulen- und Lauf-Etiketten. Maße aus Einstellungen. #}
{% set w = etikett.width_mm if etikett is defined else 70 %}
{% set h = etikett.height_mm if etikett is defined else 40 %}
{% set qr = etikett.qr_mm if etikett is defined else 20 %}
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
color: #111;
background: #f3f3f3;
}
.toolbar {
display: flex;
gap: 0.6rem;
padding: 0.8rem 1rem;
background: #1a1a1a;
color: #eee;
}
.toolbar a, .toolbar button {
color: #111;
background: #f97316;
border: 0;
border-radius: 8px;
padding: 0.4rem 0.85rem;
font: inherit;
font-weight: 650;
text-decoration: none;
cursor: pointer;
}
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
.sheet {
width: {{ w }}mm;
min-height: {{ h }}mm;
margin: 1.2rem auto;
padding: 3.5mm;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
display: grid;
grid-template-columns: 1fr {{ qr }}mm;
gap: 2.5mm;
align-items: center;
overflow: hidden;
}
.meta { min-width: 0; }
.meta h1 {
margin: 0 0 2mm;
font-size: 4.2mm;
letter-spacing: -0.02em;
overflow-wrap: anywhere;
}
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
.dots { display: inline-flex; vertical-align: middle; margin-right: 1.5mm; }
.dot {
width: 3.2mm;
height: 3.2mm;
border-radius: 50%;
border: 0.2mm solid rgba(0,0,0,0.25);
margin-left: -1mm;
}
.dot:first-child { margin-left: 0; }
.qr-wrap {
width: {{ qr }}mm;
height: {{ qr }}mm;
overflow: hidden;
flex-shrink: 0;
}
.qr-wrap canvas,
.qr-wrap img {
display: block;
width: {{ qr }}mm !important;
height: {{ qr }}mm !important;
max-width: 100%;
max-height: 100%;
}
@page {
size: {{ w }}mm {{ h }}mm;
margin: 0;
}
@media print {
body { background: #fff; }
.toolbar { display: none; }
.sheet {
margin: 0;
border: 0;
border-radius: 0;
page-break-inside: avoid;
}
}
</style>
+1 -1
View File
@@ -14,5 +14,5 @@
<section class="queue-box" data-box="alerts" id="alert-box" hidden>
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-bell"/></svg><span class="grow">Meldungen</span><span class="count bad" id="alert-count">0</span></h2>
<ul class="queue-list" id="alert-list"></ul>
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button>&nbsp;<a href="/aktivitaet">alle&nbsp;→</a></div>
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">Meldungen gelesen</button>&nbsp;<a href="/aktivitaet">alle&nbsp;→</a></div>
</section>
+36 -9
View File
@@ -19,16 +19,26 @@
</div>
</div>
<div class="toolbar" id="f-kind" role="group" aria-label="Art"></div>
<div class="toolbar" id="f-host" role="group" aria-label="Drucker"></div>
<div class="toolbar" aria-label="Drucker">
<label class="field inline" for="f-host">
<span class="sr-only">Drucker</span>
<select id="f-host" title="Drucker">
<option value="">Alle Drucker</option>
</select>
</label>
</div>
<p id="tl-note" class="tiny"></p>
<div class="timeline" id="timeline">
<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"],
@@ -64,11 +74,13 @@ function renderFilters() {
KINDS.map(([k, label]) =>
`<button type="button" class="fchip ${filter.kinds.has(k) ? "on" : ""}" data-kind="${k}"><span class="tl-dot ${k}"></span>${label}</button>`
).join("");
document.getElementById("f-host").innerHTML =
`<button type="button" class="fchip ${filter.host ? "" : "on"}" data-host="">Alle Drucker</button>` +
const hostSel = document.getElementById("f-host");
const cur = filter.host;
hostSel.innerHTML = '<option value="">Alle Drucker</option>' +
printers.map((p) =>
`<button type="button" class="fchip ${filter.host === p.host ? "on" : ""}" data-host="${escapeHtml(p.host)}">${escapeHtml(p.name)}</button>`
`<option value="${escapeHtml(p.host)}">${escapeHtml(p.name)}</option>`
).join("");
if ([...hostSel.options].some((o) => o.value === cur)) hostSel.value = cur;
}
function sinceIso() {
@@ -111,14 +123,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 +143,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 +158,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>`;
@@ -152,8 +174,6 @@ document.addEventListener("click", (e) => {
if (!k) filter.kinds.clear();
else if (filter.kinds.has(k)) filter.kinds.delete(k);
else filter.kinds.add(k);
} else if (e.target.closest("#f-host [data-host]")) {
filter.host = e.target.closest("[data-host]").dataset.host;
} else {
return;
}
@@ -162,6 +182,13 @@ document.addEventListener("click", (e) => {
load();
});
document.getElementById("f-host").addEventListener("change", () => {
filter.host = document.getElementById("f-host").value || "";
syncUrl();
renderFilters();
load();
});
document.getElementById("tl-refresh").addEventListener("click", load);
document.getElementById("tl-read").addEventListener("click", async () => {
await api("/ereignisse/gelesen", { method: "POST", json: {} });
+237 -84
View File
@@ -7,9 +7,10 @@
</div>
<div class="actions">
<a class="ghost" id="lieferschein-link" href="#" hidden target="_blank" rel="noopener">Lieferschein</a>
<button type="button" class="btn-primary" id="order-new-head"><svg class="ico"><use href="#i-plus"/></svg>Neuer Auftrag</button>
</div>
</div>
<div class="split">
<div class="split edit-split is-idle" id="order-layout">
<section class="card">
<div class="field inline">
<label for="filter-status">Status</label>
@@ -29,11 +30,17 @@
<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">
<h2 id="order-title">Neuer Auftrag</h2>
<form id="order-form">
<section class="card edit-panel" id="order-panel" hidden>
<div class="mat-form-head">
<h2 id="order-title">Neuer Auftrag</h2>
<button type="button" class="ghost tiny-btn" id="order-edit-btn" hidden>Bearbeiten</button>
<button type="button" class="icon-btn" id="order-close" title="Schließen" aria-label="Schließen"></button>
</div>
<form id="order-form" hidden>
<input id="order-id" type="hidden">
<div class="field"><label for="order-title-in">Titel</label><input id="order-title-in" required></div>
<div class="grid-2">
@@ -56,43 +63,61 @@
<div class="field"><label for="order-notes">Notiz</label><textarea id="order-notes"></textarea></div>
<div class="actions">
<button type="submit" class="btn-primary">Speichern</button>
<button type="button" class="ghost" id="order-new">Neu</button>
<button type="button" class="ghost" id="order-cancel-edit" hidden>Abbrechen</button>
<button type="button" class="btn-danger" id="order-delete" hidden>Löschen</button>
</div>
</form>
<p id="order-note" class="tiny"></p>
<p class="tiny" id="order-progress"></p>
<p class="tiny" id="order-actuals"></p>
<h2 class="section-gap">Positionen</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Datei</th><th class="num">Stk</th><th class="num">fertig</th><th></th></tr></thead>
<tbody id="item-rows"></tbody>
</table>
</div>
<form id="item-form" class="section-gap">
<div class="grid-2">
<div class="field"><label for="item-file">Datei</label><select id="item-file"><option value="">—</option></select></div>
<div class="field"><label for="item-qty">Stück</label><input id="item-qty" type="number" min="1" step="1" value="1"></div>
<div class="field"><label for="item-price">Preis/Stk €</label><input id="item-price" type="number" min="0" step="0.01" value="0"></div>
<div class="field"><label for="item-material">Material</label><input id="item-material"></div>
<div id="order-detail" hidden>
<p class="tiny" id="order-progress"></p>
<p class="tiny" id="order-actuals"></p>
<p class="tiny" id="order-notes-view"></p>
<h2 class="section-gap">Positionen</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Datei</th><th class="num">Stk</th><th class="num">fertig</th><th></th></tr></thead>
<tbody id="item-rows"></tbody>
</table>
</div>
<div class="actions"><button type="submit">Position hinzufügen</button></div>
</form>
<details class="section-gap">
<summary>Kunde anlegen / bearbeiten</summary>
<form id="customer-form">
<input type="hidden" id="cust-id">
<div class="field"><label for="cust-name">Name</label><input id="cust-name" required></div>
<div class="field"><label for="cust-email">E-Mail</label><input id="cust-email" type="email"></div>
<form id="item-edit-form" class="section-gap card" hidden style="padding:0.75rem;margin-top:0.75rem">
<input type="hidden" id="item-edit-id">
<h3 style="margin:0 0 0.5rem;font-size:0.95rem">Position ändern</h3>
<div class="grid-2">
<div class="field"><label for="item-edit-qty">Stück</label><input id="item-edit-qty" type="number" min="1" step="1" value="1"></div>
<div class="field"><label for="item-edit-price">Preis/Stk €</label><input id="item-edit-price" type="number" min="0" step="0.01" value="0"></div>
</div>
<div class="actions">
<button type="submit" class="btn-primary">Kunde speichern</button>
<button type="button" class="ghost" id="cust-edit-current">Aktuellen laden</button>
<button type="button" class="btn-danger" id="cust-delete" hidden>Kunde löschen</button>
<button type="submit" class="btn-primary">Speichern</button>
<button type="button" class="ghost" id="item-edit-cancel">Abbrechen</button>
</div>
</form>
</details>
<form id="item-form" class="section-gap">
<div class="grid-2">
<div class="field"><label for="item-file">Datei</label><select id="item-file"><option value="">—</option></select></div>
<div class="field"><label for="item-qty">Stück</label><input id="item-qty" type="number" min="1" step="1" value="1"></div>
<div class="field"><label for="item-price">Preis/Stk €</label><input id="item-price" type="number" min="0" step="0.01" value="0"></div>
<div class="field"><label for="item-material">Material</label><input id="item-material"></div>
</div>
<div class="actions"><button type="submit">Position hinzufügen</button></div>
</form>
<details class="section-gap">
<summary>Kunde anlegen / bearbeiten</summary>
<form id="customer-form">
<input type="hidden" id="cust-id">
<div class="field"><label for="cust-name">Name</label><input id="cust-name" required></div>
<div class="field"><label for="cust-email">E-Mail</label><input id="cust-email" type="email"></div>
<div class="actions">
<button type="submit" class="btn-primary">Kunde speichern</button>
<button type="button" class="ghost" id="cust-edit-current">Aktuellen laden</button>
<button type="button" class="btn-danger" id="cust-delete" hidden>Kunde löschen</button>
</div>
</form>
</details>
</div>
</section>
</div>
{% endblock %}
@@ -106,6 +131,43 @@ let orders = [];
let customers = [];
let files = [];
let current = null;
/** idle | create | view | edit */
let panelMode = "idle";
let orderPage = 1;
document.getElementById("order-close").innerHTML = icon("x");
function openPanel() {
document.getElementById("order-panel").hidden = false;
document.getElementById("order-layout").classList.remove("is-idle");
document.getElementById("order-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
}
function closePanel() {
panelMode = "idle";
current = null;
document.getElementById("order-panel").hidden = true;
document.getElementById("order-layout").classList.add("is-idle");
document.getElementById("order-form").hidden = true;
document.getElementById("order-detail").hidden = true;
document.getElementById("order-edit-btn").hidden = true;
document.getElementById("order-cancel-edit").hidden = true;
document.getElementById("order-delete").hidden = true;
document.getElementById("lieferschein-link").hidden = true;
document.getElementById("item-edit-form").hidden = true;
document.getElementById("order-note").textContent = "";
history.replaceState(null, "", location.pathname);
renderList();
}
function setFormValues(order) {
document.getElementById("order-id").value = order ? order.id : "";
document.getElementById("order-title-in").value = order ? order.title : "";
document.getElementById("order-status").value = order ? order.status : "idee";
document.getElementById("order-due").value = order && order.due_at ? order.due_at.slice(0, 10) : "";
document.getElementById("order-customer").value = order && order.customer_id ? order.customer_id : "";
document.getElementById("order-notes").value = order ? (order.notes || "") : "";
}
function fillCustomers() {
const sel = document.getElementById("order-customer");
@@ -145,27 +207,64 @@ function paintActuals(order) {
el.textContent = bits.join(" · ");
}
function fillOrder(order) {
function showCreate() {
panelMode = "create";
current = null;
setFormValues(null);
document.getElementById("order-title").textContent = "Neuer Auftrag";
document.getElementById("order-form").hidden = false;
document.getElementById("order-detail").hidden = true;
document.getElementById("order-edit-btn").hidden = true;
document.getElementById("order-cancel-edit").hidden = true;
document.getElementById("order-delete").hidden = true;
document.getElementById("lieferschein-link").hidden = true;
document.getElementById("order-note").textContent = "";
document.getElementById("item-edit-form").hidden = true;
history.replaceState(null, "", location.pathname);
openPanel();
renderList();
document.getElementById("order-title-in").focus();
}
function showView(order) {
panelMode = "view";
current = order;
document.getElementById("order-id").value = order ? order.id : "";
document.getElementById("order-title-in").value = order ? order.title : "";
document.getElementById("order-status").value = order ? order.status : "idee";
document.getElementById("order-due").value = order && order.due_at ? order.due_at.slice(0, 10) : "";
document.getElementById("order-customer").value = order && order.customer_id ? order.customer_id : "";
document.getElementById("order-notes").value = order ? order.notes : "";
document.getElementById("order-title").textContent = order ? order.title : "Neuer Auftrag";
document.getElementById("order-delete").hidden = !order;
setFormValues(order);
document.getElementById("order-title").textContent = order.title || "Auftrag";
document.getElementById("order-form").hidden = true;
document.getElementById("order-detail").hidden = false;
document.getElementById("order-edit-btn").hidden = false;
document.getElementById("order-cancel-edit").hidden = true;
document.getElementById("order-delete").hidden = true;
document.getElementById("item-edit-form").hidden = true;
const link = document.getElementById("lieferschein-link");
if (order) {
link.href = "/auftraege/" + order.id + "/lieferschein";
link.hidden = false;
} else {
link.hidden = true;
}
const prog = order && order.progress;
link.href = "/auftraege/" + order.id + "/lieferschein";
link.hidden = false;
history.replaceState(null, "", "#" + order.id);
const prog = order.progress;
document.getElementById("order-progress").textContent = prog ? "Fortschritt: " + prog.label : "";
paintActuals(order || {});
renderItems(order && order.items ? order.items : []);
paintActuals(order);
const notes = (order.notes || "").trim();
document.getElementById("order-notes-view").textContent = notes ? "Notiz: " + notes : "";
renderItems(order.items || []);
openPanel();
renderList();
}
function showEdit(order) {
panelMode = "edit";
current = order;
setFormValues(order);
document.getElementById("order-title").textContent = "Bearbeiten";
document.getElementById("order-form").hidden = false;
document.getElementById("order-detail").hidden = true;
document.getElementById("order-edit-btn").hidden = true;
document.getElementById("order-cancel-edit").hidden = false;
document.getElementById("order-delete").hidden = false;
document.getElementById("item-edit-form").hidden = true;
openPanel();
renderList();
document.getElementById("order-title-in").focus();
}
function renderItems(items) {
@@ -178,7 +277,11 @@ function renderItems(items) {
items.forEach((item) => {
const tr = document.createElement("tr");
tr.innerHTML = "<td></td><td class=\"num\"></td><td class=\"num\"></td><td class=\"actions\"></td>";
tr.children[0].textContent = item.file_name || ("#" + (item.file_id || "—"));
if (item.file_id) {
tr.children[0].innerHTML = linkTo("datei", item.file_id, item.file_name || ("#" + item.file_id));
} else {
tr.children[0].textContent = item.file_name || "—";
}
tr.children[1].textContent = item.qty;
tr.children[2].textContent = item.done_ok + (item.done_bad ? " (−" + item.done_bad + ")" : "");
const tools = document.createElement("div");
@@ -193,7 +296,7 @@ function renderItems(items) {
edit.type = "button";
edit.className = "ghost tiny-btn";
edit.textContent = "Ändern";
edit.addEventListener("click", () => editItem(item));
edit.addEventListener("click", () => beginEditItem(item));
const del = document.createElement("button");
del.type = "button";
del.className = "btn-danger tiny-btn";
@@ -203,7 +306,7 @@ function renderItems(items) {
await api("/auftraege/positionen/" + item.id, { method: "DELETE" });
toast("Position gelöscht.");
const full = await api("/auftraege/" + current.id);
fillOrder(full);
showView(full);
});
});
tools.append(btn, edit, del);
@@ -212,22 +315,12 @@ function renderItems(items) {
});
}
async function editItem(item) {
const qty = prompt("Stückzahl", String(item.qty || 1));
if (qty == null) return;
const price = prompt("Preis/Stk €", String(item.unit_price || 0));
if (price == null) return;
try {
await api("/auftraege/positionen/" + item.id, {
method: "PATCH",
json: { qty: Number(qty) || 1, unit_price: Number(price) || 0 },
});
const full = await api("/auftraege/" + current.id);
fillOrder(full);
toast("Position gespeichert.");
} catch (error) {
showNote("order-note", error);
}
function beginEditItem(item) {
document.getElementById("item-edit-id").value = item.id;
document.getElementById("item-edit-qty").value = item.qty || 1;
document.getElementById("item-edit-price").value = item.unit_price || 0;
document.getElementById("item-edit-form").hidden = false;
document.getElementById("item-edit-qty").focus();
}
async function einreihen(itemId) {
@@ -239,7 +332,7 @@ async function einreihen(itemId) {
});
toast("In Warteschlange.");
const full = await api("/auftraege/" + current.id);
fillOrder(full);
showView(full);
await loadList();
} catch (error) {
showNote("order-note", error);
@@ -249,38 +342,47 @@ async function einreihen(itemId) {
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));
body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="btn-primary" id="order-empty-new">Ersten Auftrag anlegen</button></p></div></td></tr>';
document.getElementById("order-empty-new")?.addEventListener("click", showCreate);
renderPager(pager, null, () => {});
return;
}
const selected = document.getElementById("order-id").value;
orders.forEach((order) => {
const selected = current ? String(current.id) : "";
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>";
tr.innerHTML = "<td></td><td></td><td class=\"cell-path\"></td>";
tr.children[0].textContent = order.title;
const st = order.status || "";
const chipState = st === "druckt" ? "printing" : (st === "fertig" || st === "geliefert" || st === "bezahlt") ? "done" : st === "bestaetigt" ? "waiting" : "idle";
tr.children[1].innerHTML = chipHtml(chipState, STATUS_LABEL[st] || st);
tr.children[2].textContent = (order.progress && order.progress.label) || "";
tr.children[2].title = (order.progress && order.progress.label) || "";
tr.addEventListener("click", async () => {
try {
const full = await api("/auftraege/" + order.id);
fillOrder(full);
renderList();
showView(full);
} catch (error) {
showNote("order-note", error);
}
});
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();
}
@@ -292,11 +394,28 @@ async function loadBase() {
fillCustomers();
fillFiles();
await loadList();
fillOrder(null);
}
document.getElementById("filter-status").addEventListener("change", () => loadList().catch((e) => showNote("order-note", e)));
document.getElementById("order-new-head").addEventListener("click", showCreate);
document.getElementById("order-close").addEventListener("click", closePanel);
document.getElementById("order-edit-btn").addEventListener("click", () => {
if (current) showEdit(current);
});
document.getElementById("order-cancel-edit").addEventListener("click", async () => {
if (!current) {
closePanel();
return;
}
try {
const full = await api("/auftraege/" + current.id);
showView(full);
} catch (error) {
showNote("order-note", error);
}
});
document.getElementById("order-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("order-id").value;
@@ -312,22 +431,22 @@ document.getElementById("order-form").addEventListener("submit", async (event) =
? await api("/auftraege/" + id, { method: "PATCH", json: payload })
: await api("/auftraege", { method: "POST", json: payload });
showNote("order-note", null);
document.getElementById("order-note").textContent = "Gespeichert.";
toast("Gespeichert.", "good");
await loadList();
fillOrder(saved);
const full = await api("/auftraege/" + saved.id);
showView(full);
} catch (error) {
showNote("order-note", error);
}
});
document.getElementById("order-new").addEventListener("click", () => fillOrder(null));
document.getElementById("order-delete").addEventListener("click", (event) => {
const id = document.getElementById("order-id").value;
if (!id) return;
armButton(event.currentTarget, "Auftrag wirklich löschen?", async () => {
await api("/auftraege/" + id, { method: "DELETE" });
toast("Gelöscht.");
fillOrder(null);
closePanel();
loadList();
});
});
@@ -347,12 +466,36 @@ document.getElementById("item-form").addEventListener("submit", async (event) =>
try {
await api("/auftraege/" + current.id + "/positionen", { method: "POST", json: payload });
const full = await api("/auftraege/" + current.id);
fillOrder(full);
showView(full);
} catch (error) {
showNote("order-note", error);
}
});
document.getElementById("item-edit-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (!current) return;
const itemId = document.getElementById("item-edit-id").value;
try {
await api("/auftraege/positionen/" + itemId, {
method: "PATCH",
json: {
qty: Number(document.getElementById("item-edit-qty").value) || 1,
unit_price: Number(document.getElementById("item-edit-price").value) || 0,
},
});
document.getElementById("item-edit-form").hidden = true;
const full = await api("/auftraege/" + current.id);
showView(full);
toast("Position gespeichert.");
} catch (error) {
showNote("order-note", error);
}
});
document.getElementById("item-edit-cancel").addEventListener("click", () => {
document.getElementById("item-edit-form").hidden = true;
});
document.getElementById("customer-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("cust-id").value;
@@ -409,6 +552,16 @@ document.getElementById("cust-delete").addEventListener("click", (event) => {
});
});
loadBase().catch((error) => showNote("order-note", error));
loadBase().then(async () => {
const hash = (location.hash || "").replace(/^#/, "");
if (hash && /^\d+$/.test(hash)) {
try {
const full = await api("/auftraege/" + hash);
showView(full);
} catch (error) {
showNote("order-note", error);
}
}
}).catch((error) => showNote("order-note", error));
</script>
{% endblock %}
+11 -5
View File
@@ -110,9 +110,14 @@ function costBreakdownHtml(energy, filamentCost) {
function render(data) {
const run = data.run;
const name = (run.filename || "").split("/").pop() || "ohne Datei";
document.getElementById("bericht-sub").textContent =
(run.printer_name || run.printer_host) + " · " + name
+ (run.chargen_nr ? " · " + run.chargen_nr : "");
const subBits = [
run.printer_host
? linkTo("drucker", run.printer_host, run.printer_name || run.printer_host)
: escapeHtml(run.printer_name || ""),
run.file_id ? linkTo("datei", run.file_id, name) : escapeHtml(name),
run.chargen_nr ? escapeHtml(run.chargen_nr) : "",
].filter(Boolean);
document.getElementById("bericht-sub").innerHTML = subBits.join(" · ");
const planned = Number(run.weight_planned_g) || 0;
const used = Number(run.weight_used_g) || 0;
@@ -128,7 +133,7 @@ function render(data) {
const spoolRows = (data.spools || []).map((s) => `
<tr>
<td>${escapeHtml(s.name)}${s.ace_slot ? " · ACE " + s.ace_slot : ""}</td>
<td>${s.spool_id ? linkTo("spule", s.spool_id, s.name) : escapeHtml(s.name)}${s.ace_slot ? " · ACE " + s.ace_slot : ""}</td>
<td>${escapeHtml(s.material_type || "")}</td>
<td class="num">${grams(s.weight_planned_g)}</td>
<td class="num">${grams(s.weight_g)}${s.is_estimate ? ' <span class="tag">Schätzwert</span>' : ""}</td>
@@ -161,7 +166,8 @@ function render(data) {
<p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})${run.cost_per_part != null ? " · <b>" + euro(run.cost_per_part) + "</b>/Teil" : ""}</p>
${run.pieces_ok || run.pieces_bad ? `<p>Stück <b>${run.pieces_ok || 0}</b> gut${run.pieces_bad ? " · " + run.pieces_bad + " schlecht" : ""}</p>` : ""}
${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""}
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
${data.project ? "<p class=\"tiny\">Projekt: " + linkTo("projekt", data.project.id, data.project.name) + "</p>" : ""}
${run.order_id ? "<p class=\"tiny\">Auftrag: " + linkTo("auftrag", run.order_id, "#" + run.order_id) + "</p>" : ""}
</div>
${costBreakdownHtml(energy, run.filament_cost)}
</div>
+288 -62
View File
@@ -32,6 +32,9 @@
<option value="">Alle Maschinen</option>
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
<option value="kobra_4">Kobra 4</option>
<option value="anycubic_i3">Anycubic i3</option>
<option value="tenlog">Tenlog</option>
</select>
<select id="file-filter-project">
<option value="">Alle Projekte</option>
@@ -57,6 +60,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>
@@ -115,7 +119,15 @@
<div class="opt-body">
<div id="loaded-spools" class="loaded-spools"></div>
<p class="tiny">Verbrauch geht von der Rolle am Drucker. Wechsel unter <a href="/material">Material</a>.</p>
<label class="check"><input id="send-bed-level" type="checkbox"> Bettnetz vor dem Druck messen</label>
<label class="check" id="send-bed-level-wrap"><input id="send-bed-level" type="checkbox"> Bettnetz vor dem Druck messen</label>
<label class="check" id="send-marlin-wrap" hidden>
Start über USB
<select id="send-marlin-mode">
<option value="stream">Strom (Zeile für Zeile)</option>
<option value="sd">SD-Karte</option>
</select>
</label>
<p class="tiny" id="send-marlin-hint" hidden>„Wenn frei“ startet immer als Strom.</p>
<label class="check"><input id="send-override" type="checkbox"> Auch senden, wenn die Maschine nicht passt</label>
<label for="send-project">Projekt</label>
<select id="send-project"><option value="">keins</option></select>
@@ -167,7 +179,12 @@
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=10"></script>
<script src="{{ url_for('static', filename='sweep-viewer.js') }}?v=1"></script>
<script>
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
const families = {
kobra_s1: "Kobra S1", kobra_3: "Kobra 3", kobra_4: "Kobra 4",
anycubic_i3: "Anycubic i3", tenlog: "Tenlog",
};
const MOONRAKER_FAMILIES = new Set(["kobra_3", "kobra_s1"]);
const MARLIN_FAMILIES = new Set(["anycubic_i3", "tenlog"]);
const ABLAUF_KEY = "grotti-ablauf-v2";
const SEND_KEY = "grotti-send-v1";
const LIB_KEY = "grotti-lib-v1";
@@ -358,40 +375,8 @@ function spoolWarnText() {
if (!file || need <= 0) return "";
const maps = collectAceMaps();
const hosts = selectedHosts();
const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean));
const fileProfiles = ((file.filaments || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean));
const PROFILE_VARIANTS = ["meta", "matte", "matt", "silk", "plus", "hf", "cf"];
function profileHasVariant(want) {
return PROFILE_VARIANTS.some((v) => new RegExp("\\b" + v + "\\b", "i").test(want || ""));
}
function profileBaseType(want) {
const m = String(want || "").toLowerCase().match(/\b(pla|petg|abs|asa|tpu|pa|pc|hips|pva)\b/);
return m ? m[1] : "";
}
function profileOk(spoolRows) {
if (!fileProfiles.length) return true;
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
const types = spoolRows.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
if (!names.length && !types.length) return true;
return fileProfiles.every((want) => {
if (names.some((n) => {
if (n === want) return true;
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, "");
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
return !PROFILE_VARIANTS.includes(first);
})) return true;
// Basis-Profil (nur „… PLA“): Typ reicht, Marke egal — keine Meta/Matte-Spule
if (profileHasVariant(want)) return false;
const kind = profileBaseType(want);
if (!kind) return true;
return spoolRows.some((s) => {
const t = (s.material_type || "").toLowerCase();
const n = (s.name || "").toLowerCase();
return t === kind && !profileHasVariant(n);
});
});
}
const fileTypes = fileTypesOf(file);
const fileProfiles = fileProfilesOf(file);
for (const host of hosts) {
const name = (printers.find((p) => p.host === host) || {}).name || host;
const mapping = maps[host] || [];
@@ -413,7 +398,7 @@ function spoolWarnText() {
});
if (!any) return name + ": keine Grotti-Spule auf den gewählten ACE-Plätzen — Abzug entfällt.";
if (left + 0.5 < need) return "ACE auf " + name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
if (!profileOk(mappedSpools)) {
if (!profileOk(file, mappedSpools)) {
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
}
if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
@@ -430,7 +415,7 @@ function spoolWarnText() {
if (left + 0.5 < need) {
return name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
}
if (!profileOk(external)) {
if (!profileOk(file, external)) {
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
}
const types = external.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
@@ -603,6 +588,20 @@ function folderLabel(folder) {
return "Wurzel";
}
function folderMatches(fileFolder, selected) {
if (!selected) return true;
if (selected === "root") return !fileFolder;
if (!fileFolder) return false;
return fileFolder === selected || fileFolder.startsWith(selected + "/");
}
function releaseRootForProject() {
const project = document.getElementById("file-filter-project").value;
const folder = document.getElementById("file-filter-folder");
if (!folder || !project || project === "none" || folder.value !== "root") return;
folder.value = "";
}
function saveLibMemory() {
try {
localStorage.setItem(LIB_KEY, JSON.stringify({
@@ -649,8 +648,7 @@ function filteredFiles() {
const types = ((file.filaments) || []).map((f) => f.type).filter(Boolean);
if (!types.includes(material)) return false;
}
if (folder === "root" && folderKey(file)) return false;
if (folder && folder !== "root" && folderKey(file) !== folder) return false;
if (!folderMatches(folderKey(file), folder)) return false;
if (!q) return true;
const hay = [
file.name || "",
@@ -808,10 +806,13 @@ function alwaysMeta(file, project) {
file.weight_g ? grams(file.weight_g) : "",
layerHeightText(file),
families[file.printer_family] || "",
project ? project.name : "",
];
if (!file.weight_g && !file.estimated_s) bits.push(sizeText(file.size_bytes));
return bits.filter(Boolean).map((t) => `<span>${escapeHtml(t)}</span>`).join("") + swatchesHtml(file);
const spans = bits.filter(Boolean).map((t) => `<span>${escapeHtml(t)}</span>`);
if (project) {
spans.push(`<a class="lib-proj" href="/projekte#${encodeURIComponent(project.id)}" onclick="event.stopPropagation()">${escapeHtml(project.name)}</a>`);
}
return spans.join("") + swatchesHtml(file);
}
function detailMeta(file) {
@@ -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 ---------- */
@@ -926,13 +970,116 @@ function familyMismatch(p, file) {
return !!(file && file.printer_family && p.family && file.printer_family !== p.family);
}
function renderTargets() {
const box = document.getElementById("targets");
const PROFILE_VARIANTS = ["meta", "matte", "matt", "silk", "plus", "hf", "cf"];
function fileProfilesOf(file) {
return ((file && file.filaments) || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean);
}
function fileTypesOf(file) {
return ((file && file.filaments) || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean);
}
function profileHasVariant(want) {
return PROFILE_VARIANTS.some((v) => new RegExp("\\b" + v + "\\b", "i").test(want || ""));
}
function profileBaseType(want) {
const m = String(want || "").toLowerCase().match(/\b(pla|petg|abs|asa|tpu|pa|pc|hips|pva)\b/);
return m ? m[1] : "";
}
function profileOk(file, spoolRows) {
const fileProfiles = fileProfilesOf(file);
if (!fileProfiles.length) return true;
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
const types = spoolRows.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
if (!names.length && !types.length) return true;
return fileProfiles.every((want) => {
if (names.some((n) => {
if (n === want) return true;
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, "");
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
return !PROFILE_VARIANTS.includes(first);
})) return true;
if (profileHasVariant(want)) return false;
const kind = profileBaseType(want);
if (!kind) return true;
return spoolRows.some((s) => {
const t = (s.material_type || "").toLowerCase();
const n = (s.name || "").toLowerCase();
return t === kind && !profileHasVariant(n);
});
});
}
function materialFitsHost(file, host) {
const profiles = fileProfilesOf(file);
const types = fileTypesOf(file);
if (!profiles.length && !types.length) return true;
const onHost = spoolsOnHost(host);
if (!onHost.length) return false;
if (profiles.length) return profileOk(file, onHost);
const spoolTypes = onHost.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
if (!spoolTypes.length) return false;
return types.some((t) => spoolTypes.includes(t));
}
function printerFitsFile(p, file, override) {
if (!p || !file) return false;
if (sel.tab === "ablauf" && p.family !== "kobra_3") return false;
if (familyMismatch(p, file) && !override) return false;
if (!materialFitsHost(file, p.host)) return false;
return true;
}
function printerPickScore(p, file) {
let score = 0;
if (canStartNow(p)) score += 100;
else if (isFree(p)) score += 50;
else if (p.needs_clear) score += 20;
if (spoolsOnHost(p.host).length) score += 10;
if (file && file.printer_family && p.family === file.printer_family) score += 5;
return score;
}
function suggestHostsForFile(file) {
if (!file) return new Set();
const override = !!(document.getElementById("send-override") || {}).checked;
const kept = [...sel.hosts].filter((host) => {
const p = printers.find((x) => x.host === host);
return printerFitsFile(p, file, override);
});
if (kept.length) return new Set(kept);
const candidates = printers.filter((p) => printerFitsFile(p, file, override));
candidates.sort((a, b) => printerPickScore(b, file) - printerPickScore(a, file)
|| String(a.name || "").localeCompare(String(b.name || ""), "de"));
if (!candidates.length) return new Set();
return new Set([candidates[0].host]);
}
function noPrinterReason(file) {
if (!file || !printers.length) return "";
const override = !!(document.getElementById("send-override") || {}).checked;
const familyOk = printers.filter((p) => {
if (sel.tab === "ablauf" && p.family !== "kobra_3") return false;
return !familyMismatch(p, file) || override;
});
if (!familyOk.length) {
const want = families[file.printer_family] || file.printer_family || "diese Maschine";
return "Kein Drucker für " + want + ".";
}
if (familyOk.some((p) => materialFitsHost(file, p.host))) return "";
const need = fileProfilesOf(file).join("/") || fileTypesOf(file).join("/") || "Material";
return "Keine passende Rolle am Drucker (" + need + "). Unter Material zuweisen.";
}
function targetsHtml() {
const file = selectedFile();
const override = document.getElementById("send-override").checked;
const nextMode = sel.tab === "send" && sel.mode === "next";
box.classList.toggle("dimmed", nextMode);
box.innerHTML = printers.map((p) => {
return printers.map((p) => {
const s = targetState(p);
const mismatch = familyMismatch(p, file);
const notK3 = sel.tab === "ablauf" && p.family !== "kobra_3";
@@ -945,6 +1092,17 @@ function renderTargets() {
<span class="row2 ${s.bad || off ? "bad" : ""}">${escapeHtml(why)}</span>
</button>`;
}).join("") || '<div class="picked-empty">' + icon("printer") + '<span>Noch kein Drucker.</span><span class="tiny"><a href="/einstellungen">In Einstellungen anlegen</a></span></div>';
}
function renderTargets() {
const box = document.getElementById("targets");
const nextMode = sel.tab === "send" && sel.mode === "next";
box.classList.toggle("dimmed", nextMode);
const html = targetsHtml();
if (box._html !== html) {
box._html = html;
box.innerHTML = html;
}
document.getElementById("step-target").classList.toggle("ok", nextMode || selectedHosts().length > 0);
}
@@ -1000,7 +1158,12 @@ function updateSendButton() {
button.disabled = !!problem || !!sendPoll;
hint.textContent = problem || note;
hint.classList.toggle("bad", !!problem);
targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti nach Sorte." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
let targetNote = "";
if (sel.mode === "next" && sel.tab === "send") targetNote = "Ziel wählt Grotti nach Sorte.";
else if (chosen.length > 1) targetNote = "Je Drucker ein eigener Auftrag.";
else if (file && !chosen.length && sel.mode === "this") targetNote = noPrinterReason(file);
targetHint.textContent = targetNote;
targetHint.classList.toggle("bad", !!(file && !chosen.length && sel.mode === "this" && targetNote));
}
function updateAblaufButton() {
@@ -1022,12 +1185,33 @@ function updateAblaufButton() {
hint.classList.toggle("bad", !!problem);
}
function syncSendOptions() {
const chosen = selectedPrinters();
const moonraker = chosen.some((p) => MOONRAKER_FAMILIES.has(p.family));
const marlin = chosen.some((p) => MARLIN_FAMILIES.has(p.family));
const bedWrap = document.getElementById("send-bed-level-wrap");
const marlinWrap = document.getElementById("send-marlin-wrap");
const marlinHint = document.getElementById("send-marlin-hint");
if (bedWrap) {
bedWrap.hidden = chosen.length > 0 && !moonraker;
if (bedWrap.hidden) document.getElementById("send-bed-level").checked = false;
}
if (marlinWrap) marlinWrap.hidden = !marlin;
if (marlinHint) marlinHint.hidden = !marlin;
}
function updateOptionsSummary() {
syncSendOptions();
const parts = [];
const hosts = selectedHosts();
const loaded = hosts.reduce((n, h) => n + spoolsOnHost(h).length, 0);
if (loaded) parts.push(loaded + (loaded === 1 ? " Spule" : " Spulen"));
if (document.getElementById("send-bed-level").checked) parts.push("Bettnetz");
if (!document.getElementById("send-bed-level-wrap")?.hidden && document.getElementById("send-bed-level").checked) {
parts.push("Bettnetz");
}
if (!document.getElementById("send-marlin-wrap")?.hidden) {
parts.push(document.getElementById("send-marlin-mode").value === "sd" ? "SD-Karte" : "Strom");
}
if (document.getElementById("send-override").checked) parts.push("Maschine egal");
const project = projects.find((p) => String(p.id) === document.getElementById("send-project")?.value);
if (project) parts.push(project.name);
@@ -1056,7 +1240,9 @@ function refreshFlow() {
updateAblaufButton();
}
function selectFile(id) {
function selectFile(id, opts) {
const keepHosts = !!(opts && opts.keepHosts);
const fromUrlHost = !!(opts && opts.fromUrlHost);
sel.fileId = id;
const file = selectedFile();
if (file && file.project_id && document.getElementById("send-project")) {
@@ -1067,6 +1253,10 @@ function selectFile(id) {
saveAblaufMemory();
if (sel.tab === "ablauf") renderAblauf();
}
if (file && !keepHosts && !fromUrlHost && !(sel.tab === "send" && sel.mode === "next")) {
sel.hosts = suggestHostsForFile(file);
saveSendMemory();
}
document.querySelectorAll("#lib-list .lib-item").forEach((n) => n.classList.toggle("on", n.dataset.file === String(id)));
refreshFlow();
scheduleToolpath(file, 0, 0);
@@ -1110,6 +1300,17 @@ async function load() {
fillProjectFilter();
fillMaterialFilter();
fillFolderFilter();
const deepParams = new URLSearchParams(location.search);
const deepProject = deepParams.get("project");
if (deepProject) {
const pf = document.getElementById("file-filter-project");
if (pf && [...pf.options].some((o) => o.value === deepProject)) pf.value = deepProject;
}
releaseRootForProject();
// Beim Laden: gemerkten Drucker nur behalten, wenn er zur Datei passt — außer Deep-Link ?host=
if (selectedFile() && !deepParams.get("host") && !(sel.tab === "send" && sel.mode === "next")) {
sel.hosts = suggestHostsForFile(selectedFile());
}
renderLibrary();
renderTabs();
refreshFlow();
@@ -1257,18 +1458,22 @@ async function watchJobs(ids) {
}
async function postSenden(hosts, whenFree) {
return api("/senden", {
method: "POST",
json: {
file_id: Number(sel.fileId),
hosts,
confirm: true,
override_machine: document.getElementById("send-override").checked,
bed_leveling: document.getElementById("send-bed-level").checked,
ace_maps: collectAceMaps(),
when_free: whenFree,
},
});
syncSendOptions();
const marlinOn = !document.getElementById("send-marlin-wrap")?.hidden;
const payload = {
file_id: Number(sel.fileId),
hosts,
confirm: true,
override_machine: document.getElementById("send-override").checked,
bed_leveling: !document.getElementById("send-bed-level-wrap")?.hidden
&& document.getElementById("send-bed-level").checked,
ace_maps: collectAceMaps(),
when_free: whenFree,
};
if (marlinOn && !whenFree) {
payload.marlin_mode = document.getElementById("send-marlin-mode").value || "stream";
}
return api("/senden", { method: "POST", json: payload });
}
async function doSend() {
@@ -1546,6 +1751,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 +1850,8 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => {
const node = document.getElementById(id);
if (!node) return;
const onChange = () => {
if (id === "file-filter-project") releaseRootForProject();
libPage = 1;
saveLibMemory();
renderLibrary();
};
@@ -1675,6 +1899,7 @@ document.querySelector(".tabs").addEventListener("click", (event) => {
});
document.getElementById("send-bed-level").addEventListener("change", updateOptionsSummary);
document.getElementById("send-marlin-mode")?.addEventListener("change", updateOptionsSummary);
document.getElementById("send-override").addEventListener("change", () => { refreshFlow(); });
document.getElementById("send-project")?.addEventListener("change", updateOptionsSummary);
@@ -1778,6 +2003,7 @@ document.getElementById("rescan").addEventListener("click", async () => {
});
window.onQueueChanged = refreshPrinters;
window.onDruckerLive = refreshPrinters;
loadSendMemory();
loadAblaufMemory();
load().catch((error) => toast(error.message || String(error), "bad"));
+1 -1
View File
@@ -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>
+243 -5
View File
@@ -14,7 +14,9 @@
<a href="#sec-auto"><svg class="ico"><use href="#i-zap"/></svg>Automatik</a>
<a href="#sec-slice" class="admin-only"><svg class="ico"><use href="#i-folder"/></svg>Slice</a>
<a href="#sec-fqdn" class="admin-only"><svg class="ico"><use href="#i-link"/></svg>Adresse</a>
<a href="#sec-etikett" class="admin-only"><svg class="ico"><use href="#i-tag"/></svg>Etiketten</a>
<a href="#sec-drucker" class="admin-only"><svg class="ico"><use href="#i-printer"/></svg>Drucker</a>
<a href="#sec-meldungen" class="admin-only"><svg class="ico"><use href="#i-alert"/></svg>Meldungen</a>
<a href="#sec-telegram" class="admin-only"><svg class="ico"><use href="#i-message"/></svg>Telegram</a>
<a href="#sec-ntfy" class="admin-only"><svg class="ico"><use href="#i-bell"/></svg>ntfy</a>
<a href="#sec-backup" class="admin-only"><svg class="ico"><use href="#i-hard-drive"/></svg>Backup</a>
@@ -100,9 +102,34 @@
<p id="fqdn-note"></p>
</section>
<section class="card admin-only" id="sec-etikett">
<h2>Etiketten</h2>
<p class="hint">Papierformat für Spulen- und Drucketiketten (Browser-Druck). Standard 70×40 mm. Der QR schrumpft auf flacheren Rollen mit.</p>
<div class="field" style="max-width:16rem">
<label for="etikett-preset">Voreinstellung</label>
<select id="etikett-preset">
<option value="">Eigene Maße</option>
</select>
</div>
<div class="grid-2" style="max-width:20rem">
<div class="field">
<label for="etikett-w">Breite (mm)</label>
<input id="etikett-w" type="number" min="20" max="200" step="0.1" value="70">
</div>
<div class="field">
<label for="etikett-h">Höhe (mm)</label>
<input id="etikett-h" type="number" min="15" max="200" step="0.1" value="40">
</div>
</div>
<div class="actions"><button type="button" id="etikett-save" hidden>Speichern</button></div>
<p id="etikett-note"></p>
</section>
<section class="card admin-only" id="sec-drucker">
<h2>Drucker</h2>
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom). Tippen öffnet den Dialog.</p>
<p class="hint">USB (Anycubic i3, Tenlog): Schnittstelle statt IP, zum Beispiel <code>COM5</code> oder <code>COM5@250000</code>. Grotti startet den USB-Dienst dazu selbst und lässt ihn bei einem Neustart weiterlaufen.</p>
<p class="hint" id="marlin-line" hidden></p>
<div id="printer-list" class="printer-edit-cards"></div>
<form id="printer-add" class="grid-2 section-gap">
<div class="field">
@@ -110,7 +137,7 @@
<input id="add-name" required placeholder="z. B. Kobra 3 Werkstatt">
</div>
<div class="field">
<label for="add-host">Host / IP</label>
<label for="add-host" id="add-host-label">Host / IP</label>
<input id="add-host" required placeholder="10.10.1.60">
</div>
<div class="field">
@@ -119,6 +146,9 @@
<option value="">automatisch</option>
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
<option value="kobra_4">Kobra 4</option>
<option value="anycubic_i3">Anycubic i3</option>
<option value="tenlog">Tenlog</option>
</select>
</div>
<div class="actions" style="align-self:end"><button type="submit">Hinzufügen</button></div>
@@ -126,6 +156,13 @@
<p id="printer-note"></p>
</section>
<section class="card admin-only" id="sec-meldungen">
<h2>Problem-Meldungen</h2>
<p class="hint">Kopien von „Problem melden“. Offene Einträge hier erledigen. Issues landen beim Entwickler über den Empfänger auf git.gundel.dev.</p>
<div id="meldung-list" class="printer-edit-cards"></div>
<p id="meldung-admin-note" class="tiny"></p>
</section>
<section class="card admin-only" id="sec-telegram">
<h2>Telegram</h2>
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
@@ -257,6 +294,10 @@
</form>
<p id="token-note"></p>
<p class="ok" id="token-once" hidden></p>
<div id="token-qr-wrap" hidden style="margin-top:0.75rem">
<p class="tiny">App-Kopplung — QR mit Server-URL und Token (nur jetzt):</p>
<canvas id="token-qr" width="180" height="180" aria-label="QR-Code"></canvas>
</div>
<p class="hint" id="legacy-token-hint" hidden>In der Config steht noch ein Shared-<code>token</code> — der wird seit 3.1 nicht mehr akzeptiert. Bitte leeren und API-Tokens (<code>grt_…</code>) nutzen.</p>
</section>
@@ -268,11 +309,14 @@
<input type="hidden" id="pe-id">
<div class="grid-2">
<div class="field"><label for="pe-name">Name</label><input id="pe-name" required></div>
<div class="field"><label for="pe-host">Host / IP</label><input id="pe-host" required></div>
<div class="field"><label for="pe-host" id="pe-host-label">Host / IP</label><input id="pe-host" required placeholder="10.10.1.60"></div>
<div class="field"><label for="pe-family">Familie</label>
<select id="pe-family">
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
<option value="kobra_4">Kobra 4</option>
<option value="anycubic_i3">Anycubic i3</option>
<option value="tenlog">Tenlog</option>
<option value="">—</option>
</select>
</div>
@@ -293,8 +337,20 @@
</dialog>
{% endblock %}
{% block script %}
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
<script>
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
const families = {
kobra_3: "Kobra 3", kobra_s1: "Kobra S1", kobra_4: "Kobra 4",
anycubic_i3: "Anycubic i3", tenlog: "Tenlog", "": "—",
};
const USB_FAMILIES = new Set(["anycubic_i3", "tenlog"]);
function syncHostField(family, labelEl, inputEl, nameHint) {
const usb = USB_FAMILIES.has(family)
|| (!family && /tenlog|i3/i.test(nameHint || ""));
if (labelEl) labelEl.textContent = usb ? "Schnittstelle" : "Host / IP";
if (inputEl) inputEl.placeholder = usb ? "COM5 oder COM5@250000" : "10.10.1.60";
}
const isAdmin = document.querySelector('meta[name="user-role"]')?.content === "admin";
document.querySelectorAll(".admin-only").forEach((el) => { el.hidden = !isAdmin; });
if (!isAdmin) {
@@ -328,6 +384,7 @@ function openPrinterDialog(row) {
document.getElementById("pe-name").value = row.name || "";
document.getElementById("pe-host").value = row.host || "";
document.getElementById("pe-family").value = row.family || "";
syncHostField(row.family || "", document.getElementById("pe-host-label"), document.getElementById("pe-host"), row.name || "");
document.getElementById("pe-locked").checked = !!row.locked;
document.getElementById("pe-win-on").checked = win.enabled;
document.getElementById("pe-win-from").value = win.from;
@@ -450,6 +507,62 @@ function renderTokens(rows) {
});
}
async function loadMeldungen() {
const root = document.getElementById("meldung-list");
const note = document.getElementById("meldung-admin-note");
if (!root || !isAdmin) return;
try {
const rows = await api("/meldungen?status=offen");
if (!rows.length) {
root.innerHTML = '<p class="tiny">Keine offenen Meldungen.</p>';
return;
}
root.innerHTML = rows.map((row) => `
<article class="printer-edit-card">
<div class="grow">
<strong>#${row.id}</strong>
<span class="tiny">${escapeHtml((row.created_at || "").replace("T", " ").slice(0, 16))} · ${escapeHtml(row.username || "—")} · ${escapeHtml(row.page || "")}</span>
<p>${escapeHtml(row.text || "")}</p>
<p class="tiny">${row.relay_ok ? "Beim Entwickler angekommen" : ("Lokal: " + escapeHtml(row.relay_message || "Empfänger fehlte"))}</p>
</div>
<button type="button" class="ghost" data-meldung-done="${row.id}">Erledigt</button>
</article>`).join("");
root.querySelectorAll("[data-meldung-done]").forEach((button) => {
button.addEventListener("click", async () => {
button.disabled = true;
try {
await api("/meldungen/" + button.dataset.meldungDone + "/erledigt", { method: "POST", json: {} });
loadMeldungen();
} catch (error) {
if (note) note.textContent = error.message || String(error);
}
});
});
} catch (error) {
if (note) note.textContent = error.message || String(error);
}
}
async function loadMarlin(printers) {
const line = document.getElementById("marlin-line");
if (!line) return;
const usb = (printers || []).some((row) => row.family === "tenlog" || row.family === "anycubic_i3");
if (!usb) {
line.hidden = true;
return;
}
try {
const status = await api("/marlin-dienst");
line.hidden = false;
line.textContent = status.running
? "USB-Dienst läuft."
: (status.message || "USB-Dienst ist aus.");
} catch (err) {
line.hidden = false;
line.textContent = "USB-Dienst: " + (err.message || "keine Antwort");
}
}
async function loadUsers() {
if (!isAdmin) return;
renderUsers(await api("/users"));
@@ -464,7 +577,11 @@ async function load() {
api("/einstellungen"),
api("/drucker?live=0"),
]);
if (isAdmin) renderPrinters(printers);
if (isAdmin) {
renderPrinters(printers);
loadMarlin(printers);
loadMeldungen();
}
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
const ar = document.getElementById("auto-retry");
if (ar) ar.checked = settings.auto_retry !== false;
@@ -475,6 +592,7 @@ async function load() {
}
const fqdn = document.getElementById("fqdn");
if (fqdn) fqdn.value = settings.fqdn || "";
fillEtikett(settings.etikett || {});
const tgEnabled = document.getElementById("tg-enabled");
if (tgEnabled) {
tgEnabled.checked = !!(settings.telegram && settings.telegram.enabled);
@@ -543,7 +661,9 @@ document.getElementById("user-add")?.addEventListener("submit", async (event) =>
document.getElementById("token-add").addEventListener("submit", async (event) => {
event.preventDefault();
const once = document.getElementById("token-once");
const qrWrap = document.getElementById("token-qr-wrap");
once.hidden = true;
if (qrWrap) qrWrap.hidden = true;
try {
const out = await api("/tokens", {
method: "POST",
@@ -555,8 +675,20 @@ document.getElementById("token-add").addEventListener("submit", async (event) =>
document.getElementById("token-add").reset();
once.hidden = false;
once.textContent = "Token (nur jetzt sichtbar): " + out.token;
showNote("token-note", null, "Token erzeugt — jetzt kopieren.");
showNote("token-note", null, "Token erzeugt — jetzt kopieren oder QR scannen.");
loadTokens();
if (out.token && window.QRCode && qrWrap) {
const canvas = document.getElementById("token-qr");
const payload = JSON.stringify({
url: location.origin,
token: out.token,
name: "Grotti",
});
try {
QRCode.toCanvas(canvas, payload, { width: 180, margin: 1, errorCorrectionLevel: "M" });
qrWrap.hidden = false;
} catch (error) { /* QR optional */ }
}
} catch (error) {
showNote("token-note", error);
}
@@ -624,6 +756,109 @@ document.getElementById("fqdn-save").addEventListener("click", async () => {
}
});
let etikettPresets = [];
function fillEtikett(etikett) {
const w = document.getElementById("etikett-w");
const h = document.getElementById("etikett-h");
const preset = document.getElementById("etikett-preset");
if (!w || !h || !preset) return;
etikettPresets = Array.isArray(etikett.presets) ? etikett.presets : etikettPresets;
const width = Number(etikett.width_mm) || 70;
const height = Number(etikett.height_mm) || 40;
w.value = String(width);
h.value = String(height);
const current = preset.value;
preset.innerHTML = '<option value="">Eigene Maße</option>';
etikettPresets.forEach((row) => {
const option = document.createElement("option");
option.value = row.id;
option.textContent = row.label;
option.dataset.w = String(row.width_mm);
option.dataset.h = String(row.height_mm);
preset.appendChild(option);
});
const hit = etikettPresets.find(
(row) => Number(row.width_mm) === width && Number(row.height_mm) === height
);
preset.value = hit ? hit.id : (current && [...preset.options].some((o) => o.value === current) ? current : "");
}
function syncEtikettPresetFromFields() {
const preset = document.getElementById("etikett-preset");
const width = Number(document.getElementById("etikett-w").value);
const height = Number(document.getElementById("etikett-h").value);
const hit = etikettPresets.find(
(row) => Number(row.width_mm) === width && Number(row.height_mm) === height
);
if (preset) preset.value = hit ? hit.id : "";
}
document.getElementById("etikett-preset")?.addEventListener("change", () => {
const preset = document.getElementById("etikett-preset");
const option = preset.selectedOptions[0];
if (!option || !option.value) return;
document.getElementById("etikett-w").value = option.dataset.w || "70";
document.getElementById("etikett-h").value = option.dataset.h || "40";
document.getElementById("etikett-save")?.click();
});
["etikett-w", "etikett-h"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", () => {
syncEtikettPresetFromFields();
});
});
document.getElementById("etikett-save").addEventListener("click", async () => {
try {
const out = await api("/einstellungen", {
method: "PATCH",
json: {
etikett: {
width_mm: Number(document.getElementById("etikett-w").value) || 70,
height_mm: Number(document.getElementById("etikett-h").value) || 40,
},
},
});
fillEtikett(out.etikett || {});
const e = out.etikett || {};
showNote(
"etikett-note",
null,
"Gespeichert — " + (e.width_mm || 70) + " × " + (e.height_mm || 40) + " mm (QR " + (e.qr_mm || 20) + " mm)."
);
} catch (error) {
showNote("etikett-note", error);
}
});
function syncAddHost() {
syncHostField(
document.getElementById("add-family").value,
document.getElementById("add-host-label"),
document.getElementById("add-host"),
document.getElementById("add-name").value,
);
}
document.getElementById("add-family")?.addEventListener("change", syncAddHost);
document.getElementById("add-name")?.addEventListener("input", syncAddHost);
document.getElementById("pe-family")?.addEventListener("change", () => {
syncHostField(
document.getElementById("pe-family").value,
document.getElementById("pe-host-label"),
document.getElementById("pe-host"),
document.getElementById("pe-name").value,
);
});
document.getElementById("pe-name")?.addEventListener("input", () => {
syncHostField(
document.getElementById("pe-family").value,
document.getElementById("pe-host-label"),
document.getElementById("pe-host"),
document.getElementById("pe-name").value,
);
});
document.getElementById("printer-add").addEventListener("submit", async (event) => {
event.preventDefault();
try {
@@ -957,6 +1192,9 @@ document.getElementById("auto-retry")?.addEventListener("change", wireAutosave("
document.getElementById(id)?.addEventListener("change", wireAutosave("watch-save", "watch-note"));
});
document.getElementById("fqdn")?.addEventListener("change", wireAutosave("fqdn-save", "fqdn-note"));
["etikett-w", "etikett-h"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("etikett-save", "etikett-note"));
});
["tg-enabled", "tg-token", "tg-chat", "al-fertig", "al-fehler", "al-offline", "al-queue", "al-ablauf", "al-start"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("tg-save", "tg-note"));
});
+4 -86
View File
@@ -4,90 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
color: #111;
background: #f3f3f3;
}
.toolbar {
display: flex;
gap: 0.6rem;
padding: 0.8rem 1rem;
background: #1a1a1a;
color: #eee;
}
.toolbar a, .toolbar button {
color: #111;
background: #f97316;
border: 0;
border-radius: 8px;
padding: 0.4rem 0.85rem;
font: inherit;
font-weight: 650;
text-decoration: none;
cursor: pointer;
}
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
.sheet {
width: 70mm;
min-height: 40mm;
margin: 1.2rem auto;
padding: 3.5mm;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
display: grid;
grid-template-columns: 1fr 20mm;
gap: 2.5mm;
align-items: center;
overflow: hidden;
}
.meta { min-width: 0; }
.meta h1 {
margin: 0 0 2mm;
font-size: 4.2mm;
letter-spacing: -0.02em;
overflow-wrap: anywhere;
}
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
.dots { display: inline-flex; vertical-align: middle; margin-right: 1.5mm; }
.dot {
width: 3.2mm;
height: 3.2mm;
border-radius: 50%;
border: 0.2mm solid rgba(0,0,0,0.25);
margin-left: -1mm;
}
.dot:first-child { margin-left: 0; }
.qr-wrap {
width: 20mm;
height: 20mm;
overflow: hidden;
flex-shrink: 0;
}
.qr-wrap canvas {
display: block;
width: 20mm !important;
height: 20mm !important;
max-width: 100%;
max-height: 100%;
}
@media print {
body { background: #fff; }
.toolbar { display: none; }
.sheet {
margin: 0;
border: 0;
border-radius: 0;
page-break-inside: avoid;
}
}
</style>
{% include "_etikett_style.html" %}
</head>
<body>
<div class="toolbar">
@@ -120,14 +37,15 @@
</div>
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
<script>
const qrMm = {{ (etikett.qr_mm if etikett is defined else 20)|tojson }};
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
width: 80,
margin: 1,
color: { dark: "#111111", light: "#ffffff" }
}, () => {
const node = document.getElementById("qr");
node.style.width = "20mm";
node.style.height = "20mm";
node.style.width = qrMm + "mm";
node.style.height = qrMm + "mm";
});
</script>
</body>
+5 -77
View File
@@ -4,83 +4,10 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
{% include "_etikett_style.html" %}
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
color: #111;
background: #f3f3f3;
}
.toolbar {
display: flex;
gap: 0.6rem;
padding: 0.8rem 1rem;
background: #1a1a1a;
color: #eee;
}
.toolbar a, .toolbar button {
color: #111;
background: #f97316;
border: 0;
border-radius: 8px;
padding: 0.4rem 0.85rem;
font: inherit;
font-weight: 650;
text-decoration: none;
cursor: pointer;
}
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
.sheet {
width: 70mm;
min-height: 40mm;
margin: 1.2rem auto;
padding: 3.5mm;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
display: grid;
grid-template-columns: 1fr 20mm;
gap: 2.5mm;
align-items: center;
overflow: hidden;
page-break-after: always;
}
.sheet { page-break-after: always; }
.sheet:last-child { page-break-after: auto; }
.meta { min-width: 0; }
.meta h1 {
margin: 0 0 2mm;
font-size: 4.2mm;
letter-spacing: -0.02em;
overflow-wrap: anywhere;
}
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
.qr-wrap {
width: 20mm;
height: 20mm;
overflow: hidden;
flex-shrink: 0;
}
.qr-wrap canvas,
.qr-wrap img {
display: block;
width: 20mm !important;
height: 20mm !important;
max-width: 100%;
max-height: 100%;
}
@media print {
body { background: #fff; }
.toolbar { display: none; }
.sheet {
margin: 0;
border: 0;
border-radius: 0;
page-break-inside: avoid;
}
}
</style>
</head>
<body>
@@ -111,14 +38,15 @@
{% endfor %}
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
<script>
const qrMm = {{ (etikett.qr_mm if etikett is defined else 20)|tojson }};
document.querySelectorAll("canvas.qr").forEach((node) => {
QRCode.toCanvas(node, node.dataset.url, {
width: 80,
margin: 1,
color: { dark: "#111111", light: "#ffffff" }
}, () => {
node.style.width = "20mm";
node.style.height = "20mm";
node.style.width = qrMm + "mm";
node.style.height = qrMm + "mm";
});
});
</script>
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more