23 Commits
Author SHA1 Message Date
Dominic Gundel db6a85a703 Grotti 3.7.0: Treiber für Kobra 4 und Marlin, smartere Dateien-Auswahl und einstellbare Etiketten.
Senden und Abbruch laufen über den Treiber, Orca-Doppelzeilen und tote Ideen-API fallen weg, Drucker folgen der Datei, Etikettenformat liegt in den Einstellungen.
2026-11-02 14:03:57 +01:00
Dominic Gundel 5081212212 Grotti 3.6.1: Abgebrochene Läufe bleiben im Protokoll, Spulenwechsel bucht den laufenden Druck nicht vorzeitig ab, und der Pi öffnet große Dateien nicht mehr parallel. 2026-11-01 18:56:14 +01:00
Dominic Gundel 2b1e36634a Grotti 3.6.0: Lauf-Identität nach Filament-Pause, robustere Queue, Projekte/Aufträge list-first und API-Polish. 2026-10-25 08:24:24 +01:00
Dominic Gundel d1eb87ba25 Grotti 3.5.2: Zu wenig Rest blockiert den Start nicht mehr, und ein anlaufender Druck wird nicht als Neustart neu eingereiht. 2026-10-21 21:04:28 +02:00
Dominic Gundel 5166ed6530 Grotti 3.5.1: MCP-Anweisung für Clients, Queue-Strip nur bei Ablauf, Start wartet auf printing. 2026-10-20 21:39:22 +02:00
Dominic Gundel f034c5e626 Wand: Queue-Strip nur bei Ablauf; Start wartet auf printing statt Falsch-Fertig. 2026-10-19 11:35:59 +02:00
Dominic Gundel f1f79c2ce2 Grotti 3.5.0: Pagination, Logging, Deb-Paket, Makro-Doku, PLA-Match-Fix. 2026-10-19 11:14:24 +02:00
Dominic Gundel 4ffe1dcfc6 README: Banner, Badges und Community-Screenshots oben. 2026-10-18 22:06:56 +02:00
Dominic Gundel dd0e7ebc6b Grotti 3.4.0: Dateien umbenennen, Bedarf/PLA-Match, Pause- und gklib-Fixes. 2026-10-18 21:36:12 +02:00
Dominic Gundel 67a89d1794 Grotti 3.3.0: Projekte mit Ordner, Datei-Löschen/Verschieben, Material-Archiv, UI-Polish. 2026-10-18 20:10:32 +02:00
Dominic Gundel 4ddffbbeb1 Grotti 3.2.0: Jetzt-Liste, Filament-Pause, Strom-Torte, UI-Polish.
Jetzt/Topbar und Material-Routing, Pause bei Filament-Ende, Druckbericht mit Stromanteilen, Design- und Kamera-Stream-Polish; Bedarf-Alert-Spam und Platte-belegt-Bugs behoben.
2026-10-18 14:58:32 +02:00
Dominic Gundel 6e59df22e5 Grotti 3.1.0: Bedarf-UI, Zeitfenster, HT/NT, Theme, Chrome-Fixes.
Bedarf in Sidebar/Material, Drucker-Zeitfenster und Tarif in Einstellungen, helles Theme; Legacy-Token und /api/drucker entfernt; SSE/SW-Fixes gegen hängende Navigation in Chrome.
2026-10-18 02:20:12 +02:00
Dominic Gundel 8e88efc094 Grotti 3.0.0: Aufträge, Wartung, SSE, Ausschuss-Dialog, UI-Umbau.
Migrationen 003–009, Kundaufträge mit Lieferschein, Abräumen mit Urteil,
ntfy/Backup/Zeitfenster, Live-Stream und Mobile-Mehr-Menü.
2026-10-18 00:54:51 +02:00
Dominic Gundel 0236278929 Grotti 3.0 Phase 1: Migrationen, Pool, Queue- und Abzug-Fixes.
Nummerierte SQL-Migrationen, psycopg-Pool, waitress, hängende Queue-Jobs,
sichere History-Abzüge, Chargen-Locks, Upload-/Host-Validierung, ProxyFix.
2026-10-18 00:47:23 +02:00
Dominic Gundel 9e22cebb81 Grotti 2.10.0: Orca-Senden, Slice-Ordner, ED-Strom, Hygiene.
Slice-Watch mit Ordnern/Filtern, Orca-Nachbearbeitung, Cockpit-Verlauf
mit ED, Zeitzone im Browser. Tasten-Grenze und README-API angeglichen,
toter Code entfernt, Requirements gepinnt.
2026-10-18 00:41:15 +02:00
Dominic Gundel efd0801cd1 Grotti 2.5.1: Druckbericht, Statistik, Spulenakte und Protokoll-Fixes. 2026-10-17 15:32:51 +02:00
Dominic Gundel b277ca48aa Grotti 2.2.6: Wand-Vorschau, dichterer 3D-Pfad, ASCII-Dateinamen.
Kacheln zeigen Moonraker-Thumbnails; Toolpath-Thinning behält ganze Schichten; Nicht-ASCII-Namen vermeiden 10115 beim Senden.
2026-10-17 11:22:43 +02:00
Dominic Gundel 7da895bbcd Grotti 2.2.1: Anleitungen mit relativen Pfaden für Git-Vorschau. 2026-10-15 10:54:45 +02:00
Dominic Gundel 5a3716b26a Grotti 2.2.0: Auth, API-Tokens, MCP und Doku-Trennung. 2026-10-15 10:53:12 +02:00
Dominic Gundel b20f8a7779 Update CHANGELOG and documentation for Task Hygiene improvements; enhance error handling in anycubic_lan.py; add Anycubic task display in wand.html. 2026-10-15 09:25:13 +02:00
Dominic Gundel ead7e5718e Doku Glossar: Moonraker, Mainsail und der Stack. 2026-10-15 09:07:04 +02:00
Dominic Gundel 28a1469f61 Doku Anycubic-Stack: MQTT, ACE und Restart aus den Quellen. 2026-10-15 08:58:45 +02:00
Dominic Gundel 99ede12f01 Cursor-Regel für Grotti-Konventionen ergänzt. 2026-10-15 08:50:35 +02:00
214 changed files with 28876 additions and 2191 deletions

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
---
description: Grotti-Konventionen für Agenten (Changelog, Doku, Version, Sicherheit)
alwaysApply: true
---
# Grotti
Grotti ist ein eigenständiges Repo. Alles, was das Tool betrifft, bleibt hier.
- Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits.
- Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
- **Version/`version.py` nur erhöhen, wenn der Nutzer explizit Release sagt.** Keine Zwischen-Patches von allein. Beim Release: SemVer (Patch/Minor/Major), `[Unreleased]` → `[x.y.z] – Datum`, Git-Tag `vX.Y.Z`.
- Nutzer-Anleitung: `docs/Anleitung-*.md` unter `/doku`. Technik/Debug: `docs/internal/` — nicht in der App.
- CSS-Version in `web/templates/layout.html` (`grotti.css?v=`) erhöhen, wenn sich `grotti.css` ändert.
- Keine freie G-code-Konsole, kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Druckstarts brauchen `confirm: true`.
- Auth: Session oder API-Token (`grt_…`); Rollen admin/operator.
Druckbeobachtungen und Slicer-Profile gehören nicht hierher, sondern in den FDM-Wissen-Tresor (`D:\Projekte\3DP\FDM\Wissen\`).
+9
View File
@@ -2,7 +2,16 @@
config.json
data/
# Slice-/Watch-Ordner: nur der Platzhalter im Git, Inhalt lokal
slice/*
!slice/.gitkeep
.venv/
__pycache__/
*.pyc
_tmp_*
%s
# gebaute .deb (Skript legt sie hier ab)
packaging/dist/*
!packaging/dist/.gitkeep
+2 -2
View File
@@ -4,7 +4,7 @@ Grotti ist ein eigenständiges Repo. Alles, was das Tool betrifft, bleibt hier.
- Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits.
- Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
- Version in `grotti/version.py` nach SemVer: Patch für Fehlerbehebung, Minor für Funktion, Major für Bruch an API oder Datenbankschema. Beim Release `[Unreleased]` in `[x.y.z] – Datum` umbenennen. Git-Tag `vX.Y.Z`.
- Technische Doku liegt in `docs/` und wird unter `/doku/<name>` gerendert. Neue Erkenntnisse zum Tool dort ergänzen.
- **Version/`version.py` nur erhöhen, wenn der Nutzer explizit Release sagt.** Keine Zwischen-Patches von allein. Beim Release: SemVer (Patch/Minor/Major), `[Unreleased]` → `[x.y.z] – Datum`, Git-Tag `vX.Y.Z`.
- Nutzer-Anleitung: `docs/Anleitung-*.md`, gerendert unter `/doku`. Technik und Debug: `docs/internal/` — erscheint **nicht** in der App (inkl. MCP.md, Anycubic-Stack, …).
- CSS-Version in `web/templates/layout.html` (`grotti.css?v=`) erhöhen, wenn sich `grotti.css` ändert.
- Keine freie G-code-Konsole, kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Druckstarts brauchen `confirm: true`.
+361
View File
@@ -2,6 +2,367 @@
Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionen nach [SemVer](https://semver.org/lang/de/).
## [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
- **Dateien:** Umbenennen in der Bibliothek (Stift-Icon; Endung bleibt erhalten).
### 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).
- **Protokoll „druckt“ nach Offline:** Live-Läufe werden bei anhaltendem Offline als `interrupted` geschlossen (z. B. gklib-Shutdown).
- **Sorte „Sunlu PLA“ vs. Eryone PLA:** Basis-Profil ohne Meta/Matte/Silk matcht nach Material-Typ (Marke egal); Varianten bleiben streng.
## [3.3.0] – 2026-10-18
### Neu
- **Projekte** `/projekte`: Arbeitsmappe mit Ordner unter dem Slice-Pfad (`projects.folder`); G-code-Zuordnung per Sync; Upload weiterer Dateien (STEP/STL/PDF/…); Nav unter Verwalten.
- **Dateien:** Uploads in Slice-Ordner verschieben (werden Watch); Löschen für Upload und Watch (nur unter Slice/`eingang`); kompaktere Bibliothek-Filter.
- **Material:** Filter Sorte/Farbe; leere/archivierte Spulen ausgeblendet (Filter **Archiv**); Buttons **Leer**, **Archivieren**, **Wiederherstellen** (`archived_at`).
- **Wand:** kleines Kamera-Snapshot-Thumb auf jeder Karte (nie Stream); Menüpunkt Kameras entfernt, `/kameras` → Wand.
### Geändert
- **Technik-Cockpit:** kompakte Fact-Chips; Steuerung in Gruppen Heizung / Druck / ACE-Plätze.
- **Protokoll:** kurze Dateinamen, festere Spaltenbreiten.
- **Statistik:** kompakte Filter inkl. Farbe, Auto-Laden, KPI Primär/Sekundär.
- **Orca-Login:** Session 30 Tage (überlebt App-Fenster schließen); kein zweites Chrome mit demselben Profil (sonst erneutes Anmelden).
- **Druckbericht:** Kosten-Torte und Tabelle inkl. Filament (nicht nur Strom); Summe = Material + Energie.
- **Dateien:** Starten / Einreihen / Ablauf mit einem Klick (kein Zwei-Klick mehr). API weiter mit `confirm: true`.
- **Senden:** Zwei Start-Modi statt drei — **Dieser Drucker** (frei → sofort, sonst Warteschlange) und **Nächster passender** (Grotti wählt nach Sorte).
- **Material-Routing:** Match auf Katalog-Sorte aus Orca-`filament_settings_id` (PLA Meta ≠ Matte ≠ Silk); falsche Variante wartet mit Grund. Farbe bleibt soft. Bei Auto-Wahl baut Grotti die ACE-Map aus den Spulen am Claim-Drucker. Bestehende Dateien: **Dateien neu lesen**.
- **Wand:** Bei Filament-Pause Hinweis, die neue Rolle unter Material dem Drucker zuzuweisen — sonst zählt der Verbrauch weiter zur alten Spule.
- Anleitung Material/ACE: Rollenwechsel während Pause klargestellt.
### Behoben
- **Orca/Anycubic öffnet zwei Browserfenster:** Nachbearbeitung läuft oft 2× — Debounce (~25 s) öffnet nur noch eines. Deep-Link lädt fehlende `paint_index` nach (ACE zeigt sonst fälschlich T0/T1).
- **Dateien-Tab hängt nach Orca:** 3D-Pfad erst nach ACE/Senden (Idle); Debounce ignoriert Anycubic-Zeitstempel im Dateinamen; erneuter Export aktualisiert den offenen Tab statt ein zweites Fenster.
- **Orca/Anycubic → Desktop-Fenster:** `orca_senden` öffnet Grotti als Chrome/Edge-App (`browser: app`). Anycubic-Dialog nur vermeiden durch **G-code exportieren**, nicht Remote Print / Aufgabe starten.
- Anleitung Orca: Grotti unter **Gerät** per Device-UI-URL (`http://127.0.0.1:8770/`); Upload-zum-Host weiter meiden.
- **ACE-Map mit Lücken-T (Bambu/Anycubic):** UI behält Slicer-`Tn` (z. B. T1+T10). Beim Senden verdichtet Grotti auf T0…n und schreibt passende `.acm` — sonst gklib **10011700 invalid filament id** bzw. Hang bei Map-Mismatch. Senden prüft Map gegen T-Befehle. Bestehende Dateien: **Dateien neu lesen**.
- **10011700 trotz dichter UI-Map:** Verdichtung hängt am G-code (`T1`/`T10`), nicht daran, ob die Map schon 0/1 zeigt — sonst blieb T10 auf dem Drucker. Grotti neu starten, Clip erneut senden.
- **Abräumen ließ Lauf offen:** Urteil und Platte waren quittiert, der Live-Lauf blieb `in_progress` (Moonraker-History oft ohne Endzeit). Abräumen und Watcher (`printing`→`complete`) schließen den Lauf jetzt als `completed` inkl. Materialabzug.
- **Rollenwechsel während Pause:** Einlegen hängt Abschnitte/Ereignisse auch an offene History-Jobs (nicht nur `live:`). Send-Spule und Lauf-Spule werden mitgezogen; Abzug bevorzugt die Rolle am Drucker statt eines veralteten Send-Hints.
## [3.2.0] – 2026-10-18
### Neu
- **Jetzt-Liste** / Topbar-Chips / Mobile-Tab `/jetzt`: Platte räumen, Queue-Fehler, Bedarf, Wartung, Aufträge, Material-Wartegründe; Farm-ETA; Filament-Pause → „Material nachlegen“.
- **`GET /api/v1/jetzt`**, **`GET /api/v1/prognose`**, **`GET /api/v1/suche`** (Ctrl+K).
- **Filament-Pause:** Pause + ACE/`mmu`/Runout-Hinweis → Event `spule`, Jetzt-Eintrag, Alert.
- **Druckbericht:** Strom-Aufschlüsselung (Grundlast / Düse / Bett / Trockner) mit Tortendiagramm.
- **Material-Routing:** „Nächster freier“ bevorzugt passende Rolle/Farbe/Rest; sonst Wartegrund in der Queue.
- **Termine** (`due_at`) sortieren die Warteschlange vor der Priorität.
- **Auto-Retry** (einmal, Einstellung) bei Queue-/Ablauf-Fehler.
- **Gleich nochmal drucken** im Abräumen-Dialog.
- **Tagesbericht** 7 Uhr (Telegram/ntfy) und Push bei Wartung fällig, Bedarf, Auftrag überfällig.
- **Kosten pro Teil** und Stück-Yield in Bericht, Auftrag und Statistik.
- **Undo-Toast** beim Entfernen aus der Warteschlange; **Ctrl+K**-Suche.
- **Design-Polish:** Lucide-Icons (`icons.svg`), Schrift Inter, Tokens/Komponenten; Topbar und Sidebar-Gruppen.
- **Kamera-Stream:** Snapshot oder MJPEG in Einstellungen und auf der Kameras-Seite.
- **Community-Screenshots** unter `docs/community/`.
- Einstellungen: Abschnitts-Navigation, Autosave, Drucker als Karten mit Dialog.
- Cockpit: Alltag oben, **Technik**-Fold (Steuerung, Verlauf, 3D, Bettnetz) standardmäßig zu.
### Geändert
- **Live-Status** in der Topbar (Chips + Popover); Mobile-Tab **Jetzt**.
- **PA12-CF** in Grotti als PA12-CF; am ACE als PA6-CF (ACE kennt den Typ nicht).
- Falsche Sorte warnt in Dateien, hält die Queue nicht an; zu wenig Rest blockiert weiter.
- Alert-Hygiene: `spule`/`start`/`auth` quiet; Sidebar-Label „Meldungen“.
- Status als Chip; Einstellungen-Nav mit Icons; Autosave; Light-Theme; `tabular-nums`.
- Begriffe: Queue-Eintrag = Druck; Empty-States mit Handlung; Primär-/Danger-Buttons.
- Cool-Park blockiert den Queue-Tick nicht mehr (Worker).
- Kunden und Positionen bearbeiten/löschen unter Aufträge.
- `projekte.html` / `ideen.html` entfernt (Redirect bleibt).
### Behoben
- **Bedarf/Wartung/Auftrag-Spam:** Ops-Alerts Dedup-Art und Event-`kind` stimmten nicht überein (jede Minute derselbe Hinweis).
- **Platte belegt nach Neustart:** Watcher setzte bereits Quittiertes wieder auf `needs_clear`.
- **„Pausiert“ nach Abbruch/Offline:** Live-Lauf blieb offen bei Offline→standby.
- **Regal-Modus:** Sidebar-Menü unter Desktop-Breite ausgeblendet.
## [3.1.0] – 2026-10-18
### Neu
- **Bedarf in der UI:** Sidebar-Warnung, Karte unter Material, Chip in der Warteschlange.
- **Start-Zeitfenster** je Drucker in den Einstellungen (Queue nur im Fenster).
- **HT/NT-Tarif** in den Energie-Einstellungen.
- **ntfy-Test** wie bei Telegram.
- **Helles Theme** (Dunkel / Hell / System) pro Browser.
- Statistik: Balkendiagramme Druckzeit/Verbrauch, Empty-State ohne Daten.
- Wartung: KPIs, Empty-State, Reset mit Bestätigung (`armButton`).
### Geändert
- Cockpit und Warteschlange nutzen SSE/`createPoller` statt hartem 5‑s-Intervall.
- Shared-`config.token` wird nicht mehr als Auth akzeptiert — nur noch Session und `grt_…`-Tokens.
- Legacy-Alias `GET /api/drucker` entfernt (nur `/api/v1/drucker`).
### Behoben
- SSE unter waitress: Hop-by-Hop-Header `Connection` entfernt (sonst Fehler auf `/api/v1/stream`).
- Bedarf: Material/Farbe aus `files.filaments` statt nicht existierender Spalten.
- Chrome: Service-Worker lieferte bei Fehlern die Startseite statt der angeklickten Route; „Mehr“-Dialog blockierte die Navigation; SSE und Poller liefen parallel und stauten Verbindungen.
## [3.0.0] – 2026-10-18
Major: neue Tabellen (Aufträge, Wartung), Projekte/Ideen → Aufträge, Navigation umgebaut, Dev-Server → waitress, Schema über Migrationen.
**Vor dem Update:** `pg_dump` der Datenbank. Danach Grotti starten (Migrationen 003–009 laufen automatisch).
### Neu
- **Aufträge:** Kunden, Positionen (Datei × Menge), Fortschritt, Einreihen, Lieferschein; Migration aus Projekten/Ideen; API `/api/v1/kunden`, `/api/v1/auftraege`; MCP-Lesetools.
- **Ausschuss-Pflicht:** Dialog beim „Platte geräumt“ (Urteil, Grund, Stückzahlen).
- **Wartung:** Laufzähler und Verschleißteile, Seite `/wartung`.
- **Bedarf:** Queue vs. Spulenrest (`GET /api/v1/bedarf`).
- **ntfy** neben Telegram; **Backup**-ZIP (pg_dump + data/); **Zeitfenster** je Drucker; optional HT/NT-Tarif.
- **SSE** Live-Stream `/api/v1/stream`, Verbindungsbanner, PWA-Manifest/Service-Worker.
- Migrationen unter `grotti/migrations/`, Connection-Pool, Indizes, Sample-Retention.
- Mobile-Nav mit „Mehr“-Sheet; Anleitungen Aufträge und Wartung.
### Geändert
- Server: waitress; Threads starten in `server.main()`.
- Navigation: Aufträge statt Projekte/Ideen; Wartung; Dateien-/Statistik-Icons.
- Kontrast `--faint`, `prefers-reduced-motion`, Touch-Ziele, Print-CSS.
- Upload-Whitelist, Host-Validierung, ProxyFix, Settings-Lock, `?token=` nur Bilder.
- Spulenabzug History ohne blinde Rate; Chargen-Locks; MCP `temp`/`percent`.
### Behoben
- Hängende Queue-Starts nach Neustart; Worker-Rollback; `snapshot()`-Exceptions.
- Toast-Dedupe; ACE-Map bei Send-Vorbereitung; Spulenwarnung mit ACE.
## [2.10.0] – 2026-10-18
### Neu
- Einstellungen: Slice-Ordner (`watch_dir`) setzen; Repo-Ordner `slice/` als Standard (Inhalt gitignored).
- Orca-Nachbearbeitung: `scripts/orca_senden.py` lädt den Export nach Grotti und öffnet `/dateien?file=…` (Drucker, ACE, Einreihen in der UI); Anleitung unter Doku.
- Bibliothek: Ordner unter dem Slice-Pfad anlegen und Watch-Dateien verschieben (ID/Projekt bleiben); API `…/dateien/ordner`, `…/verschieben`.
- Bibliothek: Filter Material und Ordner, Sortierung (Neueste / Name / Material / Ordner / Druckzeit / Gewicht), Suche auch in Material und Projekt; Filter bleiben im Browser; Gruppenköpfe bei Sort Material/Ordner.
- Cockpit: Verlauf (Temp/ED) des aktuellen oder letzten Drucks, Link zum Bericht; gemeinsame Chart-Hilfe in `api.js`.
- Druckbericht-Verlauf: Zeitachse (links °C, rechts ED %), gestrichelte Soll-Linien, Einschaltdauer Düse/Bett; Aufheizen wird dichter erfasst.
- Stromschätzung aus ED: Einstellungen → Düse-/Bett-Watt; Bericht zeigt kWh (ED oder pauschal).
- Einstellungen → Dieser Browser: Zeitzone für Anzeigezeiten (Browser automatisch oder z. B. Europe/Berlin).
### Geändert
- Energie-Defaults: Grundlast 40 W (Elektronik), Düse 60 W, Bett 220 W; Hinweis in den Einstellungen, wenn Düse/Bett 0 (nur pauschal).
- Druckbericht: Start/Ende als lesbare Ortszeit (`17.10.2026, 15:09 – 15:46`) statt ISO.
- Sidebar **Läuft jetzt**: eine Zeile pro Drucker (Name · % · Fertig-Uhr), Fortschritt in der Zeile; Klick öffnet die Warteschlange, × bricht ab.
- Spulenakte: KPI-Kacheln statt Fließtext; Druckzeilen mit Datum, Charge `D-…`, Kurzname, Gramm und Status.
- Material-Spulenformular: Marke, Chargen und Technik unter „Weitere Angaben“ eingeklappt.
- Druck-Etikett: „Soll … g“ statt „Ziel“ (Slicer-Filamentmenge, wie im Bericht).
- Tasten-Grenze im Ablauf: dokumentierter Default 3 mm (kalt an der Düse sonst Fehlalarm); Anleitung und Technik-Notiz angepasst.
- `requirements.txt` mit festen Versionen; `requirements-dev.txt` mit pytest.
- Unbenutzte Helfer entfernt (`printers.load_printers`, Mesh-Vergleich 3×3, `fetch_bed_mesh`, `last_sample_at`, …).
### Behoben
- Druckbericht: Objekt-Etiketten kollidieren nicht mehr, wenn dieselbe Chargennummer schon an einem anderen Lauf hängt (Seite `/protokoll/<id>` lieferte 500).
- Orca-Nachbearbeitung: Export unter dem Slice-Ordner (inkl. Unterordner) wird als Watch-Datei übernommen statt flachem Upload; Temp-Dateien `.gcode.pp` erkannt.
- Material: Rest-Gramm im Spulenformular akzeptiert Dezimalwerte (Abzug lieferte z. B. 501,34 g — Browser blockierte bei `step=1`).
## [2.5.1] – 2026-10-17
### Neu
- Cockpit-Steuerung: ACE-Trockner starten/stoppen (Temp, Dauer), nur wenn ein ACE hängt.
- Cockpit-Steuerung: bestückte ACE-Plätze mit Sorte und Farbe aus Grotti an den Drucker schreiben.
- Wand-Kacheln zeigen das Druck-Vorschaubild (Moonraker-Thumbnail) in der Job-Zeile.
- **Druckbericht** `/protokoll/<id>`: Soll/Ist-Gramm, Spulenanteile, Temperaturverlauf, Ereignisse, Charge.
- **Statistik** `/statistik`: Auslastung, Erfolg, Verbrauch, Ausschuss, Schätztreue; CSV-Export.
- Spulenakte unter Material: verdruckte Gramm, Ausschuss, Leer-in aus dem Wochenverbrauch.
- Etiketten pro Druck oder Objekt (`D-YYYYMMDD-nnnn`), QR auf den Bericht; Scan von `D-…` öffnet den Bericht.
- Spulenabzug nach Ist (`filament_used`); Abbruch nur anteilig; Mehrfarb-Anteile als Schätzwert; Rest darf ins Minus.
- Rollenwechsel während Druck/Pause (Einlegen) teilt den Verbrauch in Abschnitte.
### Geändert
- Statistik: Zeitraum per Knopf (Tag, Woche, Monat, Gestern, Letzte Woche, Letzter Monat, Alles); Bis-Datum zählt inklusiv; CSV übernimmt denselben Filter.
- Druck-Charge `D-…` wieder während des Laufs (Protokoll, Wand-Etikett, Bericht); Adoption übernimmt die Nummer auf den History-Job. Ausschuss: gedruckte Bögen verwerfen, Charge bleibt.
- Dateien → ACE-Zuordnung: Platz-Dropdown zeigt bei bekannter Spule Material, Farbe, Name und Restgramm.
### Behoben
- Spulenakte blieb leer: Abzug schrieb oft kein `run_spools` (hängende History-Läufe, Abzug vor Akte-Tabelle); Nachzug ohne erneuten Rest-Abzug, Abzug schreibt `run_spools` vor `deducted`.
- Protokoll: keine Doppel-„druckt“-Zeilen mehr (Live + History); Watcher hängt am History-Job, sobald Moonraker ihn kennt; Liste blendet überzählige Live-Zeilen aus.
- Platte geräumt: `needs_clear` nach Watcher-Ende und History-Sync (nur wenn kein Live-Druck); neuer Druckstart bricht alte Live-Läufe ab.
- Druck-Etikett: Material schon während des Laufs (Send/ACE/Spule); QR bleibt im 70‑mm-Bogen.
- Druckstart: Dateien mit altem `stored_path` (z. B. nach Umzug von `3DP/…/Uebersicht` nach Grotti) werden unter `data/eingang/<name>` wiedergefunden statt „liegt nicht mehr auf der Platte“.
- Wand/Sidebar: Dateiname springt nicht mehr alle paar Sekunden, wenn Moonraker kurz keinen Namen liefert; 3D-Pfad-Caption flackert nicht mehr mit „wird gelesen …“.
- Dateien: Hinweis zu fehlendem ACE-Trailer nur noch bei Mehrfarb (nicht als Warnung bei Monoteilen).
- 3D-Pfad wirkte gesprenkelt: Ausdünnen behält ganze Schichten dicht (statt Stichproben entlang der Bahn); keine Parallel-Offsets.
- Toolpath-API mit `Cache-Control: no-store`, damit der Browser keinen alten Pfad behält.
- Senden: Dateinamen mit Nicht-ASCII (z. B. CJK) werden für Moonraker/Rinkhals auf ASCII normalisiert — sonst Fehler 10115 „gcode … not found“.
## [2.2.2] – 2026-10-15
### Behoben
- 3D-Pfad: bei großen G-codes zerbrach das Ausdünnen die Segment-Paare — Viewer zeigte nur Objekt-Umrisse, keinen Druckfortschritt (grün/grau).
## [2.2.1] – 2026-10-15
### Geändert
- Anleitungen mit relativen Bild- und Kapitelpfaden (`../web/static/doku/…`, `Anleitung-*.md`), damit Screenshots und Querverweise auch auf Git lesbar sind; die App schreibt sie beim Rendern auf `/static/…` bzw. `/doku/…` um.
## [2.2.0] – 2026-10-15
### Neu
- **Benutzerverwaltung:** lokaler Login (Admin/Operator), Bootstrap „Ersten Admin anlegen“, Session-Cookies, CSRF, Login-Rate-Limit.
- **API-Tokens** (`grt_…`) unter Einstellungen für App, Home-Automation und MCP; Shared-`config.token` bleibt als Migrationsbrücke (Admin).
- **MCP-Server** (`python -m grotti.mcp_server`): Lesen + bestätigte Steuer-Tools; `GROTTI_MCP_READ_ONLY`; Doku `docs/internal/MCP.md`.
- OIDC-Platzhalter in Config (`oauth`) für späteren Anschluss.
- Schema: `users`, `api_tokens`, `oauth_identities`.
- **Logo / Favicon:** Markenzeichen in der Sidebar und als Favicon (`web/static/brand/`).
- Login: statt Nutzhinweis wechselnde Ingenieurszitate (ein Zitat pro Tag).
- Umfangreiche Bedienungsanleitung unter `/doku` (`docs/Anleitung-*.md`) mit UI-Screenshots.
- Task-Hygiene wie Rinkhals (Kobra 3 und S1): `ensure_task_free` vor Start; bei **10101** cancel + Retry.
- Live-Snapshot `anycubic_task` auf der Wand.
- Cursor-Regel `.cursor/rules/grotti.mdc`.
### Geändert
- `/doku` zeigt nur noch `Anleitung-*.md`; Technik/Debug unter `docs/internal/` (nicht in der App). Changelog nicht mehr in der UI.
- Anleitungen ohne interne Stack-/Debug-Details.
- HTML und API hinter Auth (außer Setup/Login); kein Token mehr im Meta-Tag.
- Kamera im Cockpit: Layout-Klassen bleiben erhalten; ohne erreichbare Webcam kein Platzreservierung; `has_camera` im Drucker-Payload.
- `POST /api/v1/druck-queue` braucht `confirm: true` (wie Senden).
### Behoben
- Cockpit: periodisches Layout-Ruckeln durch `className`-Reset der Kamera-Folds.
- Kameras-Seite und leere Flotte: echte Empty-States statt Raw-API-URL / leerem Raster.
- Spulen-Etikett: QR-Code wieder sichtbar — `qrcode.min.js` lokal.
## [2.0.0] – 2026-10-15
### Neu
+121 -67
View File
@@ -1,102 +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, der beobachtet wird. 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.
```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. Handbuch in der Oberfläche unter **Doku**.
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/` | Technische Notizen, gerendert unter `/doku/<name>` |
| `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. Die wichtigsten Endpunkte:
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 |
| --- | --- |
| `GET /version` | Version |
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag |
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter |
| `GET /dateien`, `POST /dateien` | Bibliothek, Upload |
| `POST /senden` | Datei an Drucker. `confirm: true` ist Pflicht. `when_free: true` reiht ein. |
| `GET /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange, Abbrechen (`status: cancelled`), Ablauf fortsetzen (`resume`) |
| `POST /spulen/<id>/einlegen` | Spule an Drucker/Platz; alte Rolle dort ins Regal |
| `POST /ablauf` | Ablauf für Kobra 3 einreihen |
| `GET /ereignisse?host=&kind=&since=&limit=` | Aktivität |
| `POST /ereignisse/gelesen` | Alerts als gelesen |
| `GET /spulen`, `GET /laeufe`, `GET /projekte` | Material, Protokoll, Projekte |
| `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, `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`/`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` / `GROTTI_TOKEN` → `docs/internal/MCP.md`.
## Sicherheit
- Druckstarts brauchen `confirm: true`. Die Oberfläche fragt per Zwei-Klick.
- Keine freie G-code-Konsole.
- Kein `FIRMWARE_RESTART`, GoKlipper hängt sich dabei auf. Fehler werden über „Fehler löschen“ geräumt.
- 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>
+33 -3
View File
@@ -2,29 +2,59 @@
"host": "127.0.0.1",
"port": 8770,
"data_dir": "data",
"watch_dir": "",
"watch_dir": "slice",
"database_url": "postgresql://[email protected]:5432/grotti",
"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": {
"enabled": false,
"issuer": "",
"client_id": "",
"client_secret": "",
"redirect_uri": ""
},
"energy": {
"watts": 120,
"watts": 40,
"nozzle_watts": 60,
"bed_watts": 220,
"euro_per_kwh": 0.35,
"dryer_watts": 0,
"dry_hours": 0,
"waste_percent": 0,
"utilization": 50
"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
}
+54
View File
@@ -0,0 +1,54 @@
# ACE und Spulen
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)
## Spule einlegen
1. Unter **Material → Spulen** die Rolle wählen oder anlegen.
2. Ort = Drucker und **ACE-Platz** (1–8) oder externe Spule.
3. Materialtyp und Farbe sollten zur Slice-Datei passen.
Es gibt **kein freies Spulen-Dropdown** beim Start: Grotti nimmt die Rolle(n), die am Ziel-Drucker liegen. Material-Mismatch oder zu wenig Rest → Warnung vor dem Start.
### Rollenwechsel während Pause
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). 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).
## ACE-Map beim Senden
Unter **Dateien → Optionen → ACE-Zuordnung**:
- Jede Farbe aus der Datei (`Tn` / Slicer-`paint_index`) einem **ACE-Platz** zuordnen
- Bei Bambu-/Anycubic-Dateien sind die Nummern oft **nicht 0–3** (z. B. T1 und T10 bei 16 Slots) — Grotti zeigt `T…`, verdichtet beim Senden aber auf T0…n (sonst Fehler **invalid filament id**)
- Liegt unter Material eine Spule auf dem Platz, zeigt die Liste Name, Farbe und Restgramm mit — Vorauswahl bevorzugt die **Katalog-Sorte** aus dem Orca-Profil (Meta/Matte …)
- 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 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.
Nach einem Grotti-Update mit ACE-Farben: unter Dateien **neu lesen**, damit alte Einträge die echten `Tn` bekommen.
## Typische Fehler
| Symptom | Check |
| --- | --- |
| „cannot parse the file“ | ACE-Zuordnung vollständig? Farben richtig? |
| „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 | 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. |
## Weiter
- [Dateien senden](Anleitung-Dateien.md)
- [Material](Anleitung-Material.md)
+20
View File
@@ -0,0 +1,20 @@
# Aufträge
Stand **18.10.2026**. Kundenaufträge mit Positionen (Datei × Menge), Fortschritt und Lieferschein.
## Seite Aufträge
Unter **Aufträge** legst du Kunden und Aufträge an. Status: Idee → Angebot → Bestätigt → Druckt → Fertig → Geliefert → Bezahlt.
Pro Position: Datei, Stückzahl, optional Preis und Material. **Einreihen** schickt die Position in die Warteschlange (`confirm`). Fertige gute Stücke aus dem Abräumen-Dialog zählen auf den Fortschritt „n von m“.
## Lieferschein
Im Auftrag **Lieferschein** öffnen: Positionen, Chargen `D-…`, druckbar. Danach Status auf **Geliefert** setzen.
## Weiter
- [Projekte](Anleitung-Projekte.md) (Arbeitsmappe / Konstruktion — nicht dasselbe wie Auftrag)
- [Warteschlange](Anleitung-Warteschlange.md)
- [Material](Anleitung-Material.md)
- [Protokoll / Bericht](Anleitung-Oberflaeche.md)
+62
View File
@@ -0,0 +1,62 @@
# Dateien senden
Stand **18.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
![Dateien: Bibliothek und Senden](../web/static/doku/dateien-flow.png)
## Bibliothek
- Kompakte Filterleiste: Suche, Maschine, Projekt, Material, Ordner, Sortierung
- Filter: Maschine, Projekt, Material, Ordner (Unterordner unter dem Slice-Ordner; Uploads als „Upload“)
- Sortierung: Neueste, Name, Material, Ordner, Druckzeit, Gewicht — bei Material/Ordner mit Gruppenköpfen
- Auswahl der Filter bleibt im Browser merken
- Vorschaubild, Material, Zeit, Gewicht, Schichthöhe
- Info-Popup mit Slice-Metadaten (Kopf und Footer)
- **Hochladen** per Knopf oder Drag & Drop
- Mit gesetztem Slice-Ordner ([Einstellungen](Anleitung-Einstellungen.md)): **Ordner anlegen**; Watch- **und** Upload-Dateien per Auswahl **verschieben** (Upload wird zur Watch-Datei im Slice-Ordner)
- **Umbenennen** (Stift-Icon) — Datei auf der Platte und in der Bibliothek; Endung `.gcode`/`.3mf` bleibt, wenn du sie weglässt
- **Löschen** (Papierkorb, Zwei-Klick) für jede Bibliotheksdatei — entfernt Eintrag und Datei auf der Platte (nur unter Slice-Ordner oder `eingang`)
- **Ordner neu lesen**, wenn unter Einstellungen ein Slice-Ordner gesetzt ist
Ohne Slice-Ordner geht nur manueller Upload; Verschieben in Ordner braucht den Slice-Pfad.
Projekte mit eigenem Ordner: [Projekte](Anleitung-Projekte.md).
## Senden: drei Schritte
1. **Datei** wählen (Klick in der Liste).
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 passendem Typ (PLA, PETG, …). Die genannte Sorte (Meta, Matte) wird bevorzugt, wenn sie liegt; sonst reicht derselbe Typ.
Auswahl merkt sich der Browser (`localStorage`).
### Optionen
Unter Optionen u. a.:
- **Bettnetz / Auto-Level** beim MQTT-Start (Anycubic)
- Maschinen-Override, Projekt zuweisen
- **ACE-Zuordnung** — siehe [ACE und Spulen](Anleitung-ACE.md)
## Warum es dauert
Große Dateien brauchen oft **eine Minute**. In der Sidebar unter **Senden** siehst du den MB-Fortschritt. Danach startet der Druck.
## Nach dem Start
- Fortschritt auf der [Wand](Anleitung-Wand.md) und in der Sidebar **Läuft jetzt**
- Bei Warteschlange / Nächster passender: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md)
- Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map)
## Aus Orca
Nach dem G-code-Export kann Orca die Datei direkt nach Grotti laden und diese Seite mit der Datei öffnen — siehe [Orca an Grotti](Anleitung-Orca.md).
## Weiter
- [Projekte](Anleitung-Projekte.md)
- [Orca an Grotti](Anleitung-Orca.md)
- [ACE und Spulen](Anleitung-ACE.md)
- [Warteschlange](Anleitung-Warteschlange.md)
+68
View File
@@ -0,0 +1,68 @@
# Einrichtung
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
- Python 3.11
- PostgreSQL (leere Datenbank `grotti` reicht; Schema legt Grotti an)
- Drucker im selben Netz, Moonraker auf Port 80
- Dieses Repo und eine `config.json`
## Einmalig einrichten
Im Projektordner:
```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. Die wichtigsten Schlüssel:
| 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 (meist unter **Einstellungen** setzen). Leer = nur manueller Upload. Standard-Vorschlag: Ordner `slice` im Repo. |
| `token` | Optionaler Shared-API-Token (Übergang). Mit Benutzerverwaltung nutzt du API-Tokens unter Einstellungen. |
| `fqdn` | Öffentliche Basis-URL für QR auf Spulen-Etiketten. Leer = Request-Host. |
| `auto_queue` | `false` stoppt neue „wenn frei“-Starts. |
| `energy`, `telegram`, `alerts` | Kosten im Protokoll und Telegram — später auch unter Einstellungen. |
| `secret_key` | Für Login-Sessions. Beim ersten Start wird ein Wert erzeugt, wenn leer. |
Drucker beim ersten Start über `drucker.json` (Seed). Danach pflegt man sie unter [Einstellungen](Anleitung-Einstellungen.md).
```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. Beim ersten Start legst du den Admin-Benutzer an. Nach Code-Änderungen an Python oft neu starten.
![Druckerwand nach dem Start](../web/static/doku/wand-uebersicht.png)
## Sicherheit
- 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. 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
- [Oberfläche](Anleitung-Oberflaeche.md) — Navigation und Sidebar
- [Dateien senden](Anleitung-Dateien.md) — erster Druck
- Ausführlich im `README.md` im Projektordner
+55
View File
@@ -0,0 +1,55 @@
# Einstellungen
Stand **18.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram/ntfy, Energie.
![Einstellungen: Drucker](../web/static/doku/einstellungen-drucker.png)
## Dieser Browser
- **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche.md).
- **Darstellung** — Dunkel, Hell oder System (folgt dem Gerät).
- **Zeitzone** — Browser automatisch oder fest (z. B. Europe/Berlin) für Protokoll, Bericht und Aktivität.
## Automatik
Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen Queue-Starts. Laufende Abläufe und manuelle Sofort-Starts bleiben möglich.
## Slice-Ordner
Pfad zum Ordner, den die Bibliothek beobachtet (Export aus Orca/Anycubic, **Ordner neu lesen**, Unterordner). **Standardordner** setzt `slice` im Grotti-Repo. Leer = nur Hochladen. Nur Admin; der Ordner wird beim Speichern angelegt, falls er fehlt.
## Drucker
Pro Drucker: Name, Host, Familie, **gesperrt**, optional **Start-Zeitfenster**.
- Seed beim allerersten Start aus `drucker.json`, danach nur noch hier (Datenbank).
- Familie aus dem Namen (Kobra 3 / S1) oder gesetzt.
- **Gesperrt:** keine neuen Queue-Starts auf diesem Host.
- **Zeitfenster:** Queue startet nur zwischen Von/Bis (z. B. 22:00–06:00). Optional Max.-Stunden für Nachtjobs.
## FQDN
Ö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_…`), 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`.
## Energie
**Grundlast W** (Elektronik/Motion, nicht die Heizer) und Strompreis für die Kosten im **Protokoll**. **Düse W** / **Bett W** zusammen mit ED-Proben im Verlauf → Schätzung „aus ED“; stehen sie auf 0, bleibt es bei Dauer × Grundlast („pauschal“). Typisch Kobra: Grundlast ~40 W, Düse ~60 W, Bett ~200–300 W. Optional **HT/NT-Tarif** mit Zeiten und Preisen — Grotti gewichtet dann die Stromkosten nach Uhrzeit. Beim Speichern warnt die Seite, wenn Düse oder Bett noch 0 sind.
## Weiter
- [Einrichtung](Anleitung-Einrichtung.md) (Config-Datei)
- [Störungen](Anleitung-Stoerungen.md)
+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.
+50
View File
@@ -0,0 +1,50 @@
# Material und mehr
Stand **18.10.2026**. Spulen, Etiketten, Aufträge, Protokoll, Statistik, Aktivität.
![Material: Spulen](../web/static/doku/material-spule.png)
## Material
Zwei Reiter: **Spulen** und **Sorten**.
Oben die Karte **Bedarf aus Warteschlange**, wenn wartende Jobs Filament brauchen: Queue-Gewicht vs. Spulenrest je Sorte/Farbe. Fehlmengen erscheinen auch in der Sidebar und als Chip in der Warteschlange.
**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest / Archiv; zusätzlich Sorte und Farbe. Leere und archivierte Spulen sind standardmäßig ausgeblendet — **Archiv** zeigt sie. Formular bei Auswahl oder „Neue Spule“. **Leer** setzt Rest auf 0, legt die Rolle ins Regal und archiviert; **Archivieren** / **Wiederherstellen** ohne Restzwang. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in.
**Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
### Etikett und QR
Pro Spule ein Etikett mit Chargennummer `S-…` und QR. Der Code öffnet Material mit der Spule vorausgewählt. Dafür in den Einstellungen ggf. **FQDN** setzen (öffentliche URL), sonst gilt der Host aus dem Request.
Druck-Etiketten (`D-…`) kommen vom Druckbericht: ein Etikett für den ganzen Lauf oder je Objekt. Scan von `D-…` öffnet den Bericht.
## Aufträge
Unter **Aufträge** Kunden, Positionen und Fortschritt. Dateien lassen sich einem Auftrag zuweisen; siehe [Aufträge](Anleitung-Auftraege.md).
## 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 (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 — 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.
Druck-Etiketten (`D-…`) schon während des Drucks: an der Wand **Etikett**, im Druckbericht **Etikett Druck** / **Etiketten Objekte**. Auf dem Bogen steht das Sollgewicht aus dem Slicer (geplante Filamentmenge). Bei Ausschuss die gedruckten Bögen wegwerfen; die Charge bleibt im Protokoll.
Abzug nach dem Druck nimmt das Ist aus `filament_used` (Abbruch nur anteilig). Mehrfarb-Anteile sind als Schätzwert gekennzeichnet. Restgramm darf unter 0 gehen.
## Kameras
Auf der **Druckerwand** erscheint je Drucker ein kleines Snapshot-Bild (ca. alle 3 s), wenn eine Webcam erreichbar ist. Klick öffnet das Cockpit — dort gilt weiter die Einstellung Snapshot/Stream unter Einstellungen → Dieser Browser. Die frühere Seite `/kameras` leitet auf die Wand um.
## Aktivität
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.
## Weiter
- [ACE und Spulen](Anleitung-ACE.md)
- [Einstellungen](Anleitung-Einstellungen.md)
+49
View File
@@ -0,0 +1,49 @@
# Oberfläche
Stand **18.10.2026**. Wie die App aufgebaut ist — Menü, Jetzt-Liste, Desktop und Regal.
## Navigation
Links (Desktop), gruppiert: **Live** (Drucker, Dateien, Warteschlange, Aufträge), **Rückschau** (Protokoll, Statistik, Aktivität), **Verwalten** (Projekte, Material, Wartung). Darüber im Inhaltsbereich die **Topbar**: Suche, Live-Chips, Einstellungen, Doku und Benutzer-Menü (Abmelden, Version).
Unter ca. 760 px: untere Leiste (Drucker, Dateien, Warteschlange, **Jetzt**, Mehr) plus **Mehr**-Sheet (inkl. Aktivität und Abmelden). Das Seitenmenü lässt sich zur Icon-Rail einklappen.
Shortcut **Ctrl+K** (Mac: Cmd+K) oder der Suche-Knopf in der Topbar: Suche über Drucker, Dateien, Spulen, Chargen und Aufträge.
## Jetzt
Desktop: Chips in der Topbar (**Jetzt**, laufende Drucker, Senden, Meldungen). Klick öffnet ein Popover mit Liste und Aktionen. Bei mehreren Läufen zusätzlich **Alles fertig um …**.
Mobile / Touch: Tab **Jetzt** in der unteren Leiste (mit Badge) öffnet `/jetzt` — dieselbe Liste als volle Seite. Aktivität liegt unter **Mehr**.
| Priorität | Inhalt |
| --- | --- |
| Platte räumen | Quittieren mit Urteil (optional „gleich nochmal“) |
| Material nachlegen | Druck pausiert, Filament vermutlich leer (ACE/Runout) — Spule zuweisen, dann an der Wand **Weiter** |
| Druck fehlgeschlagen | Fortsetzen / Ausblenden |
| Spule fehlt | Link zu Material / Bedarf |
| Wartung fällig | Link zu Wartung |
| Auftrag überfällig | Link zu Aufträgen |
| Wartet auf Material | Wartegrund aus dem Routing |
Aktualisierung über Live-Stream (SSE), sonst Polling.
## Desktop und Regal
Unter **Einstellungen → Dieser Browser**:
- **Desktop** — normale Dichte
- **Regal (Touch)** — größere Bedienelemente, Sidebar oft eingeklappt; auf schmalem Viewport die untere Leiste. Alle Menüpunkte bleiben in der Sidebar sichtbar.
- **Darstellung** — Dunkel, Hell oder System
- **Zeitzone** — Browser oder fest (z. B. Europe/Berlin) für Uhrzeiten in Protokoll und Bericht
- **Kamera** — Snapshot (schonend, alle paar Sekunden) oder Stream (flüssig, hält eine Verbindung). Gilt für das Cockpit; Wand-Thumbs sind immer Snapshot.
Shortcut in der URL: `?shelf=1` bzw. `?shelf=0`. Nur dieser Browser (`localStorage`).
Drucker-Cockpit: Alltag (Job, ETA, Rolle, Steuerung der Aktionen) oben; **Technik** (Steuerung, Verlauf, 3D-Pfad, Bettnetz) zugeklappt.
## Weiter
- [Druckerwand](Anleitung-Wand.md)
- [Dateien senden](Anleitung-Dateien.md)
- [Projekte](Anleitung-Projekte.md)
+86
View File
@@ -0,0 +1,86 @@
# Orca an Grotti
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 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 (im Grotti-Repo unter Windows, gleicher Ordner wie lokal `config.json`):
```json
{
"url": "http://10.10.1.41:8770",
"token": "grt_…",
"browser": "app",
"watch_dir_local": "D:/Slice"
}
```
`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:
```text
D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_senden.py
```
Gilt für **OrcaSlicer** und **AnycubicSlicerNext**. Pfade anpassen. Der Slicer hängt den Export-Pfad selbst an.
### Gerät-Reiter = Grotti (nur OrcaSlicer)
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://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.
In **AnycubicSlicerNext** steckt unter Gerät/Arbeitsfläche die Anycubic-Cloud — dort lässt sich Grotti so nicht einbinden.
## Ablauf (wichtig bei Anycubic)
**Nicht** „Remote Print“ / „Aufgabe starten“ / Cloud-Dialog — der Anycubic-Dialog lässt sich von Grotti nicht abschalten.
Stattdessen:
1. Slicen, dann **G-code exportieren** / **Platte schreiben** (Ziel: Slice-Ordner; Unterordner ok).
2. Das Nachbearbeitungsskript lädt die Datei nach Grotti und öffnet das Grotti-Fenster.
3. Drucker wählen, unter Optionen Farbe → ACE-Platz, **Dieser Drucker** oder **Nächster passender**, dann **Starten**.
Erstes App-Fenster: einmal bei Grotti anmelden (eigenes Profil unter `data/grotti-app-profile`). Die Session hält **30 Tage** und überlebt das Schließen des Fensters. Läuft das Fenster noch, startet Orca kein zweites Chrome (sonst leeres Profil → erneutes Login) — die neue Datei wird im offenen Fenster ausgewählt.
Test ohne Orca:
```powershell
D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_senden.py C:\pfad\datei.gcode
```
## Störungen
| Symptom | Check |
| --- | --- |
| Anycubic-Dialog trotz Grotti | Du hast Remote Print / Aufgabe starten genutzt → nur **G-code exportieren** |
| Meldungsfenster „Config fehlt“ | `data/orca-senden.json` anlegen |
| „Grotti nicht erreichbar“ | Server läuft? `url` stimmt? |
| „Token fehlt oder stimmt nicht“ | Token neu anlegen, in der JSON eintragen |
| App-Fenster will Login | Einmal anmelden (Session 30 Tage). Profil: `data/grotti-app-profile`. Zweites Chrome-Fenster schließen, nur eines offen lassen. Grotti neu starten nach Update. |
| Browser-Tab statt Fenster | In `orca-senden.json` `"browser": "app"` setzen (oder Chrome/Edge fehlt) |
| Zwei Fenster / Tab hängt | Debounce (~25 s); erneuter Export aktualisiert den offenen Tab. 3D-Pfad lädt verzögert |
| ACE zeigt T0/T1 statt T1/T10 | Seite neu laden oder **Ordner neu lesen**; Deep-Link zieht `paint_index` nach |
| Skript läuft nicht | Export wirklich speichern; Zeile im **Prozessprofil**; Python-Pfad prüfen |
## Was Grotti nicht kann
- Den **Anycubic-Cloud-/Aufgabe-Dialog** unterdrücken (liegt im Slicer, nicht im Skript).
- Sich in Anycubics Reiter **Arbeitsfläche** einbetten. In **Orca** dagegen: Device-UI-URL → Grotti unter **Gerät** (siehe oben).
## Weiter
- [Dateien senden](Anleitung-Dateien.md)
- [ACE und Spulen](Anleitung-ACE.md)
+41
View File
@@ -0,0 +1,41 @@
# Projekte
Stand **24.10.2026**. Arbeitsmappen unter `/projekte` — getrennt von Kunden-[Aufträgen](Anleitung-Auftraege.md).
## Wozu
Ein **Projekt** bündelt Konstruktion und Druckdateien in einem Ordner unter dem [Slice-Pfad](Anleitung-Einstellungen.md), z. B. `Projekte/2609_002`.
| | Projekt | Auftrag |
| --- | --- | --- |
| Zweck | Konstruktion + Slice-Dateien | Kunde, Menge, Lieferschein |
| Ort | Ordner auf der Platte | nur in der Datenbank |
| Seite | `/projekte` | `/auftraege` |
## Anlegen
**Neues Projekt** — Name, Status, optional Ordner (Standard: `Projekte/<Kurzname>`). Mit gesetztem Slice-Ordner legt Grotti den Ordner an.
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.
## Detail
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
Projekt löschen (Zwei-Klick) entfernt den Datensatz und den Ordner **inkl. Inhalt** (`force`). Druckdateien in der Bibliothek verlieren nur die Projekt-Zuordnung, wenn sie woanders liegen; Dateien im Projektordner sind mit dem Ordner weg.
## Weiter
- [Dateien](Anleitung-Dateien.md)
- [Aufträge](Anleitung-Auftraege.md)
- [Einstellungen](Anleitung-Einstellungen.md)
+64
View File
@@ -0,0 +1,64 @@
# Störungen
Stand **15.10.2026**. Was tun, wenn etwas hängt. Kurzfassung auch auf der [Doku-Startseite](/doku#haengt).
## Auftrag startet nicht
| Anzeige | Maßnahme |
| --- | --- |
| **Platte belegt** | Teil runter, dann **Platte geräumt** (Karte oder Sidebar → Aufgaben). |
| Drucker **gesperrt** | Unter Einstellungen entsperren. |
| Automatik aus | Einstellungen → Automatik-Warteschlange an. |
| Falsche Maschine | Andere Datei oder anderen Drucker wählen. |
## Fehlerzustand auf dem Drucker
Auf der Karte **Fehler löschen** (Zwei-Klick). Grotti räumt den hängenden Fehler und schaltet die Heizung aus. Ein Neustart der Firmware über Grotti ist absichtlich nicht möglich — bei Bedarf Display-Neustart oder Strom aus/an.
Hängende Fehler vor einem Start räumt Grotti oft selbst, wenn kein Druck aktiv ist.
## Ablauf gestoppt
1. Grund in Sidebar / Karte / Aktivität lesen (Schieben, Tasten, Timeout, …).
2. Mechanisch klären (Kiste, Teil, Platte).
3. **Weiter** ab dem fehlgeschlagenen Schritt — oder Verwerfen und neu einreihen.
## Start scheitert nach Abbruch
Manchmal bleibt nach einem Abbruch ein alter Auftrag auf dem Drucker hängen. Der nächste Start scheitert dann oft mit einer Fehlermeldung (z. B. Code **10101**).
Was tun: Abbruch über Grotti (nicht nur am Display), auf der Wand den Task-Hinweis prüfen, erneut starten. Bleibt es hängen: Strom aus und wieder an.
## ACE / „cannot parse“
ACE-Zuordnung unter Dateien → Optionen prüfen. Spulen am richtigen Platz. Siehe [ACE und Spulen](Anleitung-ACE.md).
## Offline
Strom, WLAN und Erreichbarkeit des Druckers prüfen. Grotti meldet „wieder erreichbar“ unter Aktivität und ggf. Telegram.
## E-Stop
Im Cockpit (nur bei aktivem Druck/Heizen). Danach oft **Strom aus und wieder an**. Firmware-Neustart über Grotti ist absichtlich gesperrt.
## Upload hängt bei großen Dateien
Oft noch Verarbeitung auf dem Drucker. In der Sidebar unter **Senden** den Fortschritt beobachten. Nach einem Grotti-Update den Dienst einmal neu starten.
## „gcode … not found“ (10115)
Oft Dateinamen mit Nicht-ASCII-Zeichen (z. B. Chinesisch). Grotti normalisiert den Namen beim Senden auf ASCII. Nochmal senden; in der Bibliothek darf der Originalname bleiben.
## Drucker „heruntergefahren“ / 10111 / 506 nach Upload
Oft **Einfarbig**-G-code mit `; filament_colour_type` (Orca) — gklib stürzt ab. Grotti entfernt die Zeile beim Senden nur bei Einfarbig. Multicolor bleibt unverändert. Betroffene Drucker: Display-Reboot oder Strom aus/an, dann in Grotti ggf. **Fehler löschen**.
## Wo nachschauen
- Sidebar: Alerts, Läuft, Wartet, Senden
- **Aktivität** — Zeitstrahl mit Filtern
## Weiter
- [Druckerwand](Anleitung-Wand.md)
- [Warteschlange](Anleitung-Warteschlange.md)
+57
View File
@@ -0,0 +1,57 @@
# Druckerwand
Stand **24.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
![Druckerwand](../web/static/doku/wand-uebersicht.png)
## Karte
Pro Drucker siehst du:
- Zustand (Chip), Fertig-Uhrzeit, Fortschrittsbalken
- Kleines Kamerabild (Snapshot ca. alle 3 s), wenn eine Webcam erreichbar ist — Klick öffnet das Cockpit
- Restzeit, Schicht, Laufzeit
- Temperaturen Düse/Bett (mit Ziel)
- Dateiname, bei ACE oft Farb-Swatches
- Bei Ablauf: Schrittleiste und Abbrechen
Aktionen auf der Karte brauchen oft **zwei Klicks** (erster Klick „scharf“, zweiter bestätigt):
| Aktion | Wann |
| --- | --- |
| **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 |
| **Weiter** (Ablauf) | Gestoppter Ablauf — ab dem fehlgeschlagenen Schritt, wenn die Kiste steht |
Während eines Abräum-Ablaufs blendet Grotti „Platte geräumt“ aus — der Ablauf räumt selbst.
## Cockpit
URL mit Hash: `/#<host>` (z. B. nach Klick auf die Karte).
![Cockpit eines Druckers](../web/static/doku/cockpit.png)
Inhalt:
- **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg. Modus Snapshot/Stream unter Einstellungen → Dieser Browser (gilt nur im Cockpit; die Wand-Thumbs sind immer Snapshot)
- 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; 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
## Tipps
- Gesperrte Drucker (Einstellungen) starten keine neuen „wenn frei“-Jobs.
- Live-Daten kommen mit wenigen Sekunden Verzögerung.
- Ein Task-Hinweis auf der Karte zeigt, wenn auf dem Drucker noch ein alter Auftrag hängt — siehe [Störungen](Anleitung-Stoerungen.md).
## Weiter
- [Dateien senden](Anleitung-Dateien.md)
- [Warteschlange und Ablauf](Anleitung-Warteschlange.md)
- [Störungen](Anleitung-Stoerungen.md)
+54
View File
@@ -0,0 +1,54 @@
# Warteschlange und Ablauf
Stand **18.10.2026**. Aufträge in der Warteschlange (Dieser Drucker / Nächster passender), Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
![Warteschlange](../web/static/doku/warteschlange.png)
## Seite Warteschlange
Filter: **Läuft** / **Wartet** / **Fehler** / **Historie** (und „Alle aktiv“).
Pro Auftrag:
- Priorität hoch/runter
- **Abbrechen**
- Bei gestopptem Ablauf: **Fortsetzen** (ab dem fehlgeschlagenen Schritt)
Unter **Läuft** siehst du auch Direktstarts ohne Queue-Zeile (Link zur Wand).
Dieselbe Information steht kompakt in der Sidebar (**Läuft jetzt** / **Wartet**). Unter **Läuft jetzt** eine Zeile pro Drucker; Klick öffnet diese Seite, × bricht ab.
Reicht der Spulenrest nicht für wartende Jobs, erscheint oben ein **Fehlmenge**-Hinweis (Details unter Material → Bedarf) und in der Sidebar unter **Bedarf**.
## Wann startet ein wartender Auftrag?
Sobald der Drucker bereit ist (`standby` / fertig / abgebrochen), die **Platte frei** ist und der Drucker nicht gesperrt. Automatik aus (`auto_queue`) oder gesperrt → keine neuen Starts; laufende Abläufe laufen weiter. Mit **Start-Zeitfenster** in den Einstellungen nur innerhalb von Von/Bis.
## Ablauf (nur Kobra 3)
Unter **Dateien → Ablauf** eine Kette bauen und einreihen:
```text
Drucken → Abkühlen → Schieben → Tasten → (Wiederholung)
```
| Schritt | Bedeutung |
| --- | --- |
| **Abkühlen** | Wartet, bis Bett und Düse unter der Grenze sind. PLA löst sich oft von selbst. |
| **Schieben** | Extruder schiebt das Teil von hinten nach vorn in die Kiste. |
| **Tasten** | LeviQ-Netz tippt; Maß aus `Result is z=`. Über 3 mm stoppt den Ablauf (Default; enger geht unter Einstellungen/Ablauf). |
Aktueller Schritt: auf der Druckerkarte und in der Sidebar. Abbrechen an beiden Stellen. Jeder Schritt landet unter **Aktivität**.
**Schieben testen** und „Bett ist leer“: im Ablauf-Reiter, bevor du unbeaufsichtigt läuft.
## Gestoppter Ablauf
1. Ursache beheben (Kiste voll, Teil hängt, Tasten zu hoch, …).
2. Auf der Karte oder in der Sidebar **Weiter** — nicht nur neu einreihen.
3. Oder verwerfen / abbrechen und später neu starten.
## Weiter
- [Druckerwand](Anleitung-Wand.md)
- [Störungen](Anleitung-Stoerungen.md)
+12
View File
@@ -0,0 +1,12 @@
# Wartung
Stand **18.10.2026**. Druckstunden, Filament-Meter und Verschleißteile je Drucker.
## Seite Wartung
Unter **Wartung** siehst du je Drucker die Summe aus dem Protokoll (Stunden, Meter, Starts). Teile mit Soll-Lebensdauer (Stunden oder Meter) und Einbaudatum; bei Reset wird der Zählerstand protokolliert.
## Weiter
- [Einstellungen](Anleitung-Einstellungen.md)
- [Statistik](Anleitung-Oberflaeche.md)
-25
View File
@@ -1,25 +0,0 @@
# Chargen, Etiketten und 3D-Viewer
Stand 14.10.2026.
## Spulen-Chargen
Jede Spule in Grotti bekommt automatisch eine `chargen_nr` der Form `S-YYYYMMDD-0001`. Derselbe Wert steht in `code` und auf dem Etikett. Optional: `hersteller_charge` vom Rollenetikett.
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 qrcode.js (CDN). 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`.
## 3D-Pfad
`GET /api/v1/dateien/<id>/pfad` liefert einen gedünnten Toolpath (bis ca. 120 000 Punkte, plus `layer_breaks`, Cache-`version` 5) aus dem lokalen G-code. Die Dateien-Seite und das Cockpit zeichnen ihn mit Three.js.
Darstellung: nur Extrusionswege. Preview = orange. Live: gedruckt leicht verdickt grün, offen grau; Schnitt nach `progress`. Parser akzeptiert `E.39`/`Z.6`. Bett: Gitter 10 mm, mm-Sprites, Legende.
**Exclude-Object** (nur Klipper/Moonraker): Status aus `exclude_object`. Polygone im Viewer; Liste unter dem Pfad; Zwei-Klick → `POST .../steuern` Action `exclude_object` mit `confirm`. Fehlt Modul/DEFINEs → Panel aus. BambuLab später eigener Adapter.
Cockpit-Live: Kamera-Frames werden unsichtbar vorgeladen (letztes Bild bleibt), `no-cam`-Layout nur ohne Kamera-URL. 3D-Orbit bleibt beim 5‑s-Poll erhalten (`setPath` nur beim ersten Laden / Dateiwechsel).
Auf der Wand: Caption `Dateiname · % · Schicht a/b`, sonst „Datei nicht in Grotti“.
Bedienung: Orbit / Zoom / Schwenken / Doppelklick-Reset; Tippen auf Polygon wählt das Objekt.
-55
View File
@@ -1,55 +0,0 @@
# Grotti: Alltag und Oberfläche
Stand **14.10.2026**, v1.4.0.
## Design
- Dunkles UI mit Orange-Akzent, Lucide-ähnliche Stroke-Icons (SVG-Sprite in `layout.html`).
- Kurze Seitenuntertitel; Technik-/Config-Hinweise liegen in der Doku, nicht als Fließtext in den Formularen.
- Einheitliche Seitenköpfe, Toolbar-Filter, kompakte Tabellen, Formularfelder mit kleinen Labels, Empty-States mit Icon.
- **Anzeige pro Browser** (Einstellungen → Dieser Browser): `Desktop` oder `Regal (Touch)` in `localStorage grotti-display`. Shortcut `?shelf=1` / `?shelf=0`. Regal: größere Controls, kompakte Nav (Drucker/Kameras/Warteschlange), Sidebar default eingeklappt.
- Seitenmenü einklappbar (Icon-Rail, `grotti-nav-collapsed`). Unter 760 px weiter Bottom-Nav.
## Seitenleiste
- Hauptmenü: Drucker, Dateien, Kameras, Aktivität, Warteschlange, Protokoll, Material, Projekte. Darunter Einstellungen, Ideen, Doku (mit Icons); Fuß **© by dominic@gundel.dev** und Version (Changelog).
- Live-Leiste, eigene Scrollfläche, Boxen auf- und zuklappbar (Zustand in `localStorage` `grotti-live-closed`):
- **Läuft jetzt**: laufende Aufträge und Abläufe mit Schrittleiste, Durchlauf `n/m`, aktueller Phase und ×-Abbrechen; auch Direktstarts ohne Queue.
- **Wartet**: wartende Aufträge mit Ziel, Priorität hoch/runter, Abbrechen; die letzten Fehler mit Fortsetzen/ausblenden.
- **Aufgaben**: Platte belegt ohne Ablauf — „geräumt“ per Zwei-Klick.
- **Senden**: laufende Uploads mit Fortschritt.
- **Alerts**: ungelesene wichtige Ereignisse, „gelesen“, Link auf Aktivität.
- Aktualisierung: Senden 2 s, Queue 4 s, Aufgaben/Alerts 8 s.
## Wand
- Karte je Drucker: Zustandschip, Fertig-Uhrzeit groß, Fortschrittsbalken, drei Kacheln (Rest, Schicht, Läuft), Temperaturbalken Düse/Bett mit Ziel, Datei. Klick auf die Karte öffnet das Cockpit. Pause, Abbrechen, Fehler löschen und Platte geräumt bleiben auf der Karte (Zwei-Klick).
- Laufender Ablauf als Streifen mit Schrittleiste und Abbrechen. Wartende Aufträge für diesen Drucker als Hinweis.
- Streifen „Fehler“ mit **Fehler löschen** und „Platte belegt“ mit **Platte geräumt** (während eines Ablaufs ausgeblendet, der räumt selbst).
- **Cockpit** (Hash `/#<host>`): Kamera (Frame-Vorladen; zugeklappt oder ohne Kamera → eine Spalte, kein leeres 16:10), Status, Temps inkl. Bauraum wenn vorhanden, Lüfter-Icons, Steuerung, 3D-Pfad (Exclude-Object), Bettnetz. E-Stop nur bei printing/paused/heating, Zwei-Klick. Live vom Watcher.
## Dateien und Senden
- Links Bibliothek: Suche, Filter Maschine/Projekt, Vorschaubild; Zeile zeigt Material, Zeit, Gewicht, Schichthöhe, Maschine, Projekt; Details per Info-Popup. Metadaten aus G-code-Kopf **und** Footer. Hochladen / Ordner neu lesen.
- Rechts der Flow: Datei → Drucker (Live-Zustand, eingelegte Rolle) → Wann (Jetzt / Wenn frei / Nächster freier) oder Reiter Ablauf.
- Kein freies Spulen-Dropdown: Abzug nimmt die Rolle(n) am Ziel-Drucker zur Startzeit. ACE: Zuordnung Datei-Farbe → Platz. Warnung bei Material-Mismatch oder zu wenig Rest.
- Startknopf mit Zwei-Klick bei „Jetzt“. Auswahl in `localStorage` `grotti-send-v1`.
## Material
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf.
- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal.
- Sorten: Profile (Dichte, mm³/s, Preis, Maschinen). Anlegen/ändern/löschen; „Spule damit anlegen“ wechselt zum Spulen-Reiter.
## Warteschlange
- Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen.
- Unter **Läuft** / **Alle aktiv** auch Direktstarts aus `/laeuft` (ohne Queue-Zeile), mit Fortschritt und Link zur Wand.
## Aktivität
- Zeitstrahl nach Tagen. Filter in der URL. Ungelesene Alerts markiert.
## Kameras und Handy
- Kamerakacheln mit Live-Zustand (`live=1`). Snapshot-Cache 2 s. Unter 760 px: Schnellmenü Drucker / Dateien / Aktivität.
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

+47
View File
@@ -0,0 +1,47 @@
# Grotti — Community-Vorstellung
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).
---
## Kurztext (zum Posten)
**Grotti** ist ein Farm-Manager für Moonraker/Klipper-Drucker — gebaut für den Alltag mit mehreren Maschinen, nicht für den Einzelplatz.
Statt zehn Tabs und Excel: eine Wand, eine Warteschlange, Material und Abräumen an einem Ort. Orca/AnycubicSlicerNext schicken den G-code per Nachbearbeitungsskript rein; Grotti wählt den nächsten freien Drucker (inkl. ACE-Farbe/Rest), startet im Zeitfenster und meldet, wenn die Platte geräumt werden muss oder Spule fehlt.
**Was schon geht**
- **Druckerwand + Cockpit** — Live-Status, Temps, Pause/Cancel/E-Stop, Farm-ETA („Alles fertig um …“)
- **Jetzt-Liste** — Platte räumen, Materialbedarf, Wartung, überfällige Aufträge als To-do oben
- **Warteschlange** — Priorität, Termine, Auto-Retry, Routing „nächster freier“ mit Material-Match
- **Kobra-3-Ablauf** — Druck → Abkühlen → Schieben → Tasten (unbeaufsichtigte Serie)
- **Material** — Spulen am Drucker/Regal, Chargen, Restgewicht, Fehlmenge aus der Queue
- **Aufträge** — Kunden, Positionen, Einreihen, Lieferschein, Stück-Fortschritt aus dem Abräumen
- **Kosten & Statistik** — Material + Strom (auch HT/NT), €/Teil, Yield, Ausschussgründe
- **Wartung** — Laufstunden/Filamentmeter, Verschleißteile mit Intervallen
- **Betrieb** — Auth (Session/API-Token), Telegram/ntfy, PWA/Regal-Touch, Ctrl+K-Suche
Stack: Python, PostgreSQL, Moonraker. UI auf Deutsch. Self-hosted.
Fragen und Feedback willkommen.
---
## Screenshots
| Datei | Inhalt |
| --- | --- |
| [01-druckerwand.png](01-druckerwand.png) | Farm-Übersicht, gruppiert nach Modell |
| [06-jetzt.png](06-jetzt.png) | Jetzt-Popover: Abräumen + Materialbedarf |
| [07-cockpit.png](07-cockpit.png) | Einzeldrucker mit Job, Spule, Steuerung |
| [02-warteschlange.png](02-warteschlange.png) | Queue mit Status und Fehlmenge |
| [09-dateien.png](09-dateien.png) | Bibliothek + Senden an mehrere Drucker |
| [03-material.png](03-material.png) | Spulenbestand und Bedarf aus der Queue |
| [04-auftraege.png](04-auftraege.png) | Kundenaufträge und Positionen |
| [05-statistik.png](05-statistik.png) | KPIs: Läufe, Kosten, Erfolg, Schätztreue |
| [08-wartung.png](08-wartung.png) | Laufzeit und Filament je Maschine |
Empfohlene Reihenfolge im Post: Wand → Jetzt → Warteschlange → Material → Statistik.
File renamed without changes.
+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`.
@@ -6,7 +6,7 @@ Nach dem Druck kann Grotti auf der **Kobra 3** Bett und Düse abkühlen und die
## Quittierung vs. Schieben
- **Wand „Platte geräumt“:** Nur Quittierung. Tabelle `bed_status`, API `POST /api/v1/drucker/<host>/abraeumen` mit `confirm`. Fährt keine Bahn. `needs_clear` setzt der Watcher bei Druckende/Abbruch/Fehler (nicht schon beim Start). Solange gesetzt, startet „wenn frei“ den nächsten Job nicht.
- **Wand „Platte geräumt“:** Quittierung + optional Urteil. Tabelle `bed_status`, API `POST /api/v1/drucker/<host>/abraeumen` mit `confirm`. Fährt keine Bahn. Offener Live-Lauf wird als `completed` geschlossen (History von Moonraker oft ohne Endzeit). `needs_clear` setzt der Watcher beim Live-Übergang Druckende/Abbruch/Fehler (nicht schon beim Start; nicht blind beim ersten Blick nach Neustart). Solange gesetzt, startet „wenn frei“ den nächsten Job nicht.
- **Ablauf (Dateien):** Drucken → Abkühlen → Schieben → Tasten, dann der nächste Druck mit LeviQ. Eine Bestätigung beim Einreihen; Schieben läuft ohne zweite Rückfrage. Nach erfolgreichem Schieben ist die Platte automatisch quittiert.
- **Normale Drucke** und „Nochmal“ schieben nicht — nach dem Druck muss man auf der Wand quittieren.
@@ -17,7 +17,7 @@ Unter **Dateien** erscheint die Karte **Ablauf**, sobald mindestens eine Kobra 3
- **Drucken** — Datei, Bettnetz (LeviQ) standardmäßig an
- **Abkühlen** — Heizung aus, Lüfter an, Düse in die Bettmitte, warten bis Bett/Düse unter den Grenzen
- **Schieben** — Bahn mit einstellbaren Zahlen, danach Lüfter aus
- **Tasten** — kein `G28 Z`. **Mit aktivem LeviQ-Netz** (kein `BED_MESH_CLEAR`): `Result` = Höhe über der lokalen Platte. Bezugspunkt + Tastpunkt (x/y) einstellbar. Maß: letztes `Result is z=` aus dem Store. Über 1 mm = Restteil.
- **Tasten** — kein `G28 Z`. **Mit aktivem LeviQ-Netz** (kein `BED_MESH_CLEAR`): `Result` = Höhe über der lokalen Platte. Bezugspunkt + Tastpunkt (x/y) einstellbar. Maß: letztes `Result is z=` aus dem Store. Über `max_delta_mm` (Default 3 mm) = Restteil. Enge 1 mm/2,5 mm-Werte aus alten Abläufen werden auf 3 mm angehoben (kalt an der Düse sonst Fehlalarm).
Darunter **wie oft hintereinander**. Einreihen legt einen Eintrag in die Warteschlange „Wenn frei“. **Schieben testen** fährt die Bahn auf einer freien Kobra 3 (Bestätigung „Bett ist leer“). Werte merkt sich der Browser lokal.
@@ -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).
+156
View File
@@ -0,0 +1,156 @@
# Anycubic-Stack: wie Druck, Abbruch und ACE wirklich laufen
Stand **15.10.2026**. Quellen: Anycubic [Klipper-go](https://github.com/ANYCUBIC-3D/Klipper-go), [K3-klipper-mcu](https://github.com/ANYCUBIC-3D/K3-klipper-mcu); Rinkhals `kobra.py`, `mmu_ace.py`, `mmu_ace_metadata.py`, Docs `firmware/mqtt.md` und `firmware/goklipper.md` (lokal unter `D:\Projekte\_ref\`). Nicht geraten — am Quellcode und an der Rinkhals-Doku gelesen.
## Schichten
```mermaid
flowchart TB
subgraph host [PC]
Grotti[Grotti]
end
subgraph printer [Kobra 3 / S1]
Moonraker[Moonraker + kobra.py]
MmuAce[mmu_ace + mmu_ace_metadata]
Gkapi[gkapi MQTT Broker]
Gklib[gklib GoKlipper]
Ui[K3SysUi Touch]
Mcu[MCU Firmware]
AceHw[ACE Hardware]
end
Grotti -->|"HTTP :80 upload / print/start / cancel"| Moonraker
Grotti -->|"MQTTS :9883 print start/stop"| Gkapi
Grotti -->|"HTTP :18910 info/ctrl Credentials"| Gkapi
Moonraker -->|"bei print/start oft MQTT lokal :2883"| Gkapi
Moonraker --> Gklib
MmuAce --> Moonraker
Gkapi --> Gklib
Ui --> Gkapi
Gklib --> Mcu
Gklib --> AceHw
```
| Komponente | Offen? | Rolle |
| --- | --- | --- |
| **gklib** (GoKlipper) | teilweise ([Klipper-go](https://github.com/ANYCUBIC-3D/Klipper-go)) | Klipper-Host: Motion, Heater, `virtual_sdcard`, ACE-G-codes |
| **MCU** | teilweise ([K3-klipper-mcu](https://github.com/ANYCUBIC-3D/K3-klipper-mcu)) | Stepper/Sensoren |
| **gkapi** | nein (proprietär) | MQTT-Broker, Druck-Tasks, Cloud/LAN, Credentials `:18910` |
| **K3SysUi** | nein | Touch-UI |
| **Rinkhals Moonraker** | ja | Vanilla Moonraker + `kobra.py` + `mmu_ace*` |
Anycubic schreibt in den READMEs: proprietäre Features sind gekapselt. MQTT-Taskverwaltung und Cloud stecken nicht in Klipper-go.
## Zwei Welten beim Druckstart
1. **Klipper-Pfad** (Moonraker `POST /printer/print/start` → gklib `virtual_sdcard`): setzt `print_stats`, lädt G-code in gklib.
2. **Anycubic-Task** (MQTT `type: print`, `action: start` an Topic `…/slicer/printer/{modelId}/{deviceId}/print`): legt den Job an, den Display und Cloud kennen; steuert LeviQ/`auto_leveling`, ACE-Map in der Payload.
Rinkhals `kobra.py` `patch_mqtt_print` hängt den Moonraker-`print/start` oft auf MQTT um (`mqtt_print_file`), lokal `127.0.0.1:2883`. Grotti kann denselben Start von außen über **MQTTS `:9883`** mit Credentials aus `:18910` machen — dann mit eigener `task_settings` und `ams_settings` (siehe [Senden-Queue](Senden-Queue)).
### MQTT Start (belegt)
Aus Rinkhals-Doku `firmware/mqtt.md` und `kobra.py`:
- Publish: `anycubic/anycubicCloud/v1/slicer/printer/{modelId}/{deviceId}/print`
- Report: `…/printer/public/{modelId}/{deviceId}/print/report`
- Pflichtfelder Start: `filename`, `filetype` (1), `taskid`
- Optional: `ams_settings.ams_box_mapping`, `task_settings.auto_leveling` (0/1 Integer)
- Rinkhals und Grotti setzen `taskid` auf eine Zufallszahl; die Doc-Minimalbeispiel nutzt `"-1"` — beides kommt vor
### MQTT Stop (belegt) — warum 10101
```json
{ "type": "print", "action": "stop", "data": { "taskid": "-1" } }
```
Moonraker `/printer/print/cancel` trifft nur gklib (`print_stats` → `cancelled`). Der Anycubic-Task in gkapi bleibt → nächster MQTT-Start mit **code 10101** („Print task already exists“ / chinesische Meldung). Rinkhals `mqtt_print_file` kennt 10101 und **retryt einmal**; Grotti sendet bei Abbruch Moonraker-cancel **und** MQTT-stop.
### Task-Hygiene (wie Rinkhals)
Vorbild: `kobra.py` `mqtt_print_file` + `firmware/mqtt.md`. **Kobra 3 und Kobra S1** — dieselbe gkapi-API (`:18910` / MQTTS `:9883`). Gemessen 15.10.2026: S1 `modelId` 20025 / `state: free`, K3 `modelId` 20024; `project: null` zählt nicht als busy. Abräumen-Ablauf bleibt nur Kobra 3.
| Schritt | Verhalten in Grotti |
| --- | --- |
| Vor MQTT-Start | `ensure_task_free`: `info/query`; wenn `state` nicht free oder `project` gesetzt → MQTT-stop, bis zu 3× pollen |
| Vor Moonraker-`print/start` | dieselbe Hygiene best-effort (Rinkhals wandelt Start oft intern zu MQTT um) |
| gkapi kurz weg | `print_info` → `None`, negativer Cache ~45 s; Start über Moonraker läuft weiter |
| Report **10101** | einmal cancel + 1,5 s Pause + erneuter Start |
| Report **10107** | klare Filament-Meldung |
| Live | Snapshot-Feld `anycubic_task` (`state`, `task_id`, `filename`) aus gecachtem `print_info` (TTL ~10 s); Wand zeigt die Zeile |
Codes und Retry: `grotti/anycubic_lan.py` (`message_for_code`, `ensure_task_free`, `start_print`).
### Report-Timeout
`kobra.py` wartet max. 30 s auf `print/report`. Nach Bettnetz/`auto_leveling` kann der Report später kommen, der Druck trotzdem laufen (`info/query` → `project.task_id`). Grotti prüft bei Timeout deshalb `print_info` (MQTT `info/query`).
## FIRMWARE_RESTART
In **Klipper-go** ist der Befehl implementiert wie bei Klipper:
- `Cmd_FIRMWARE_RESTART` → `Request_restart("firmware_restart")` (`gcode.go`)
- Nach dem Reactor-Ende: Event `project:firmware_restart` → MCU `_firmware_restart` (`k3c.go`, `Mcu.go`)
Auf dem Gerät **deadlockt gklib** trotzdem (beobachtet, Rinkhals #32): Log zeigt Annahme des Befehls, danach keine weitere Verarbeitung, Moonraker bleibt in `startup`. Rinkhals `patch_klipper_restart` **lehnt** `RESTART` / `FIRMWARE_RESTART` unter GoKlipper ab. Grotti ebenso. Config: Display-Reboot oder Strom. Hängenden Fehler: **Fehler löschen** (`SDCARD_RESET_FILE`), nicht Firmware-Restart.
Der Open-Source-Code erklärt den Intent des Befehls; die Deadlock-Ursache steckt vermutlich im ausgelieferten Binary / der Anbindung an gkapi — nicht im veröffentlichten Happy Path.
## ACE
### In gklib (Klipper-go `extras_ace.go`)
G-codes: `ACE_CHANGE_TOOL`, `ACE_FEED`, `ACE_RETRACT`, `ACE_SET_SLOT`, Trocknen, Endless Spool, … Kommunikation über Seriell (`extras_ace_commun.go`). `Get_status` liefert ACE-`info` + Endless-Spool-Flags — **nicht** das Moonraker-Objekt `mmu` mit `gate_color`.
### In Rinkhals Moonraker (`mmu_ace.py`)
- Emuliert Happy-Hare-ähnliches **`mmu`**-Objekt (`gate_color`, `gate_material`, …) für Clients
- Beim MQTT-Start: `register_print_data_patcher` → wenn keine `ams_settings` in der Payload, baut Identity-/TTG-Map aus den Gates und hängt `ams_settings` an
- Liest/schreibt Sidecar **`basename.acm`** neben dem G-code; ohne ACE kann sie nach `.acm.disabled` umbenannt werden
- Auto-Feed-Logik vor Start, wenn nichts im Toolhead ist (Issue #464)
### Upload-Metadaten (`mmu_ace_metadata.py`)
Beim Moonraker-Upload: ganze Datei lesen, ggf. umschreiben. Fingerprint `; processed by MmuAcePatcher`. Trailer `; referenced_tools = 0,2,…`. Ohne Trailer: gklib „skipping auto-feed (no trailer found)“. Grotti setzt Fingerprint + Trailer vor dem Upload, damit der teure Durchlauf entfällt ([Senden-Queue](Senden-Queue)).
### `.acm` vs MQTT-`ams_settings`
Zwei Kanäle für dieselbe Zuordnung:
| Kanal | Wann |
| --- | --- |
| Datei `name.gcode.acm` / `name.acm` auf dem Drucker | Stock/gklib liest Sidecar beim Start |
| MQTT-Feld `data.ams_settings` | gkapi / Rinkhals-Patcher legt Map in die Payload |
Ohne Map: Rinkhals füllt oft Identity T0→Platz0 … — deshalb Grotti bei eigener ACE-Zuordnung per MQTT mit expliziter Map startet und beide `.acm`-Varianten hochlädt ([ACE-Live](ACE-Live)).
## Was Grotti bewusst nutzt
| Aktion | Weg | Beleg |
| --- | --- | --- |
| Einfacher Start | Upload + Moonraker `print/start` → Rinkhals MQTT | `kobra.py` `patch_mqtt_print` |
| Start mit Bettnetz oder ACE-Map | Upload (+ `.acm`) + Grotti-MQTT `:9883` | `anycubic_lan.start_print`, mqtt.md |
| Abbruch | Moonraker cancel + MQTT stop `taskid:-1` | `mqtt_stop_print`, mqtt.md |
| Credentials | `:18910/info` + `/ctrl` AES | `anycubic_lan.credentials` |
| 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
+31
View File
@@ -0,0 +1,31 @@
# Chargen, Etiketten und 3D-Viewer
Stand 17.10.2026.
## Spulen-Chargen
Jede Spule in Grotti bekommt automatisch eine `chargen_nr` der Form `S-YYYYMMDD-0001`. Derselbe Wert steht in `code` und auf dem Etikett. Optional: `hersteller_charge` vom Rollenetikett.
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`. 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 proportional zur Etikettenhöhe. Wand: Knopf **Etikett** bei Druck/Pause. Urteil Ausschuss ändert die Charge nicht — gedruckte Bögen verwerfen.
## 3D-Pfad
`GET /api/v1/dateien/<id>/pfad` liefert einen gedünnten Toolpath (bis ca. 220 000 Punkte, plus `layer_breaks`, Cache-`version` 8) aus dem lokalen G-code. Thinning behält ganze Z-Höhen dicht und lässt nur ganze Schichten weg (sonst gesprenkelte Wände). Die Dateien-Seite und das Cockpit zeichnen ihn mit Three.js.
Darstellung: nur Extrusionswege. Preview = orange. Live: gedruckt leicht verdickt grün, offen grau; Schnitt nach `progress`. Parser akzeptiert `E.39`/`Z.6`. Bett: Gitter 10 mm, mm-Sprites, Legende.
**Exclude-Object** (nur Klipper/Moonraker): Status aus `exclude_object`. Polygone im Viewer; Liste unter dem Pfad; Zwei-Klick → `POST .../steuern` Action `exclude_object` mit `confirm`. Fehlt Modul/DEFINEs → Panel aus. BambuLab später eigener Adapter.
Cockpit-Live: Kamera-Frames werden unsichtbar vorgeladen (letztes Bild bleibt), `no-cam`-Layout nur ohne Kamera-URL. 3D-Orbit bleibt beim 5‑s-Poll erhalten (`setPath` nur beim ersten Laden / Dateiwechsel).
Auf der Wand: Caption `Dateiname · % · Schicht a/b`, sonst „Datei nicht in Grotti“.
Bedienung: Orbit / Zoom / Schwenken / Doppelklick-Reset; Tippen auf Polygon wählt das Objekt.
@@ -1,6 +1,8 @@
# Grotti-Dateien Metadaten
`stored_path` liegt nur in der DB und in Rohzeilen (`get_file_row`, `list_file_rows`). Die API-Antwort (`file_out` / `GET /api/v1/dateien`) enthält den Pfad bewusst nicht.
`stored_path` liegt nur in der DB und in Rohzeilen (`get_file_row`, `list_file_rows`). Die API-Antwort (`file_out` / `GET /api/v1/dateien`) enthält den Pfad bewusst nicht — stattdessen berechnet `folder` den Relativ-Elternordner unter `watch_dir` (Uploads → `Upload`).
Verschieben einer Watch-Datei: Dateisystem unter `watch_dir` + `UPDATE files.stored_path` über dieselbe ID (`library.move_file`), damit Projekt/Spule erhalten bleiben. Reiner Re-Scan nach manuellem Verschieben würde die ID verlieren.
`POST /api/v1/dateien/neu-lesen` liest Watch-Ordner neu und ruft `refresh_file_meta` auf Rohzeilen auf — nicht auf `list_files`, sonst `KeyError: 'stored_path'`.
@@ -12,7 +14,7 @@ In der Bibliothek gespeichert:
| Feld | Bedeutung |
|------|-----------|
| `filaments` | Typ/Farbe (`paint_info` oder `filament_type`/`filament_colour`) |
| `filaments` | Typ/Farbe (`paint_info` oder `filament_type`/`filament_colour`) plus `profile` aus `filament_settings_id` (Sorte, z. B. Sunlu PLA Meta) |
| `weight_g` / `estimated_s` | Filamentgewicht und Druckzeit |
| `printer_family` | aus `printer_model` |
| `slice_meta` | jsonb: `layer_height`, `layers`, `max_z_mm`, `nozzle_mm`, `temp_nozzle`, `temp_bed`, `slicer` |
+26
View File
@@ -0,0 +1,26 @@
# Druckbericht und Statistik
Stand **17.10.2026**.
## Tabellen
- `runs`: zusätzlich `weight_planned_g`, `weight_used_g`, `weight_source`, `filament_used_mm`/`filament_total_mm`, `progress_last`, `estimated_s`, `chargen_nr`, `message`, `objects`
- `run_spools`: Abzug je Spule (`is_estimate` bei Mehrfarb)
- `run_segments`: Abschnitte bei Rollenwechsel (Einlegen während Druck/Pause)
- `run_samples`: Temperatur/Soll/ED (`nozzle_power`/`bed_power`) — alle ~30 s, beim Aufheizen ~10 s; Proben schon vor `printing`, wenn Targets gesetzt und Auftrag/Live-Lauf bekannt
- `run_labels`: Objekt-Chargen `D-…-01`
- `events.run_id`: optionaler Bezug zum Lauf
## Abzug
`grotti/usage.py` rechnet Ist-Gramm aus `filament_used` / `filament_total` × Soll. Abbruch ohne Extrusion: 0. Ohne Zähler: Fortschritt × Soll (Schätzwert). Fertig ohne Messgröße: Soll bleibt, Quelle `estimate`.
`apply_deduction` schreibt zuerst `run_spools`, dann `deducted`/`remaining_g` (auch unter 0); `ensure_run_chargen` oft schon während des Laufs. Ohne `run_spools` bleibt die Spulenakte leer.
## Live
`StateWatcher` hängt am offenen History-Job oder legt `live:…` an, schreibt Proben, vergibt `chargen_nr`, merkt Objektnamen. Vor neuem Start: `abandon_live_runs`. `sync_history` übernimmt Proben/Segmente (`adopt_live_run`, nicht auf ältere fertige Jobs derselben Datei). Die Live-Charge geht nur auf einen fertigen Lauf ohne Nummer über; Objekt-Etiketten nur zusammen mit dieser Charge. Sonst werden sie neu vergeben, statt eine fremde `D-…-01` zu blockieren.
## Statistik-Filter
`since` inklusiv, reines `until` als Datum (`YYYY-MM-DD`) inklusiv (intern Folgetag exklusiv). Presets in der UI setzen Von/Bis. CSV nutzt dieselben Query-Parameter.
+40
View File
@@ -0,0 +1,40 @@
# Grotti: Alltag und Oberfläche
Stand **18.10.2026**, v3.3.0.
## Design
- Dunkles UI mit Orange-Akzent; Light-Theme über dieselben Tokens (`html[data-theme]`).
- Schrift **Inter** (variable, lokal unter `web/static/fonts/`).
- Icons: **Lucide** als Sprite `web/static/icons.svg` (bauen mit `python scripts/build_icons.py`), IDs `i-<name>`.
- Komponenten: Toolbar, Card-Head, KPI-Karten, Chips (Zustände + Queue + Urteile), Dialog-Kopf/Fuß, Empty-States, Skeleton.
- Primär: `btn-primary` / `go-btn`; Danger: `btn-danger` / `warn`; Icon-Buttons mit `title`/`aria-label`.
- **Anzeige pro Browser**: Desktop oder Regal (Touch); Zeitzone; Nav einklappbar.
- PWA: `manifest.webmanifest` + Service-Worker (Shell-Cache inkl. Icons/Font).
- Live: SSE `/api/v1/stream` plus Poll-Fallback; Verbindungsbanner.
- Ctrl+K: globale Suche (`/api/v1/suche`); Knopf in der Topbar.
- Kamera: Snapshot (gecacht) oder MJPEG-Stream (`/api/v1/kamera/stream`), Browser-Einstellung `grotti-camera`.
## Seitenleiste und Topbar
- Sidebar: Abschnittslabels **Live** / **Rückschau** / **Verwalten** (ohne Live-Panel).
- Topbar-Live-Chips (**Jetzt** · Läufe · Senden · Meldungen) mit Popover; Farm-ETA nur bei mehreren Läufen.
- Mobile: Tab **Jetzt** (`/jetzt`) statt Aktivität in der unteren Leiste; Aktivität unter Mehr.
- Topbar: Suche, Einstellungen, Doku, Benutzer-Menü (Avatar → Abmelden, Version, Copyright).
- Mobile: Bottom-Nav Drucker · Dateien · Warteschlange · Aktivität · **Mehr** (Sheet inkl. Abmelden); Topbar ausgeblendet, Suche als Icon in der Brand-Zeile.
## Wand und Abräumen
- Karten + Cockpit; Technik-Fold mit Icons je Abschnitt.
- **Platte geräumt** öffnet Dialog: gut/Ausschuss, Grund, Stückzahlen.
## Aufträge und Wartung
- `/auftraege`: Kunden, Positionen, Einreihen, Lieferschein. Status als Chip.
- `/wartung`: Stunden/Meter und Verschleißteile.
## Dateien, Material, Statistik
- Bibliothek mit Toolbar/Suche; Senden-Schritte nummeriert.
- Material: Spulen/Sorten-Tabs, Filter-Toolbar.
- Statistik: KPI-Karten mit Icons.
+89
View File
@@ -0,0 +1,89 @@
# Glossar: Moonraker, Mainsail und der Rest
Stand **15.10.2026**. Begriffe rund um Kobra 3 / S1, Rinkhals und Grotti. Architektur-Details: [Anycubic-Stack](Anycubic-Stack).
## Die Schichten in einem Satz
| Schicht | Rolle |
| --- | --- |
| **MCU-Firmware** | Läuft auf der Drucker-Platine: Stepper, Heizer, Sensoren. |
| **gklib (GoKlipper)** | „Gehirn“ auf dem Linux der Kobra — Anycubics Go-Port von Klipper. |
| **gkapi** | Anycubic-Dienst: MQTT, Cloud/LAN, Druck-Tasks, Touch-Anbindung. |
| **Moonraker** | HTTP/WebSocket-API vor gklib — das, was Apps und Grotti ansprechen. |
| **Mainsail / Fluidd** | Web-Oberflächen *auf* Moonraker (Live-Steuerung, Konsole). |
| **Grotti** | Eure lokale Farm-Seite: Queue, Wand, Material, Abräumen — spricht Moonraker (+ MQTT wo nötig). |
| **Rinkhals** | Community-Aufsatz auf der Stock-Firmware: bringt Moonraker, SSH, Apps. |
```mermaid
flowchart TB
UI[Mainsail / Fluidd / Grotti / Orca]
UI --> MR[Moonraker]
UI -.->|Start mit ACE/Bettnetz, Stop| MQTT[gkapi MQTT]
MR --> GK[gklib GoKlipper]
MQTT --> GK
Touch[K3SysUi Touch] --> MQTT
GK --> MCU[MCU]
GK --> ACE[ACE]
```
## A–Z
### ACE
Anycubic Color Engine — Multimaterial-Box (vier Plätze). Ansteuerung in gklib (`ACE_*`-G-codes); Zuordnung Farbe→Platz oft über Sidecar **`.acm`** und/oder MQTT **`ams_settings`**. In Moonraker erscheint das bei Rinkhals oft als Objekt **`mmu`** (`gate_color`, …).
### Fluidd
Web-UI für Klipper/Moonraker, Alternative zu Mainsail. Gleiche API, anderes Aussehen. Bei Rinkhals typisch Port **4408**.
### FIRMWARE_RESTART
Klipper-Befehl: Host + MCU neu laden. Auf **GoKlipper** deadlockt gklib oft (Rinkhals #32). Grotti und Rinkhals lehnen ihn ab → Display-Reboot oder Strom.
### gklib / GoKlipper
Anycubics Klipper-Host in Go (Open Source: [Klipper-go](https://github.com/ANYCUBIC-3D/Klipper-go)). Entspricht grob dem klassischen `klippy`. Dateiname auf dem Drucker: `gklib`.
### gkapi
Geschlossener Anycubic-Dienst neben gklib. Betreibt den lokalen MQTT-Broker, Druck-Tasks, Credentials (`:18910`), Anbindung Touch/Cloud. Hier sitzt der Task, der bei Abbruch **10101** verursacht, wenn nur Moonraker-cancel kommt.
### Grotti
Dieses Tool: lokale deutsche Seite für mehrere Moonraker-Drucker — Wand, Warteschlange „wenn frei“, Abräumen (Kobra 3), Material, Kosten, Telegram. Kein Slicer, keine Cloud.
### K3SysUi
Anycubic-Touch-Oberfläche (geschlossen). Rinkhals patcht sie, um das eigene Menü einzuhängen.
### Klipper
Open-Source-Firmware-Architektur: schwere Berechnung auf dem Host (Linux), MCU führt Bewegungen aus. Vanilla-Klipper ist Python (`klippy`); auf der Kobra 3 läuft die Go-Variante.
### Mainsail
Beliebte Web-UI für Moonraker: Status, Temp, G-code-Konsole, Dateien, Webcam. Bei Rinkhals oft Port **80** / **4409**. Fühlt sich „direkter“ an als Grotti, weil die Konsole offen ist.
### Moonraker
API-Server vor Klipper/gklib: HTTP und WebSocket. Upload, `print/start`, Pause, `gcode/script`, `objects/query`, Kamera-Proxy. **OctoPrint-Äquivalent** in der Klipper-Welt. Grotti und Mainsail sind Clients davon — Moonraker ist nicht die schöne Oberfläche selbst.
### MQTT (hier)
Protokoll zu **gkapi**, nicht zu Moonraker. Topics wie `…/slicer/printer/{modelId}/{deviceId}/print`. Genutzt für Druck **start/stop**, Info-Query, optional Temp/Speed/Licht. Ports: lokal oft **2883**, von außen TLS **9883**.
### OctoPrint
Klassische Host-Software für (meist) Marlin-Drucker: Web-UI, Plugins, oft G-code-**Streaming** Zeile für Zeile. Bei Klipper/Rinkhals ersetzt durch **Moonraker + Mainsail/Fluidd** (Datei komplett hochladen, Host spielt ab).
### Orca / AnycubicSlicerNext
Slicer auf dem PC. Schicken fertige `.gcode` per Moonraker (oder USB). Kein Teil der Drucker-Firmware.
### printer.cfg / printer.generated.cfg
Klipper-Konfiguration. Auf Anycubic oft generiert/neu geschrieben; Rinkhals warnt vor wildem Editieren (Boot-Risiko). Z-Offset u. a. in `printer_mutable.cfg`.
### Repetier-Server
Host-Software ähnlich OctoPrint (eigene UI, oft Streaming/direktes G-code). Gleicher Kontrast: auf der Kobra ist der Host **gklib**, die API **Moonraker**.
### Rinkhals
Community-Overlay auf Stock-Kobra-OS: Moonraker, Mainsail/Fluidd, SSH, App-Store, ohne die Stock-Firmware zu ersetzen. Installiert der Nutzer selbst (SWU). Docs: [rinkhals-community/Rinkhals](https://github.com/rinkhals-community/Rinkhals).
### SSH
Root-Shell auf dem Drucker-Linux (Rinkhals: Port 22, `root` / `rockchip`). Zum Debuggen, Logs, App-Properties — **nicht** der normale Weg für Drucksteuerung (das bleibt Moonraker).
### SWU
Anycubic-Update-Paket auf USB (Ordner `aGVscF9zb3Nf`). Darüber kommen Stock-Updates und Rinkhals/Installer.
### virtual_sdcard
Klipper-Modul: G-code liegt als Datei auf dem Host und wird von gklib abgespielt. Deshalb Upload + Start statt Streaming über WLAN.
### 10101
Anycubic-MQTT-Fehler: „Druck-Task existiert schon“. Typisch nach Abbruch nur über Moonraker, ohne MQTT `action: stop`.
File renamed without changes.
+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.
+32
View File
@@ -0,0 +1,32 @@
# MCP-Server
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
```powershell
$env:GROTTI_URL = "http://127.0.0.1:8770"
$env:GROTTI_TOKEN = "grt_…" # unter Einstellungen → API-Tokens
$env:GROTTI_MCP_READ_ONLY = "1" # optional: nur lesen
.\.venv\Scripts\pip install mcp
.\.venv\Scripts\python.exe -m grotti.mcp_server
```
## Tools
| Tool | Schreibend | Confirm |
| --- | --- | --- |
| `list_printers`, `get_printer`, `list_running`, `list_queue`, `list_events`, `list_files`, `list_spools`, `get_version` | nein | — |
| `pause_printer`, `resume_printer` | ja | — |
| `cancel_printer`, `clear_bed`, `enqueue_*` | ja | `confirm=true` |
| `steer_temp`, `steer_speed`, `steer_fan` | ja | — |
Nicht exponiert: freie G-code, Firmware-Restart, E-Stop, User/Token/Settings.
## Sicherheit
- Token mit Rolle Operator reicht für den Alltag; Admin-Token vermeiden.
- Read-only-Flag für vorsichtige Agents.
- Server ideal nur lokal; Grotti selbst hinter Login/TLS.
+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"`.
@@ -12,7 +12,7 @@ Seite `/einstellungen`: Druckernamen/Host/Familie/gesperrt (PostgreSQL `printers
## Druckwarteschlange „wenn frei“
Dateien → „Wenn frei“ oder „Nächster freier“ → `when_free` in `POST /api/v1/senden`. Einträge in `print_queue`; Dispatcher startet, sobald der Host (oder ein freier Drucker) `standby`/`complete`/`cancelled` ist, Platte frei und nicht gesperrt. **Alle freien Drucker im selben Tick** (Upload/Start je Host im Worker parallel) — nicht einer alle ~8 s. Sidebar und Seite `/warteschlange`. Sofort-Senden bleibt über die Send-Queue (mehrere Worker). Hängender Fehler wird nur geräumt, wenn für den Host ein Auftrag wartet. Ablauf: Phase `starting` vor Upload; Timeout 10 min ohne `print_active`.
Dateien → „Dieser Drucker“ (belegt) oder „Nächster passender“ → `when_free` in `POST /api/v1/senden`. Einträge in `print_queue`; Dispatcher startet, sobald der Host (oder ein freier Drucker) `standby`/`complete`/`cancelled` ist, Platte frei und nicht gesperrt. Material-Score: Katalog-Sorte aus `filament_settings_id` (streng), Farbe soft; bei ACE und leerer Map baut Grotti die Zuordnung aus Spulen. **Alle freien Drucker im selben Tick** (Upload/Start je Host im Worker parallel) — nicht einer alle ~8 s. Sidebar und Seite `/warteschlange`. Sofort-Senden bleibt über die Send-Queue (mehrere Worker). Hängender Fehler wird nur geräumt, wenn für den Host ein Auftrag wartet. Ablauf: Phase `starting` vor Upload; Timeout 10 min ohne `print_active`.
## Spulen
@@ -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 |
+13
View File
@@ -0,0 +1,13 @@
# Interne Technik-Notizen
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 |
@@ -1,6 +1,6 @@
# Grotti: Senden und Start dauert
Stand **14.10.2026**. Spulen kommen vom Ziel-Drucker zur Startzeit (`ace_map` + eingelegte Rolle), nicht aus einem freien Dropdown. `sends` speichert `ace_map` und Bettnetz für „Nochmal“.
Stand **15.10.2026** (Stack-Quellen und Task-Hygiene in [Anycubic-Stack](Anycubic-Stack)). Spulen kommen vom Ziel-Drucker zur Startzeit (`ace_map` + eingelegte Rolle), nicht aus einem freien Dropdown. `sends` speichert `ace_map` und Bettnetz für „Nochmal“.
## Warum Sekunden / Minuten
@@ -18,7 +18,9 @@ Ab 12.10.2026: Start legt Hintergrund-Aufträge an (`pending` → `running` →
## Slice-Ordner / Upload
`watch_dir` in `config.json` zeigt auf den Slice-Ordner der Bibliothek. Ohne Ordner geht Hochladen über die Dateien-Seite trotzdem.
Slice-Ordner unter **Einstellungen** (persistiert als `watch_dir`). Ohne Ordner geht Hochladen über die Dateien-Seite trotzdem.
**Dateinamen:** Moonraker-Multipart nutzt nur noch einen ASCII-`filename` (`moonraker.safe_upload_name`). Früher standen Nicht-ASCII-Zeichen in `filename*=UTF-8''…`, während `filename=` Unterstriche bekam — gklib speicherte A, Start suchte B → **10115 not found**.
## ACE / `.acm`
@@ -71,6 +73,10 @@ Credentials holt Grotti ohne SSH: `GET :18910/info` → signiertes `POST :18910/
**Abbruch:** Grotti `abbrechen` = Moonraker `/printer/print/cancel` **plus** MQTT `action: stop` / `taskid: "-1"` (gleicher Topic wie Start). Nur Moonraker-cancel lässt den Anycubic-Task stehen → nächster MQTT-Start mit **10101**. 13.10.2026: Gridfinity auf da26 so hängen geblieben, per MQTT-stop freigemacht (`data.state: free`).
### Task-Hygiene vor dem Start (15.10.2026)
Vor jedem Start (MQTT und reiner Moonraker-Pfad) räumt Grotti einen hängenden gkapi-Task best-effort — **Kobra 3 und S1**: `print_info` → ggf. MQTT-stop → poll. Kommt trotzdem **10101**, einmal cancel + 1,5 s + Retry (wie Rinkhals). Ist gkapi kurz unerreichbar, läuft der Moonraker-Start trotzdem (negativer Cache ~45 s). Live zeigt die Wand `anycubic_task` aus demselben `info/query` (Cache ~10 s). Details: [Anycubic-Stack](Anycubic-Stack) Abschnitt Task-Hygiene.
**Start-Report verspätet:** 13.10.2026 ~21:35, A2 (`10.10.1.59`), Ablauf 2/5 direkt nach der Bettnetz-Prüfung. Innerhalb von 30 s kam kein `print/report`, der Druck lief trotzdem an. `info/query` (Topic `…/slicer/printer/{modelId}/{deviceId}/info`, Antwort auf `…/info/report`) zeigte `project.task_id` = Grotti-`taskid`, `state: auto_leveling`. Moonraker meldet in dieser Phase noch `print_stats.state: complete` vom Vorauftrag. Grotti prüft deshalb bei einem Start-Timeout bis zu dreimal `info/query`. Steht dort die eigene `taskid`, gilt der Start als erfolgreich (`anycubic_lan.print_info`).
Fallback ohne MQTT: global in Rinkhals Touch → Manage apps → 40-moonraker → Auto leveling (gilt dann für alle Moonraker-Starts inkl. Mainsail/Orca).
+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.
File renamed without changes.
File renamed without changes.
+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: []
+171 -2
View File
@@ -73,13 +73,182 @@ def normalize_mapping(raw):
ams = int(item.get("ams_index"))
except (TypeError, ValueError):
continue
if paint < 0 or ams < 0 or ams > 7 or paint in seen:
# paint_index = Slicer-T (oft 0–15 bei Bambu), ams_index = ACE-Platz 0–7
if paint < 0 or paint > 63 or ams < 0 or ams > 7 or paint in seen:
continue
seen.add(paint)
out.append({"paint_index": paint, "ams_index": ams})
return out
def filament_for_paint(filaments, paint):
"""Filament-Eintrag zur Slicer-T-Nummer; Fallback Array-Index für Alt-Daten."""
filaments = filaments or []
try:
paint = int(paint)
except (TypeError, ValueError):
return {}
for index, row in enumerate(filaments):
if not isinstance(row, dict):
continue
try:
key = int(row.get("paint_index"))
except (TypeError, ValueError):
key = index
if key == paint:
return row
if 0 <= paint < len(filaments) and isinstance(filaments[paint], dict):
return filaments[paint]
return {}
def densify_mapping(mapping):
"""Slicer-T mit Lücken (T1/T10) → dichte 0…n−1 für gklib/ACE.
gklib meldet bei zu hoher Filament-ID ``invalid filament id`` (10011700).
Rückgabe: (remap alt→neu, dichte Map). Identity → ({}, unverändert).
"""
mapping = normalize_mapping(mapping)
paints = sorted(item["paint_index"] for item in mapping)
if not paints or paints == list(range(len(paints))):
return {}, mapping
remap = {old: new for new, old in enumerate(paints)}
dense = [
{"paint_index": remap[item["paint_index"]], "ams_index": item["ams_index"]}
for item in mapping
]
return remap, dense
def remap_filaments(filaments, remap):
"""paint_index der Filament-Metadaten mit densify-Remap mitziehen."""
if not remap:
return list(filaments or [])
out = []
for row in filaments or []:
if not isinstance(row, dict):
continue
copy = dict(row)
try:
old = int(row.get("paint_index"))
except (TypeError, ValueError):
out.append(copy)
continue
if old in remap:
copy["paint_index"] = remap[old]
out.append(copy)
return out
def align_tools_for_ace(content, mapping, filaments=None):
"""G-code-T und ACE-Map auf dichte 0…n−1 bringen.
Maßgeblich sind die ``Tn`` im G-code. Die UI-Map darf echte Slicer-Indizes
(T1/T10) oder fälschlich kompakte 0/1 haben — ACE-Plätze werden in
Sortierreihenfolge der Map-Einträge den sortierten T-Nummern zugeordnet.
Sonst: ACM 0/1 + G-code T10 → 10011700 invalid filament id.
"""
from grotti.parse import tools_used_in_gcode
mapping = normalize_mapping(mapping)
used = sorted(tools_used_in_gcode(content))
if not mapping:
return content, mapping, list(filaments or []), {}
if not used:
remap, dense = densify_mapping(mapping)
new_content = rewrite_gcode_tools(content, remap) if remap else content
return new_content, dense, remap_filaments(filaments, remap), remap
if len(mapping) != len(used):
from grotti.errors import GrottiError
raise GrottiError(
"ACE-Zuordnung (%d Farben) passt nicht zum G-code (T%s). "
"Datei neu lesen und Zuordnung erneut setzen."
% (len(mapping), ", T".join(str(t) for t in used)),
"ace",
)
mapped_paints = {item["paint_index"] for item in mapping}
paints_match_gcode = mapped_paints == set(used)
if paints_match_gcode:
ams_by_tool = {item["paint_index"]: item["ams_index"] for item in mapping}
ams_order = [ams_by_tool[tool] for tool in used]
else:
# UI kompakt 0…n−1, G-code sparse — Reihenfolge der Map = Reihenfolge der Farben
ordered = sorted(mapping, key=lambda item: item["paint_index"])
ams_order = [item["ams_index"] for item in ordered]
remap = {old: new for new, old in enumerate(used)}
dense = [
{"paint_index": index, "ams_index": ams_order[index]}
for index in range(len(used))
]
source = list(filaments or [])
if paints_match_gcode:
new_fils = remap_filaments(source, remap)
else:
# Metadaten folgen der Map-Reihenfolge (0,1,…), nicht den G-code-T
ordered_paints = sorted(item["paint_index"] for item in mapping)
new_fils = []
for index, tool in enumerate(used):
row = filament_for_paint(source, ordered_paints[index]) or (
source[index] if index < len(source) and isinstance(source[index], dict) else {}
)
if not row:
row = filament_for_paint(source, tool) or {}
copy = dict(row) if isinstance(row, dict) else {}
copy["paint_index"] = index
new_fils.append(copy)
# Identity-Remap weglassen (schon T0…n−1)
if used == list(range(len(used))):
return content, dense, new_fils, {}
new_content = rewrite_gcode_tools(content, remap)
return new_content, dense, new_fils, remap
_TOOL_NUM_RE = re.compile(
rb"^(?P<prefix>(?:MMU_CHANGE_TOOL(?:_STANDALONE)?[^\n]*?\bTOOL=)|T)(?P<num>\d{1,2})\b",
re.I,
)
_REF_TOOLS_RE = re.compile(rb"^(;\s*referenced_tools\s*=\s*)(.+?)\s*$", re.I)
def rewrite_gcode_tools(content, remap):
"""Tn / TOOL=n / referenced_tools laut Remap umschreiben (T10 vor T1)."""
if not content or not remap:
return content
text = content if isinstance(content, (bytes, bytearray)) else str(content).encode("utf-8", "replace")
lines = text.splitlines(keepends=True)
out = []
for line in lines:
stripped = line[:-2] if line.endswith(b"\r\n") else (line[:-1] if line.endswith(b"\n") else line)
ending = line[len(stripped) :]
ref = _REF_TOOLS_RE.match(stripped)
if ref:
parts = []
for bit in ref.group(2).split(b","):
raw = bit.strip()
try:
number = int(raw)
except ValueError:
parts.append(raw)
continue
parts.append(str(remap.get(number, number)).encode("ascii"))
out.append(ref.group(1) + b",".join(parts) + ending)
continue
match = _TOOL_NUM_RE.match(stripped)
if match:
old = int(match.group("num"))
if old in remap:
new = str(remap[old]).encode("ascii")
stripped = stripped[: match.start("num")] + new + stripped[match.end("num") :]
out.append(stripped + ending)
return b"".join(out)
def build_acm(filaments, mapping, slots):
filaments = filaments or []
slots = slots or []
@@ -88,7 +257,7 @@ def build_acm(filaments, mapping, slots):
for item in normalize_mapping(mapping):
paint = item["paint_index"]
ams = item["ams_index"]
filament = filaments[paint] if 0 <= paint < len(filaments) else {}
filament = filament_for_paint(filaments, paint)
slot = slot_by_index.get(ams) or {}
paint_color = color_channels(filament.get("color")) or [0, 0, 0]
ams_color = color_channels(slot.get("color")) or paint_color
+266 -31
View File
@@ -24,13 +24,23 @@ from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
CRED_TTL = 300
INFO_TTL = 45
FAIL_TTL = 45
_CRED_CACHE = {}
_INFO_CACHE = {}
_FAIL_CACHE = {}
# Anycubic MQTT-Report-Codes (Rinkhals kobra.py / mqtt.md)
# Gilt für Kobra 3 und Kobra S1 (gleiche gkapi-Ports :18910 / :9883).
CODE_TASK_ACTIVE = 10101
CODE_FILAMENT = 10107
class LanError(Exception):
def __init__(self, message):
def __init__(self, message, code=None):
super().__init__(message)
self.message = message
self.code = code
def _info(host):
@@ -141,17 +151,103 @@ def normalize_ams(ams_settings):
return {"use_ams": True, "ams_box_mapping": entries}
def _report_error(body, default_message):
code = body.get("code")
if code == 10107:
return "Filament gerissen. Bitte neues laden. (code 10107)"
if code == 10101:
def _parse_code(value):
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return value
def message_for_code(code, default_message="Druckbefehl abgelehnt"):
"""MQTT-Report-Code → deutsche Meldung (für Tests und UI)."""
parsed = _parse_code(code)
if parsed == CODE_FILAMENT:
return "Filament gerissen oder leer. Bitte neues laden. (code 10107)"
if parsed == CODE_TASK_ACTIVE:
return (
"Alter Druckauftrag noch aktiv (Anycubic-MQTT). "
"Bitte zuerst abbrechen. (code 10101)"
"Alter Druckauftrag noch aktiv (Anycubic-Task). "
"Wird geräumt und ggf. erneut gestartet. (code 10101)"
)
message = str(default_message or "Druckbefehl abgelehnt")
if parsed is None:
return message
return "%s (code %s)" % (message, parsed)
def _report_error(body, default_message):
code = _parse_code(body.get("code"))
raw = str(body.get("msg") or "").strip()
if code in (CODE_FILAMENT, CODE_TASK_ACTIVE):
text = message_for_code(code, default_message)
elif raw:
text = "%s%s" % (raw, (" (code %s)" % code) if code is not None else "")
else:
text = message_for_code(code, default_message)
return text, code
def _task_busy(info):
"""True, wenn gkapi einen laufenden/hängenden Task meldet."""
if not isinstance(info, dict):
return False
state = str(info.get("state") or "").strip().lower()
if state and state not in ("free", "idle", "done", ""):
return True
project = info.get("project")
if isinstance(project, dict) and (project.get("task_id") or project.get("filename")):
return True
return False
def task_summary(info):
"""Kompaktes Live-Feld aus print_info-data."""
if not isinstance(info, dict):
return None
project = info.get("project") if isinstance(info.get("project"), dict) else {}
state = str(info.get("state") or "").strip() or None
task_id = project.get("task_id")
if task_id is not None:
task_id = str(task_id)
filename = project.get("filename") or project.get("name") or None
if isinstance(filename, str):
filename = filename.replace("\\", "/").rsplit("/", 1)[-1] or None
if not state and not task_id and not filename:
return None
return {
"state": state,
"task_id": task_id,
"filename": filename,
}
def ensure_task_free(host, attempts=3, wait_s=1.5):
"""Hängenden Anycubic-Task best-effort räumen (Stop + poll).
Kobra 3 und S1. Ohne erreichbares gkapi: None (kein Fehler) — Moonraker-Start
darf nicht an LAN-Ausfall scheitern.
"""
info = print_info(host, timeout=8, use_cache=False)
if info is None or not _task_busy(info):
return info
last_err = None
for _ in range(max(1, int(attempts))):
try:
cancel_print(host, timeout=12)
except LanError as exc:
last_err = exc
time.sleep(max(0.5, float(wait_s)))
info = print_info(host, timeout=8, use_cache=False)
if info is None or not _task_busy(info):
return info
raise LanError(
last_err.message if last_err else (
"Anycubic-Task ließ sich nicht freimachen (state=%s)."
% ((info or {}).get("state") or "?")
),
code=getattr(last_err, "code", CODE_TASK_ACTIVE),
)
message = str(body.get("msg") or default_message)
return "%s%s" % (message, (" (code %s)" % code) if code else "")
def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Druckbefehl."):
@@ -165,7 +261,7 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
model_id, device_id,
)
state = {"done": False, "error": None, "report": None}
state = {"done": False, "error": None, "code": None, "report": None}
def on_connect(client, userdata, flags, reason_code, properties=None):
if getattr(reason_code, "is_failure", False):
@@ -184,9 +280,17 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
report = str(body.get("state") or "")
action = str(request.get("action") or "")
if action == "start" and report in ("failed", "stoped", "stopped"):
state["error"] = _report_error(body, "Druckstart abgelehnt")
text, code = _report_error(body, "Druckstart abgelehnt")
state["error"] = text
state["code"] = code
elif action == "stop" and report in ("failed",):
state["error"] = _report_error(body, "Druckabbruch abgelehnt")
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
@@ -221,16 +325,26 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
pass
if state["error"]:
raise LanError(state["error"])
raise LanError(state["error"], code=state.get("code"))
return state["report"]
def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=30):
"""MQTT-Start wie Rinkhals, mit task_settings.auto_leveling 1/0."""
"""MQTT-Start wie Rinkhals: Task freimachen, bei 10101 einmal Retry."""
name = (filename or "").replace("\\", "/").split("/")[-1]
if not name:
raise LanError("Dateiname fehlt.")
try:
ensure_task_free(host)
except LanError:
# freimachen fehlgeschlagen — Start trotzdem versuchen (Retry fängt 10101)
pass
ams = normalize_ams(ams_settings)
timeout_message = "Timeout beim MQTT-Druckstart."
last_exc = None
for attempt in range(2):
data = {
"filename": name,
"filepath": "/",
@@ -243,10 +357,8 @@ def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=3
"flow_calibration": 0,
},
}
ams = normalize_ams(ams_settings)
if ams:
data["ams_settings"] = ams
request = {
"type": "print",
"action": "start",
@@ -254,26 +366,52 @@ def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=3
"timestamp": int(time.time() * 1000),
"data": data,
}
timeout_message = "Timeout beim MQTT-Druckstart."
try:
_mqtt_print(host, request, timeout=timeout, timeout_message=timeout_message)
except LanError as exc:
# Nach Bettnetz-Prüfung kam das Start-Report schon > 30 s zu spät, der Druck lief trotzdem.
if exc.message != timeout_message:
raise
for attempt in range(3):
project = (print_info(host) or {}).get("project") or {}
if str(project.get("task_id") or "") == data["taskid"]:
break
time.sleep(5)
else:
raise
return {"filename": name, "auto_leveling": bool(auto_leveling)}
except LanError as exc:
last_exc = exc
if exc.message == timeout_message:
for _ in range(3):
project = (print_info(host, use_cache=False) or {}).get("project") or {}
if str(project.get("task_id") or "") == data["taskid"]:
return {"filename": name, "auto_leveling": bool(auto_leveling)}
time.sleep(5)
raise
if exc.code == CODE_TASK_ACTIVE and attempt == 0:
try:
cancel_print(host, timeout=12)
except LanError:
pass
time.sleep(1.5)
continue
raise
if last_exc:
raise last_exc
raise LanError("Druckstart fehlgeschlagen.")
def print_info(host, timeout=10):
"""MQTT info/query, liefert data aus info/report (project.task_id, state …)."""
def print_info(host, timeout=10, use_cache=True):
"""MQTT info/query, liefert data aus info/report (project.task_id, state …).
Bei LAN-/MQTT-Fehler: None (kein Raise), kurz negativ cachen — Live-Wand und
Moonraker-Start bleiben für Kobra 3 und S1 nutzbar, wenn gkapi kurz weg ist.
"""
now = time.time()
if use_cache:
cached = _INFO_CACHE.get(host)
if cached and now - cached[0] < INFO_TTL:
return cached[1]
fail_at = _FAIL_CACHE.get(host)
if fail_at and now - fail_at < FAIL_TTL:
return None
try:
cred = credentials(host)
except LanError:
_FAIL_CACHE[host] = time.time()
return None
model_id = cred["modelId"]
device_id = cred["deviceId"]
topic_query = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/info" % (model_id, device_id)
@@ -316,6 +454,7 @@ def print_info(host, timeout=10):
try:
client.connect(host, 9883, keepalive=30)
except OSError:
_FAIL_CACHE[host] = time.time()
return None
deadline = time.time() + max(3, int(timeout))
try:
@@ -326,9 +465,104 @@ def print_info(host, timeout=10):
client.disconnect()
except Exception:
pass
if state["data"] is None:
_FAIL_CACHE[host] = time.time()
return None
_FAIL_CACHE.pop(host, None)
_INFO_CACHE[host] = (time.time(), state["data"])
return state["data"]
def anycubic_task(host, use_cache=True):
"""Live-Zusammenfassung des gkapi-Tasks (cached), oder null."""
try:
return task_summary(print_info(host, timeout=6, use_cache=use_cache))
except Exception:
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 = {
@@ -344,4 +578,5 @@ def cancel_print(host, timeout=15):
timeout=timeout,
timeout_message="Timeout beim MQTT-Druckabbruch.",
)
_INFO_CACHE.pop(host, None)
return {"mqtt": "stop", "report": report}
+121
View File
@@ -0,0 +1,121 @@
"""Benutzer, Sessions, API-Tokens und CSRF."""
from __future__ import annotations
import hashlib
import secrets
import time
from collections import defaultdict
from werkzeug.security import check_password_hash, generate_password_hash
from grotti.errors import GrottiError
ROLES = ("admin", "operator")
ROLE_RANK = {"operator": 1, "admin": 2}
TOKEN_PREFIX = "grt_"
LOGIN_WINDOW_S = 300
LOGIN_MAX_ATTEMPTS = 12
_login_hits: dict[str, list[float]] = defaultdict(list)
def hash_password(password: str) -> str:
return generate_password_hash(password, method="scrypt")
def verify_password(password_hash: str, password: str) -> bool:
if not password_hash or not password:
return False
return check_password_hash(password_hash, password)
def validate_username(username: str) -> str:
name = (username or "").strip()
if len(name) < 2 or len(name) > 64:
raise GrottiError("Benutzername: 2–64 Zeichen.", "user")
if not all(ch.isalnum() or ch in "._-" for ch in name):
raise GrottiError("Benutzername: nur Buchstaben, Ziffern, . _ -", "user")
return name
def validate_password(password: str) -> str:
if not password or len(password) < 8:
raise GrottiError("Passwort mindestens 8 Zeichen.", "password")
if len(password) > 200:
raise GrottiError("Passwort zu lang.", "password")
return password
def normalize_role(role: str, *, default: str = "operator") -> str:
role = (role or default).strip().lower()
if role not in ROLES:
raise GrottiError("Rolle muss admin oder operator sein.", "role")
return role
def role_at_least(have: str, need: str) -> bool:
return ROLE_RANK.get(have or "", 0) >= ROLE_RANK.get(need or "", 99)
def clamp_token_role(user_role: str, token_role: str | None) -> str:
wanted = normalize_role(token_role or user_role, default=user_role)
if not role_at_least(user_role, wanted):
raise GrottiError("Token-Rolle darf nicht höher als die des Benutzers sein.", "role")
return wanted
def new_api_token() -> str:
return TOKEN_PREFIX + secrets.token_urlsafe(32)
def hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def token_prefix(raw: str) -> str:
return (raw or "")[:12]
def new_csrf() -> str:
return secrets.token_urlsafe(24)
def login_rate_ok(key: str) -> bool:
now = time.time()
hits = _login_hits[key]
_login_hits[key] = [t for t in hits if now - t < LOGIN_WINDOW_S]
return len(_login_hits[key]) < LOGIN_MAX_ATTEMPTS
def login_rate_hit(key: str) -> None:
_login_hits[key].append(time.time())
def public_user(row) -> dict:
if not row:
return {}
created = row.get("created_at")
last = row.get("last_login")
return {
"id": row.get("id"),
"username": row.get("username"),
"role": row.get("role"),
"active": bool(row["active"]) if "active" in row else True,
"created_at": created.isoformat() if created and hasattr(created, "isoformat") else created,
"last_login": last.isoformat() if last and hasattr(last, "isoformat") else last,
}
def public_token(row) -> dict:
return {
"id": row["id"],
"user_id": row["user_id"],
"name": row["name"],
"prefix": row["token_prefix"],
"role": row["role"],
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
"last_used": row["last_used"].isoformat() if row.get("last_used") else None,
"expires_at": row["expires_at"].isoformat() if row.get("expires_at") else None,
"revoked": bool(row.get("revoked_at")),
}
+92
View File
@@ -0,0 +1,92 @@
"""Datenbank- und Datenordner-Backup."""
from __future__ import annotations
import os
import re
import subprocess
import zipfile
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
def backup_dir(cfg):
base = Path(cfg.get("data_dir") or "data")
path = base / "backup"
path.mkdir(parents=True, exist_ok=True)
return path
def _safe_name(name):
name = re.sub(r"[^\w.\-]+", "_", name or "")
return name[:180] or "backup.zip"
def create_backup(cfg):
"""ZIP mit pg_dump (stdout) und data/ ohne toolpath/ und backup/."""
data_dir = Path(cfg.get("data_dir") or "data")
out_dir = backup_dir(cfg)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
filename = _safe_name("grotti-%s.zip" % stamp)
dest = out_dir / filename
url = cfg.get("database_url") or ""
parsed = urlparse(url)
db_name = (parsed.path or "/grotti").lstrip("/") or "grotti"
env = {}
if parsed.password:
env["PGPASSWORD"] = parsed.password
cmd = ["pg_dump", "--no-owner", "--no-acl", db_name]
if parsed.hostname:
cmd[1:1] = ["-h", parsed.hostname]
if parsed.port:
cmd[1:1] = ["-p", str(parsed.port)]
if parsed.username:
cmd[1:1] = ["-U", parsed.username]
proc = subprocess.run(cmd, capture_output=True, env={**os.environ, **env})
if proc.returncode != 0:
err = (proc.stderr or b"").decode("utf-8", "replace").strip()
raise RuntimeError(err or "pg_dump fehlgeschlagen.")
skip_dirs = {"toolpath", "backup"}
with zipfile.ZipFile(dest, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("database.sql", proc.stdout)
if data_dir.is_dir():
for path in sorted(data_dir.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(data_dir)
if rel.parts and rel.parts[0] in skip_dirs:
continue
zf.write(path, "data/" + rel.as_posix())
return {
"name": filename,
"path": str(dest),
"size": dest.stat().st_size,
}
def list_backups(cfg):
out_dir = backup_dir(cfg)
items = []
for path in sorted(out_dir.glob("*.zip"), reverse=True):
try:
size = path.stat().st_size
except OSError:
size = 0
items.append({"name": path.name, "size": size})
return items
def backup_path(cfg, name):
out_dir = backup_dir(cfg)
safe = _safe_name(name)
path = (out_dir / safe).resolve()
if not str(path).startswith(str(out_dir.resolve())):
return None
if not path.is_file():
return None
return path
+105
View File
@@ -0,0 +1,105 @@
"""Filament-Bedarf aus wartender Queue vs. Spulenrest."""
from __future__ import annotations
import re
_HEX_COLOR_RE = re.compile(r"^#?[0-9a-f]{6}([0-9a-f]{2})?$", re.I)
def _group_key(material_type, color):
return (
(material_type or "").strip().lower(),
(color or "").strip().lower(),
)
def _is_slicer_hex(color):
"""Orca/Anycubic speichert oft #26A69A — Lagerfarben heißen „Grau“/„Weiß“."""
return bool(_HEX_COLOR_RE.match((color or "").strip()))
def _stock_for_need(stock_map, mat, color):
"""Exakte Farbe zuerst; bei Slice-Hex oder leerer Farbe: Summe gleicher Sorte."""
key = _group_key(mat, color)
exact = stock_map.get(key, 0.0)
if exact > 0.5:
return exact
if not color or _is_slicer_hex(color):
mat_key = (mat or "").strip().lower()
return sum(
grams
for (m, _c), grams in stock_map.items()
if m == mat_key and grams > 0
)
return exact
def compute(conn):
"""Wartende Jobs × Dateigewicht vs. Spulenrest, gruppiert nach Sorte/Farbe."""
queue_rows = conn.execute(
"""
SELECT q.id, q.spool_id, q.repeat_total, q.repeat_done,
f.weight_g, f.filaments,
s.material_type AS spool_material, s.color AS spool_color
FROM print_queue q
JOIN files f ON f.id = q.file_id
LEFT JOIN spools s ON s.id = q.spool_id
WHERE q.status = 'waiting'
"""
).fetchall()
needed_map = {}
for row in queue_rows:
filaments = row.get("filaments") or []
first = filaments[0] if isinstance(filaments, list) and filaments else {}
if not isinstance(first, dict):
first = {}
mat = (row.get("spool_material") or first.get("type") or "").strip()
color = (row.get("spool_color") or first.get("color") or "").strip()
key = _group_key(mat, color)
try:
repeats = max(1, int(row.get("repeat_total") or 1) - int(row.get("repeat_done") or 0))
except (TypeError, ValueError):
repeats = 1
weight = float(row.get("weight_g") or 0) * repeats
if key not in needed_map:
needed_map[key] = {
"material_type": mat,
"color": color,
"weight_g": 0.0,
"jobs": 0,
}
needed_map[key]["weight_g"] += weight
needed_map[key]["jobs"] += 1
spool_rows = conn.execute(
"""
SELECT material_type, color, COALESCE(SUM(remaining_g), 0) AS remaining_g
FROM spools
WHERE archived_at IS NULL
GROUP BY material_type, color
"""
).fetchall()
stock_map = {}
for row in spool_rows:
key = _group_key(row.get("material_type"), row.get("color"))
stock_map[key] = stock_map.get(key, 0.0) + float(row.get("remaining_g") or 0)
needed = sorted(
needed_map.values(),
key=lambda x: (-x["weight_g"], x["material_type"], x["color"]),
)
shortfall = []
for item in needed:
have = _stock_for_need(stock_map, item["material_type"], item["color"])
gap = item["weight_g"] - have
if gap > 0.5:
shortfall.append({
**item,
"remaining_g": have,
"shortfall_g": gap,
})
shortfall.sort(key=lambda x: (-x["shortfall_g"], x["material_type"], x["color"]))
return {"needed": needed, "shortfall": shortfall}
+9 -1
View File
@@ -136,7 +136,7 @@ MATERIALS = [
{
"name": "Sunlu PA12-CF",
"brand": "Sunlu",
"material_type": "PA",
"material_type": "PA12-CF",
"density": 1.08,
"volumetric": 8,
"volumetric_s1": 8,
@@ -157,6 +157,14 @@ MATERIALS = [
},
]
def ace_material_type(material_type):
"""Typ für den ACE. PA12-CF gibt es dort nicht, der Platz heißt PA6-CF."""
text = (material_type or "").strip()
if text.lower() == "pa12-cf":
return "PA6-CF"
return text
SOLIDS = [
{"id": "schwarz", "name": "Schwarz", "hex": "#1c1c1c"},
{"id": "weiss", "name": "Weiß", "hex": "#f4f4f4"},
+274 -16
View File
@@ -1,23 +1,76 @@
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
import json
import os
import secrets
import threading
from pathlib import Path
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:
return None
try:
ht = float(tariff.get("ht_euro"))
nt = float(tariff.get("nt_euro"))
except (TypeError, ValueError):
return None
return {
"ht_euro": ht,
"nt_euro": nt,
"nt_from": str(tariff.get("nt_from") or "22:00").strip(),
"nt_to": str(tariff.get("nt_to") or "06:00").strip(),
}
def _energy(raw):
energy = raw.get("energy") or {}
return {
"watts": float(energy.get("watts", 120)),
out = {
"watts": float(energy.get("watts", 40)),
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
"bed_watts": float(energy.get("bed_watts", 220)),
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
"dryer_watts": float(energy.get("dryer_watts", 0)),
"dry_hours": float(energy.get("dry_hours", 0)),
"waste_percent": float(energy.get("waste_percent", 0)),
"utilization": float(energy.get("utilization", 50)),
}
tariff = _tariff(energy)
if tariff:
out["tariff"] = tariff
return out
def _ntfy(raw):
ntfy = raw.get("ntfy") or {}
return {
"enabled": bool(ntfy.get("enabled")),
"server": str(ntfy.get("server") or "https://ntfy.sh").rstrip("/"),
"topic": str(ntfy.get("topic") or ""),
"token": str(ntfy.get("token") or ""),
}
def _telegram(raw):
@@ -36,6 +89,7 @@ def _alerts(raw):
"fehler": bool(alerts.get("fehler", True)),
"offline": bool(alerts.get("offline", True)),
"queue": bool(alerts.get("queue", True)),
"spule": bool(alerts.get("spule", True)),
"ablauf": bool(alerts.get("ablauf", False)),
"start": bool(alerts.get("start", False)),
}
@@ -59,22 +113,59 @@ def public_base(cfg, request_root=None):
return str(request_root or "").rstrip("/")
def _oauth(raw):
oauth = raw.get("oauth") or {}
return {
"enabled": bool(oauth.get("enabled")),
"issuer": str(oauth.get("issuer") or ""),
"client_id": str(oauth.get("client_id") or ""),
"client_secret": str(oauth.get("client_secret") or ""),
"redirect_uri": str(oauth.get("redirect_uri") or ""),
}
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:
return key
key = secrets.token_hex(32)
raw["secret_key"] = key
path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return key
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 ""
if host not in LOOPBACK and not token:
raise SystemExit("Ohne Token startet der Dienst nicht, wenn er nicht nur lokal lauscht.")
secret_key = _ensure_secret_key(path, raw)
# Nicht-Loopback ohne Shared-Token ist ok — Login/API-Tokens schützen die App.
# Neu laden falls secret_key geschrieben wurde
if raw.get("secret_key") == secret_key:
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
data_dir = Path(raw.get("data_dir") or "data")
if not data_dir.is_absolute():
data_dir = ROOT / data_dir
watch = (raw.get("watch_dir") or "").strip()
watch_dir = Path(watch) if watch else None
if watch_dir and not watch_dir.is_absolute():
watch_dir = (ROOT / watch_dir).resolve()
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),
@@ -82,12 +173,19 @@ def load():
"watch_dir": watch_dir,
"database_url": raw.get("database_url") or "postgresql://[email protected]:5432/grotti",
"token": token,
"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,
}
@@ -100,12 +198,83 @@ def mask_token(token):
return token[:4] + "…" + token[-2:]
def parse_watch_dir(value, *, create=False, missing_ok=False):
"""Pfad aus Einstellungen/Config → (Path|None, gespeicherter String).
Relativ zum Repo-Root. Leer = aus. create legt den Ordner an.
"""
text = str(value or "").strip()
if not text:
return None, ""
path = Path(text)
if not path.is_absolute():
path = (ROOT / path).resolve()
else:
path = path.resolve()
if path.exists() and not path.is_dir():
raise GrottiError("Der Slice-Ordner ist keine Ordner.", "watch")
if create:
try:
path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise GrottiError("Slice-Ordner konnte nicht angelegt werden: %s" % exc, "watch") from exc
elif not path.is_dir():
if missing_ok:
return None, text
raise GrottiError("Slice-Ordner fehlt: %s" % path, "watch")
try:
stored = path.relative_to(ROOT.resolve()).as_posix()
except ValueError:
stored = str(path)
return path, stored
def default_watch_suggestion():
"""Absoluter Standardpfad (Repo/slice) für die Einstellungen."""
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 {}
return {
"watch_dir": str(cfg.get("watch_dir") or ""),
"watch_dir_default": default_watch_suggestion(),
"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": {
"enabled": bool(tg.get("enabled")),
@@ -113,12 +282,63 @@ def settings_public(cfg):
"bot_token_set": bool(tg.get("bot_token")),
"chat_id": str(tg.get("chat_id") or ""),
},
"ntfy": {
"enabled": bool((cfg.get("ntfy") or {}).get("enabled")),
"server": str((cfg.get("ntfy") or {}).get("server") or "https://ntfy.sh"),
"topic": str((cfg.get("ntfy") or {}).get("topic") or ""),
"token": mask_token((cfg.get("ntfy") or {}).get("token") or ""),
"token_set": bool((cfg.get("ntfy") or {}).get("token")),
},
"alerts": dict(cfg.get("alerts") or {}),
"oauth": {
"enabled": bool(oauth.get("enabled")),
"issuer": str(oauth.get("issuer") or ""),
"client_id": str(oauth.get("client_id") or ""),
"redirect_uri": str(oauth.get("redirect_uri") or ""),
},
}
def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None):
"""Schreibt energy/telegram/alerts/auto_queue/fqdn zurück nach config.json und aktualisiert cfg."""
def save_settings(
cfg,
energy=None,
telegram=None,
ntfy=None,
alerts=None,
auto_queue=None,
auto_retry=None,
fqdn=None,
watch_dir=None,
etikett=None,
):
"""Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
with _SETTINGS_LOCK:
return _save_settings_locked(
cfg,
energy=energy,
telegram=telegram,
ntfy=ntfy,
alerts=alerts,
auto_queue=auto_queue,
auto_retry=auto_retry,
fqdn=fqdn,
watch_dir=watch_dir,
etikett=etikett,
)
def _save_settings_locked(
cfg,
energy=None,
telegram=None,
ntfy=None,
alerts=None,
auto_queue=None,
auto_retry=None,
fqdn=None,
watch_dir=None,
etikett=None,
):
path = cfg.get("_path") or CONFIG_PATH
raw = dict(cfg.get("_raw") or {})
if path.exists():
@@ -129,19 +349,44 @@ def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None,
if auto_queue is not None:
raw["auto_queue"] = bool(auto_queue)
cfg["auto_queue"] = bool(auto_queue)
if auto_retry is not None:
raw["auto_retry"] = bool(auto_retry)
cfg["auto_retry"] = bool(auto_retry)
if watch_dir is not None:
resolved, stored = parse_watch_dir(watch_dir, create=True, missing_ok=False)
raw["watch_dir"] = stored
cfg["watch_dir"] = resolved
if fqdn is not None:
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", 120)),
raw_energy = {
"watts": float(energy.get("watts", 40)),
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
"bed_watts": float(energy.get("bed_watts", 220)),
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
"dryer_watts": float(energy.get("dryer_watts", 0)),
"dry_hours": float(energy.get("dry_hours", 0)),
"waste_percent": float(energy.get("waste_percent", 0)),
"utilization": float(energy.get("utilization", 50)),
}
tariff_in = energy.get("tariff") if isinstance(energy.get("tariff"), dict) else None
if tariff_in:
raw_energy["tariff"] = {
"ht_euro": float(tariff_in.get("ht_euro", raw_energy["euro_per_kwh"])),
"nt_euro": float(tariff_in.get("nt_euro", raw_energy["euro_per_kwh"])),
"nt_from": str(tariff_in.get("nt_from") or "22:00"),
"nt_to": str(tariff_in.get("nt_to") or "06:00"),
}
raw["energy"] = raw_energy
cfg["energy"] = _energy(raw)
if telegram is not None:
current = _telegram(raw)
@@ -154,12 +399,25 @@ def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None,
"chat_id": str(telegram.get("chat_id", current["chat_id"]) or ""),
}
cfg["telegram"] = _telegram(raw)
if ntfy is not None:
current = _ntfy(raw)
token = ntfy.get("token")
if token is None or token == "" or "…" in str(token) or str(token).startswith("••••"):
token = current["token"]
raw["ntfy"] = {
"enabled": bool(ntfy.get("enabled", current["enabled"])),
"server": str(ntfy.get("server", current["server"]) or "https://ntfy.sh").rstrip("/"),
"topic": str(ntfy.get("topic", current["topic"]) or ""),
"token": str(token or ""),
}
cfg["ntfy"] = _ntfy(raw)
if alerts is not None:
raw["alerts"] = {
"fertig": bool(alerts.get("fertig", True)),
"fehler": bool(alerts.get("fehler", True)),
"offline": bool(alerts.get("offline", True)),
"queue": bool(alerts.get("queue", True)),
"spule": bool(alerts.get("spule", True)),
"ablauf": bool(alerts.get("ablauf", False)),
"start": bool(alerts.get("start", False)),
}
+225 -2
View File
@@ -1,5 +1,9 @@
"""Dieselbe Rechnung wie in der Druckkalkulation."""
from datetime import datetime, timedelta, timezone
from grotti.time_window import _in_segment, _parse_hm
def calculate(job, material):
part_g = float(job.get("weight_g") or 0) * max(1, float(job.get("qty") or 1))
@@ -37,6 +41,225 @@ def calculate(job, material):
}
def energy_cost(duration_s, energy):
def _parse_ts(value):
if value is None:
return None
if isinstance(value, datetime):
return value.timestamp()
text = str(value).strip()
if not text:
return None
try:
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
def _avg_power(a, b):
vals = [float(v) for v in (a, b) if isinstance(v, (int, float)) and not isinstance(v, bool)]
if not vals:
return 0.0
return max(0.0, min(1.0, sum(vals) / len(vals)))
def _ed_print_parts(samples, base_w, nozzle_w, bed_w):
"""Trapez: Grundlast / Düse / Bett getrennt. None wenn zu wenig ED-Proben."""
if not samples or len(samples) < 2:
return None
base_kwh = 0.0
nozzle_kwh = 0.0
bed_kwh = 0.0
ed_steps = 0
# Max. Intervall: 3× Normal-Sample (90 s), damit Lücken nicht die ED fortschreiben
max_dt = 90.0
for i in range(len(samples) - 1):
a, b = samples[i], samples[i + 1]
t0 = _parse_ts(a.get("sampled_at"))
t1 = _parse_ts(b.get("sampled_at"))
if t0 is None or t1 is None or t1 <= t0:
continue
dt = min(t1 - t0, max_dt)
hours = dt / 3600.0
pn0, pn1 = a.get("nozzle_power"), b.get("nozzle_power")
pb0, pb1 = a.get("bed_power"), b.get("bed_power")
has_ed = any(
isinstance(v, (int, float)) and not isinstance(v, bool)
for v in (pn0, pn1, pb0, pb1)
)
if has_ed:
ed_steps += 1
ed_n = _avg_power(pn0, pn1)
ed_b = _avg_power(pb0, pb1)
base_kwh += (float(base_w) / 1000.0) * hours
nozzle_kwh += (float(nozzle_w) * ed_n / 1000.0) * hours
bed_kwh += (float(bed_w) * ed_b / 1000.0) * hours
if ed_steps < 1:
return None
return {
"base_kwh": base_kwh,
"nozzle_kwh": nozzle_kwh,
"bed_kwh": bed_kwh,
"print_kwh": base_kwh + nozzle_kwh + bed_kwh,
}
def _is_nt_moment(moment, tariff):
start_t = _parse_hm(tariff.get("nt_from"))
end_t = _parse_hm(tariff.get("nt_to"))
if start_t is None or end_t is None:
return False
now_t = moment.timetz().replace(tzinfo=None)
return _in_segment(now_t, start_t, end_t)
def effective_euro_per_kwh(duration_s, energy, end_dt=None):
energy = energy or {}
tariff = energy.get("tariff")
if not tariff or not duration_s or float(duration_s) <= 0:
return float(energy.get("euro_per_kwh") or 0)
end = end_dt if isinstance(end_dt, datetime) else datetime.now(timezone.utc).astimezone()
if end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
start = end - timedelta(seconds=float(duration_s))
step = 300.0
t = start
nt_s = 0.0
total = 0.0
while t < end:
chunk = min(step, (end - t).total_seconds())
if _is_nt_moment(t, tariff):
nt_s += chunk
total += chunk
t += timedelta(seconds=chunk)
if total <= 0:
return float(energy.get("euro_per_kwh") or 0)
nt_frac = nt_s / total
ht = float(tariff.get("ht_euro") or energy.get("euro_per_kwh") or 0)
nt = float(tariff.get("nt_euro") or ht)
return nt_frac * nt + (1.0 - nt_frac) * ht
def _part(key, label, kwh, euro):
kwh = float(kwh or 0)
return {
"key": key,
"label": label,
"kwh": kwh,
"cost": kwh * float(euro or 0),
}
def energy_estimate(duration_s, energy, samples=None, end_dt=None):
"""Strom aus ED-Proben oder pauschal Dauer × Watt.
Quelle ``ed`` nur wenn Düse-/Bett-Watt gesetzt und Proben mit power vorliegen.
``parts`` listet Grundlast / Düse / Bett / Trockner (kWh + €).
"""
energy = energy or {}
euro = effective_euro_per_kwh(duration_s, energy, end_dt=end_dt)
dry_kwh = (float(energy.get("dryer_watts") or 0) / 1000) * float(energy.get("dry_hours") or 0)
base_w = float(energy.get("watts") or 0)
nozzle_w = float(energy.get("nozzle_watts") or 0)
bed_w = float(energy.get("bed_watts") or 0)
parts_map = None
source = "flat"
if (nozzle_w > 0 or bed_w > 0) and samples:
parts_map = _ed_print_parts(samples, base_w, nozzle_w, bed_w)
if parts_map is not None:
source = "ed"
if parts_map is None:
hours = float(duration_s or 0) / 3600
return (float(energy["watts"]) / 1000) * hours * float(energy["euro_per_kwh"])
parts_map = {
"base_kwh": (base_w / 1000) * hours,
"nozzle_kwh": 0.0,
"bed_kwh": 0.0,
"print_kwh": (base_w / 1000) * hours,
}
source = "flat"
print_kwh = float(parts_map["print_kwh"])
kwh = print_kwh + dry_kwh
parts = [
_part("base", "Grundlast", parts_map["base_kwh"], euro),
_part("nozzle", "Düse", parts_map["nozzle_kwh"], euro),
_part("bed", "Bett", parts_map["bed_kwh"], euro),
_part("dryer", "Trockner", dry_kwh, euro),
]
parts = [p for p in parts if p["kwh"] > 1e-6]
return {
"kwh": kwh,
"print_kwh": print_kwh,
"dry_kwh": dry_kwh,
"base_kwh": float(parts_map["base_kwh"]),
"nozzle_kwh": float(parts_map["nozzle_kwh"]),
"bed_kwh": float(parts_map["bed_kwh"]),
"cost": kwh * euro,
"euro_per_kwh": euro,
"source": source,
"parts": parts,
}
def energy_cost(duration_s, energy, samples=None, end_dt=None):
return energy_estimate(duration_s, energy, samples, end_dt=end_dt)["cost"]
def offer_from_file(file_row, material_price, machine_rate_per_h, setup_min, margin):
"""Angebots-Schätzung aus Datei-Gewicht und Druckzeit."""
weight_g = float((file_row or {}).get("weight_g") or 0)
est_s = float((file_row or {}).get("estimated_s") or 0)
hours = est_s / 3600.0 if est_s > 0 else 0.0
mat = float(material_price or 0)
rate = float(machine_rate_per_h or 0)
setup_h = float(setup_min or 0) / 60.0
material_cost = (weight_g / 1000.0) * mat
machine_cost = hours * rate + setup_h * rate
subtotal = material_cost + machine_cost
pct = float(margin or 0)
total = subtotal * (1.0 + pct / 100.0) if subtotal > 0 else 0.0
return {
"weight_g": weight_g,
"hours": hours,
"material_cost": material_cost,
"machine_cost": machine_cost,
"subtotal": subtotal,
"margin_percent": pct,
"total": total,
}
def order_actuals(conn, order_id):
"""Summe Kosten, Zeit und Ausschuss der verknüpften Läufe."""
row = conn.execute(
"""
SELECT
COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost_total,
COALESCE(SUM(r.duration_s), 0) AS duration_s,
COALESCE(SUM(r.pieces_ok), 0) AS pieces_ok,
COALESCE(SUM(r.pieces_bad), 0) AS pieces_bad,
COALESCE(SUM(r.weight_g) FILTER (WHERE r.verdict = 'ausschuss'), 0) AS scrap_g,
COUNT(*) FILTER (WHERE r.verdict = 'ausschuss') AS scrap_runs
FROM order_items oi
JOIN runs r ON r.order_item_id = oi.id
WHERE oi.order_id = %s
""",
(order_id,),
).fetchone()
pieces_ok = int(row["pieces_ok"] or 0)
cost_total = float(row["cost_total"] or 0)
return {
"cost_total": cost_total,
"duration_s": float(row["duration_s"] or 0),
"pieces_ok": pieces_ok,
"pieces_bad": int(row["pieces_bad"] or 0),
"scrap_g": float(row["scrap_g"] or 0),
"scrap_runs": int(row["scrap_runs"] or 0),
"cost_per_part": (cost_total / pieces_ok) if pieces_ok > 0 else None,
"yield_percent": (
100.0 * pieces_ok / (pieces_ok + int(row["pieces_bad"] or 0))
if (pieces_ok + int(row["pieces_bad"] or 0)) > 0
else None
),
}
+153
View File
@@ -0,0 +1,153 @@
"""Tagesbericht und Push für Wartung/Bedarf/Termine."""
from __future__ import annotations
import threading
import time
from datetime import date, datetime, timedelta, timezone
from grotti import bedarf as bedarf_mod
from grotti import jetzt as jetzt_mod
from grotti import notify
from grotti import store
from grotti.db import connect
def _yesterday_stats(conn):
today = date.today()
yesterday = today - timedelta(days=1)
start = datetime.combine(yesterday, datetime.min.time(), tzinfo=timezone.utc)
end = datetime.combine(today, datetime.min.time(), tzinfo=timezone.utc)
row = conn.execute(
"""
SELECT
COUNT(*) AS runs,
COALESCE(SUM(duration_s), 0) AS duration_s,
COALESCE(SUM(weight_used_g), 0) AS grams,
COALESCE(SUM(CASE WHEN verdict = 'ausschuss' THEN 1 ELSE 0 END), 0) AS scrap,
COALESCE(SUM(pieces_ok), 0) AS pieces_ok,
COALESCE(SUM(pieces_bad), 0) AS pieces_bad
FROM runs
WHERE started_at >= %s AND started_at < %s
""",
(start, end),
).fetchone()
return {
"date": yesterday.isoformat(),
"runs": int(row["runs"] or 0),
"duration_s": float(row["duration_s"] or 0),
"grams": float(row["grams"] or 0),
"scrap": int(row["scrap"] or 0),
"pieces_ok": int(row["pieces_ok"] or 0),
"pieces_bad": int(row["pieces_bad"] or 0),
}
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)["items"]
if j.get("status") == "waiting"
])
eta = jetzt_mod.farm_eta([
{"remain": (live or {}).get("remain") or 0}
for live in (watcher_latest or [])
if (live or {}).get("state") in ("printing", "paused", "heating")
])
lines = [
"Grotti Tagesbericht %s" % stats["date"],
"Läufe: %d · Druckzeit: %.1f h · Material: %.0f g" % (
stats["runs"], stats["duration_s"] / 3600.0, stats["grams"],
),
"Ausschuss-Läufe: %d · Stück gut/schlecht: %d/%d" % (
stats["scrap"], stats["pieces_ok"], stats["pieces_bad"],
),
"Heute in Warteschlange: %d" % waiting,
]
if eta.get("all_done_in_s"):
at = datetime.now(timezone.utc) + timedelta(seconds=eta["all_done_in_s"])
lines.append("Laufende Drucke fertig gegen %s Uhr" % at.astimezone().strftime("%H:%M"))
overdue = jetzt_mod.overdue_orders(conn, limit=5)
if overdue:
lines.append("Überfällig: " + ", ".join(o["title"] for o in overdue))
short = (bedarf_mod.compute(conn).get("shortfall") or [])[:3]
if short:
bits = []
for item in short:
label = " ".join(x for x in [item.get("material_type"), item.get("color")] if x) or "Filament"
bits.append("%s (−%sg)" % (label, int(item.get("shortfall_g") or 0)))
lines.append("Bedarf: " + ", ".join(bits))
parts = jetzt_mod.overdue_maintenance(conn, printers or store.list_printers(conn), limit=5)
if parts:
lines.append("Wartung: " + ", ".join("%s/%s" % (p["printer"], p["label"]) for p in parts))
return "\n".join(lines)
def push_ops_alerts(conn, cfg, printers=None):
"""Einmalige Push-Hinweise für Bedarf, Wartung, Termine (mit Dedup über Events).
kind beim Speichern muss zum Dedup-Check passen — sonst jede Minute ein neuer Eintrag
(DailyReportThread tickt alle 60 s).
"""
printers = printers or store.list_printers(conn)
for order in jetzt_mod.overdue_orders(conn, limit=10):
msg = "Auftrag überfällig: %s (fällig %s)" % (order["title"], order.get("due_at") or "")
key = "order:%s" % order["id"]
if not store.recent_event_kind(conn, key, "auftrag", within_s=86400):
notify.record(conn, cfg, key, order["title"], "auftrag", msg, dedupe_s=86400)
for part in jetzt_mod.overdue_maintenance(conn, printers, limit=10):
msg = "Wartung fällig: %s · %s" % (part["printer"], part["label"])
key = "maint:%s" % part["id"]
if not store.recent_event_kind(conn, key, "wartung", within_s=86400):
notify.record(conn, cfg, key, part["printer"], "wartung", msg, dedupe_s=86400)
for item in (bedarf_mod.compute(conn).get("shortfall") or [])[:5]:
label = " ".join(x for x in [item.get("material_type"), item.get("color")] if x) or "Filament"
msg = "Bedarf: %s — %s g fehlen" % (label, int(item.get("shortfall_g") or 0))
key = "bedarf:%s:%s" % (item.get("material_type") or "", item.get("color") or "")
if not store.recent_event_kind(conn, key, "bedarf", within_s=21600):
notify.record(conn, cfg, key, label, "bedarf", msg, dedupe_s=21600)
class DailyReportThread:
"""Sendet um 7:00 Ortszeit den Tagesbericht und prüft periodisch Ops-Alerts."""
def __init__(self, database_url, cfg, watcher=None):
self.database_url = database_url
self.cfg = cfg
self.watcher = watcher
self._stop = threading.Event()
self._thread = None
self._last_report_day = None
def start(self):
if self._thread and self._thread.is_alive():
return
self._thread = threading.Thread(target=self._loop, name="grotti-daily", daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
def _loop(self):
while not self._stop.wait(60):
try:
self._tick()
except Exception:
pass
def _tick(self):
now = datetime.now().astimezone()
with connect(self.database_url) as conn:
push_ops_alerts(conn, self.cfg, store.list_printers(conn))
conn.commit()
if now.hour == 7 and now.minute < 2:
day = now.date().isoformat()
if self._last_report_day == day:
return
latest = (self.watcher.latest if self.watcher else None) or []
text = build_text(conn, latest, store.list_printers(conn))
notify.send_telegram(self.cfg, text)
notify.send_ntfy(self.cfg, text)
store.add_event(conn, "", "Grotti", "fertig", text.split("\n")[0])
conn.commit()
self._last_report_day = day
+178 -13
View File
@@ -1,6 +1,9 @@
"""PostgreSQL-Verbindung und Schema."""
"""PostgreSQL-Verbindung, Pool und Migrationen."""
from __future__ import annotations
import re
import threading
from contextlib import contextmanager
from pathlib import Path
from urllib.parse import urlparse, urlunparse
@@ -8,22 +11,109 @@ from urllib.parse import urlparse, urlunparse
import psycopg
from psycopg import sql
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
SCHEMA = (Path(__file__).parent / "schema.sql").read_text(encoding="utf-8")
from grotti import logutil
log = logutil.get("grotti.db")
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
_pool: ConnectionPool | None = None
_pool_lock = threading.Lock()
_user_count_cache = {"n": None, "at": 0.0}
_USER_COUNT_TTL = 5.0
def database_name(url):
name = urlparse(url).path.lstrip("/")
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name or ""):
raise SystemExit("Der Datenbankname in database_url darf nur Buchstaben, Zahlen und Unterstrich enthalten.")
raise SystemExit(
"Der Datenbankname in database_url darf nur Buchstaben, Zahlen und Unterstrich enthalten."
)
return name
def apply_schema(conn):
for statement in SCHEMA.split(";"):
def _migration_files():
files = sorted(MIGRATIONS_DIR.glob("*.sql"))
out = []
for path in files:
match = re.match(r"^(\d+)_", path.name)
if not match:
continue
out.append((int(match.group(1)), path))
return out
def _split_statements(text):
"""Einfache Trennung an Semikolon — Migrationen ohne Funktionen mit ; im Körper."""
parts = []
for statement in text.split(";"):
cleaned = statement.strip()
if cleaned:
conn.execute(cleaned)
parts.append(cleaned)
return parts
def _ensure_version_table(conn):
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_version (
version integer PRIMARY KEY,
name text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now()
)
"""
)
def _applied_versions(conn):
rows = conn.execute("SELECT version FROM schema_version ORDER BY version").fetchall()
return {int(row["version"] if isinstance(row, dict) else row[0]) for row in rows}
def _legacy_schema_present(conn):
row = conn.execute(
"""
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'printers'
"""
).fetchone()
return bool(row)
def apply_migrations(conn):
"""Wendet fehlende Migrationen an. Bestehende Installationen ohne Version bekommen 001 gutgeschrieben."""
_ensure_version_table(conn)
applied = _applied_versions(conn)
migrations = _migration_files()
if not migrations and SCHEMA_LEGACY.exists():
# Fallback: altes schema.sql einmalig
for statement in _split_statements(SCHEMA_LEGACY.read_text(encoding="utf-8")):
conn.execute(statement)
return
if not applied and _legacy_schema_present(conn):
# Schema war schon da (vor Migrationssystem) → Basis als angewandt markieren.
base = next((m for m in migrations if m[0] == 1), None)
if base:
conn.execute(
"INSERT INTO schema_version (version, name) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(1, base[1].name),
)
applied.add(1)
for version, path in migrations:
if version in applied:
continue
text = path.read_text(encoding="utf-8")
for statement in _split_statements(text):
conn.execute(statement)
conn.execute(
"INSERT INTO schema_version (version, name) VALUES (%s, %s)",
(version, path.name),
)
def prepare(url):
@@ -31,14 +121,16 @@ def prepare(url):
admin = urlunparse(urlparse(url)._replace(path="/postgres"))
try:
with psycopg.connect(admin, autocommit=True, connect_timeout=5) as conn:
exists = conn.execute("SELECT 1 FROM pg_database WHERE datname = %s", (name,)).fetchone()
exists = conn.execute(
"SELECT 1 FROM pg_database WHERE datname = %s", (name,)
).fetchone()
if not exists:
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
except psycopg.Error:
pass
except psycopg.Error as exc:
log.warning("Datenbank anlegen: %s", exc)
try:
with psycopg.connect(url, connect_timeout=5) as conn:
apply_schema(conn)
with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
apply_migrations(conn)
conn.commit()
except psycopg.Error as exc:
raise SystemExit(
@@ -50,9 +142,50 @@ def prepare(url):
) from exc
def init_pool(url, min_size=2, max_size=16):
global _pool
with _pool_lock:
if _pool is not None:
return _pool
_pool = ConnectionPool(
conninfo=url,
min_size=min_size,
max_size=max_size,
kwargs={"row_factory": dict_row, "connect_timeout": 5},
open=True,
name="grotti",
)
return _pool
def close_pool():
global _pool
with _pool_lock:
if _pool is not None:
_pool.close()
_pool = None
def get_pool():
if _pool is None:
raise RuntimeError("Datenbank-Pool ist nicht gestartet.")
return _pool
@contextmanager
def connect(url):
conn = psycopg.connect(url, row_factory=dict_row)
def connect(url=None):
"""Verbindung aus dem Pool, sonst einmalig direkt (Tests / prepare)."""
if _pool is not None:
with _pool.connection() as conn:
try:
yield conn
except Exception:
conn.rollback()
raise
return
if not url:
raise RuntimeError("Keine database_url und kein Pool.")
conn = psycopg.connect(url, row_factory=dict_row, connect_timeout=5)
try:
yield conn
except Exception:
@@ -60,3 +193,35 @@ def connect(url):
raise
finally:
conn.close()
def cached_user_count(conn):
"""Zählt Benutzer mit kurzem Cache — vermeidet count(*) bei jedem Request."""
import time
now = time.monotonic()
cached = _user_count_cache["n"]
if cached is not None and (now - _user_count_cache["at"]) < _USER_COUNT_TTL:
return cached
n = int(conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"])
_user_count_cache["n"] = n
_user_count_cache["at"] = now
return n
def invalidate_user_count():
_user_count_cache["n"] = None
_user_count_cache["at"] = 0.0
def prune_run_samples(conn, keep_days=90):
"""Alte Temperaturproben löschen (Retention)."""
days = max(7, int(keep_days))
row = conn.execute(
"""
DELETE FROM run_samples
WHERE sampled_at < now() - (%s || ' days')::interval
""",
(str(days),),
)
return row.rowcount if row is not None else 0
+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)
+2 -101
View File
@@ -55,8 +55,8 @@ DEFAULT_SWEEP = {
# Ein Punkt in der Bettmitte — kein LeviQ, kein 3×3, kein Heizen.
DEFAULT_CHECK = {
"type": "check",
# Result is z= gegen Grenze. Temporär 3 mm — kalter Kunststoff an der Düse
# tastet sonst oft 1–2 mm und meldet Restteil. Später wieder straffen.
# Result is z= gegen Grenze. 3 mm: kalter Kunststoff an der Düse
# tastet sonst oft 1–2 mm und meldet Restteil.
"max_delta_mm": 3.0,
"timeout_min": 2,
"x": 125.0,
@@ -454,11 +454,6 @@ def sweep_lower_to_push_script(sweep):
])
def sweep_probe_height_script(sweep):
"""Kompatibel: hoch, hinten, langsam auf z_push."""
return sweep_go_back_high_script(sweep) + "\n" + sweep_lower_to_push_script(sweep)
def assert_clear_before_lower(live, sweep, min_z_mm=15.0):
"""Vor dem Senken: Toolhead muss noch klar über dem Bett stehen."""
pos = (live or {}).get("position") or []
@@ -562,100 +557,6 @@ def check_lift_script(check):
])
def _axis_coords(lo, hi, count):
if count <= 1:
return [(lo + hi) / 2.0]
return [lo + (hi - lo) * i / (count - 1) for i in range(count)]
def _matrix_ok(matrix):
if not isinstance(matrix, list) or not matrix:
return False
width = None
for row in matrix:
if not isinstance(row, list) or not row:
return False
if width is None:
width = len(row)
if len(row) != width:
return False
for value in row:
if not isinstance(value, (int, float)):
return False
return True
def interpolate_mesh_z(mesh, x, y):
"""Bilinear aus Roh-probed_matrix. Klipper: Zeile 0 = mesh_min Y."""
matrix = mesh.get("matrix") if isinstance(mesh, dict) else None
if not _matrix_ok(matrix):
return None
mesh_min = mesh.get("mesh_min") or [0, 0]
mesh_max = mesh.get("mesh_max") or [0, 0]
try:
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
except (TypeError, ValueError, IndexError):
return None
rows = len(matrix)
cols = len(matrix[0])
if cols < 1 or rows < 1:
return None
if abs(x1 - x0) < 1e-9 or abs(y1 - y0) < 1e-9:
return float(matrix[0][0])
tx = 0.0 if cols == 1 else (float(x) - x0) / (x1 - x0) * (cols - 1)
ty = 0.0 if rows == 1 else (float(y) - y0) / (y1 - y0) * (rows - 1)
tx = max(0.0, min(float(cols - 1), tx))
ty = max(0.0, min(float(rows - 1), ty))
c0 = int(tx)
r0 = int(ty)
c1 = min(cols - 1, c0 + 1)
r1 = min(rows - 1, r0 + 1)
fx = tx - c0
fy = ty - r0
z00 = float(matrix[r0][c0])
z10 = float(matrix[r0][c1])
z01 = float(matrix[r1][c0])
z11 = float(matrix[r1][c1])
z0 = z00 * (1 - fx) + z10 * fx
z1 = z01 * (1 - fx) + z11 * fx
return z0 * (1 - fy) + z1 * fy
def mesh_delta_mm(baseline, measured):
"""Max |Δ| und Mittel |Δ|: Messpunkte gegen interpoliertes Baseline-Netz."""
if not isinstance(baseline, dict) or not _matrix_ok(baseline.get("matrix")):
raise GrottiError("Kein Bettnetz zum Vergleich — vorher nivellieren.", "mesh")
if not isinstance(measured, dict) or not _matrix_ok(measured.get("matrix")):
raise GrottiError("Neues Bettnetz fehlt nach dem Antasten.", "mesh")
matrix = measured["matrix"]
mesh_min = measured.get("mesh_min") or [0, 0]
mesh_max = measured.get("mesh_max") or [0, 0]
try:
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
except (TypeError, ValueError, IndexError) as exc:
raise GrottiError("Messnetz ohne gültigen Bereich.", "mesh") from exc
rows = len(matrix)
cols = len(matrix[0])
xs = _axis_coords(x0, x1, cols)
ys = _axis_coords(y0, y1, rows)
deltas = []
for ri, y in enumerate(ys):
for ci, x in enumerate(xs):
expected = interpolate_mesh_z(baseline, x, y)
if expected is None:
raise GrottiError("Baseline-Netz lässt sich nicht interpolieren.", "mesh")
deltas.append(abs(float(matrix[ri][ci]) - expected))
if not deltas:
raise GrottiError("Keine Messpunkte zum Vergleich.", "mesh")
return {
"max_delta_mm": max(deltas),
"mean_delta_mm": sum(deltas) / len(deltas),
"points": len(deltas),
}
def step_label(step):
if not isinstance(step, dict):
return ""
+42
View File
@@ -0,0 +1,42 @@
"""Thread-sicherer Pub/Sub für Server-Sent Events (Live-UI)."""
from __future__ import annotations
import threading
from queue import Empty, Queue
_lock = threading.Lock()
_subscribers: list[Queue] = []
def subscribe() -> Queue:
q: Queue = Queue(maxsize=256)
with _lock:
_subscribers.append(q)
return q
def unsubscribe(q: Queue) -> None:
with _lock:
try:
_subscribers.remove(q)
except ValueError:
pass
def publish(kind: str, payload: dict | None = None) -> None:
msg = {"kind": kind or "message", "payload": payload if payload is not None else {}}
with _lock:
targets = list(_subscribers)
for q in targets:
try:
q.put_nowait(msg)
except Exception:
pass
def wait_event(q: Queue, timeout_s: float) -> dict | None:
try:
return q.get(timeout=timeout_s)
except Empty:
return None
+293
View File
@@ -0,0 +1,293 @@
"""Aggregierte „Jetzt“-Handlungsliste für Sidebar und Mobile."""
from __future__ import annotations
from datetime import date, datetime, timezone
from grotti import bedarf as bedarf_mod
from grotti import maintenance
from grotti import store
def _filename_label(filename):
name = (filename or "").strip()
for suffix in (".gcode.3mf", ".gcode", ".3mf"):
if name.lower().endswith(suffix):
name = name[: -len(suffix)]
break
while name and name[0].isdigit():
parts = name.split("-", 1)
if len(parts) == 2 and parts[0].isdigit():
name = parts[1]
else:
break
return name or filename or ""
def _iso_remain(remain_s):
remain = float(remain_s or 0)
if remain <= 0:
return None
at = datetime.now(timezone.utc).timestamp() + remain
return datetime.fromtimestamp(at, tz=timezone.utc).isoformat()
def farm_eta(running_items):
"""Früheste und späteste Fertig-Zeit aus laufenden Drucken."""
remains = [
float(item.get("remain") or 0)
for item in (running_items or [])
if float(item.get("remain") or 0) > 0
]
if not remains:
return {"all_done_at": None, "all_done_in_s": None, "next_free_in_s": None}
return {
"all_done_at": _iso_remain(max(remains)),
"all_done_in_s": max(remains),
"next_free_in_s": min(remains),
}
def overdue_orders(conn, limit=8):
today = date.today()
rows = conn.execute(
"""
SELECT o.*, c.name AS customer_name
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.due_at IS NOT NULL
AND o.due_at < %s
AND o.status NOT IN ('fertig', 'geliefert', 'bezahlt')
ORDER BY o.due_at ASC, o.id ASC
LIMIT %s
""",
(today, int(limit)),
).fetchall()
out = []
for row in rows:
out.append({
"id": row["id"],
"title": row["title"],
"status": row["status"],
"due_at": row["due_at"].isoformat() if row.get("due_at") else None,
"customer": row.get("customer_name") or "",
})
return out
def overdue_maintenance(conn, printers, limit=8):
items = []
for printer in printers or []:
host = printer.get("host") or ""
counters = maintenance.counters_for_host(conn, host)
rows = conn.execute(
"SELECT * FROM maintenance_parts WHERE printer_host = %s ORDER BY label, part_key, id",
(host,),
).fetchall()
for row in rows:
part = maintenance.part_out(row, counters, conn=conn)
if not (part.get("overdue_hours") or part.get("overdue_meters")):
continue
items.append({
"id": part["id"],
"host": host,
"printer": printer.get("name") or host,
"label": part.get("label") or part.get("part_key") or "Teil",
"overdue_hours": bool(part.get("overdue_hours")),
"overdue_meters": bool(part.get("overdue_meters")),
})
if len(items) >= limit:
return items
return items
def build(conn, watcher_latest, printers=None, send_jobs=None):
"""Priorisierte Handlungsliste + laufende Drucke + Farm-ETA."""
printers = printers if printers is not None else store.list_printers(conn)
clears = store.bed_clear_map(conn)
busy_hosts = {
row["host"]
for row in conn.execute(
"SELECT DISTINCT host FROM print_queue WHERE status = 'running'"
).fetchall()
}
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"]
actions = []
for live in watcher_latest or []:
host = live.get("host") or ""
state = (live.get("state") or "").lower()
if state == "paused" and (live.get("filament_empty") or (live.get("pause_reason") or "").strip()):
actions.append({
"kind": "filament_pause",
"priority": 12,
"host": host,
"printer": live.get("name") or host,
"title": "Material nachlegen",
"detail": (live.get("pause_reason") or "Drucker pausiert — Filament leer?").strip(),
"filename": live.get("filename") or "",
"href": "/#" + host,
})
for live in watcher_latest or []:
host = live.get("host") or ""
state = (live.get("state") or "").lower()
if not clears.get(host) or host in busy_hosts or state in ("printing", "paused", "heating"):
continue
actions.append({
"kind": "clear",
"priority": 10,
"host": host,
"printer": live.get("name") or host,
"title": "Platte räumen",
"detail": _filename_label(live.get("filename") or ""),
"filename": live.get("filename") or "",
"href": "/#" + host,
})
for job in errors[:6]:
actions.append({
"kind": "queue_error",
"priority": 20,
"id": job.get("id"),
"host": job.get("host") or "",
"printer": job.get("printer_name") or job.get("host") or "",
"title": "Druck fehlgeschlagen",
"detail": job.get("message") or _filename_label(job.get("filename") or ""),
"filename": job.get("filename") or "",
"is_ablauf": bool(job.get("is_ablauf")),
"step_index": job.get("step_index"),
"step_label_current": job.get("step_label_current") or "",
"href": "/warteschlange",
})
bedarf = bedarf_mod.compute(conn)
for item in (bedarf.get("shortfall") or [])[:5]:
label = " · ".join(
x for x in [item.get("material_type"), item.get("color")] if x
) or "Filament"
actions.append({
"kind": "bedarf",
"priority": 30,
"title": "Spule fehlt",
"detail": "%s — %s g fehlen" % (label, int(item.get("shortfall_g") or 0)),
"material_type": item.get("material_type") or "",
"color": item.get("color") or "",
"shortfall_g": item.get("shortfall_g") or 0,
"href": "/material#bedarf",
})
for part in overdue_maintenance(conn, printers):
actions.append({
"kind": "wartung",
"priority": 40,
"id": part["id"],
"host": part["host"],
"printer": part["printer"],
"title": "Wartung fällig",
"detail": "%s · %s" % (part["printer"], part["label"]),
"href": "/wartung",
})
for order in overdue_orders(conn):
actions.append({
"kind": "auftrag",
"priority": 50,
"id": order["id"],
"title": "Auftrag überfällig",
"detail": "%s · fällig %s" % (order["title"], order.get("due_at") or ""),
"due_at": order.get("due_at"),
"href": "/auftraege",
})
shortfall = bedarf.get("shortfall") or []
for job in waiting[:8]:
tip = (job.get("wait_reason") or "").strip()
if not tip and shortfall:
# Fallback ohne Routing-Hinweis
tip = ""
if not tip:
continue
actions.append({
"kind": "waiting",
"priority": 60,
"id": job.get("id"),
"host": job.get("host") or "",
"printer": job.get("printer_name") or job.get("host") or "",
"title": "Wartet auf Material",
"detail": tip,
"filename": job.get("filename") or "",
"reason": tip,
"href": "/warteschlange",
})
actions.sort(key=lambda a: (a.get("priority") or 99, a.get("id") or 0))
live_by_host = {}
running = []
for live in watcher_latest or []:
state = (live.get("state") or "").lower()
if state not in ("printing", "paused", "heating"):
continue
item = {
"host": live.get("host") or "",
"name": live.get("name") or "",
"state": state,
"progress": live.get("progress") or 0,
"remain": live.get("remain") or 0,
"filename": live.get("filename") or "",
"filament_empty": bool(live.get("filament_empty")),
"pause_reason": (live.get("pause_reason") or "").strip(),
"queue_id": None,
}
live_by_host[item["host"]] = item
running.append(item)
for job in running_q:
host = job.get("host") or ""
if host in live_by_host:
live_by_host[host]["queue_id"] = job.get("id")
live_by_host[host]["is_ablauf"] = bool(job.get("is_ablauf"))
live_by_host[host]["step_labels"] = job.get("step_labels")
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": "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")),
})
sends = []
for job in send_jobs or []:
if job.get("status") in ("pending", "running"):
sends.append(job)
elif job.get("status") == "error" and len(sends) < 2:
sends.append(job)
alert_kinds = list(store.ALERT_EVENT_KINDS)
alert_items = store.list_events(conn, limit=5, unread_only=True, kinds=alert_kinds)
unread = store.unread_event_count(conn, kinds=alert_kinds)
return {
"actions": actions,
"running": running,
"waiting_count": len(waiting),
"sends": sends[:5],
"alerts": alert_items,
"unread_alerts": unread,
"eta": farm_eta(running),
"bedarf_shortfall": len(shortfall),
}
+922 -148
View File
File diff suppressed because it is too large. Load diff
+535 -25
View File
@@ -1,5 +1,7 @@
"""Watch-Ordner, Upload und Vorschaubild."""
import re
import shutil
import time
import zipfile
from pathlib import Path
@@ -10,6 +12,8 @@ from grotti.parse import embedded_png, inspect_file, safe_zip_name
_LAST_WATCH_SYNC = 0.0
_WATCH_MIN_INTERVAL_S = 20
_FOLDER_PART = re.compile(r"^[A-Za-z0-9._\- äöüÄÖÜß]+$")
_MAX_FOLDER_DEPTH = 6
def skip_path(path, data_dir):
@@ -36,13 +40,20 @@ def sync_watch(conn, cfg, force=False):
global _LAST_WATCH_SYNC
folder = cfg["watch_dir"]
if folder is None:
return {"gelesen": 0, "hinweis": "In config.json watch_dir auf den Slice-Ordner setzen."}
return {"gelesen": 0, "hinweis": "Unter Einstellungen den Slice-Ordner setzen."}
if not folder.exists():
return {"gelesen": 0, "hinweis": "Der Watch-Ordner fehlt: %s" % folder}
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
@@ -50,13 +61,71 @@ 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):
store.update_file(
conn,
file_id,
{
"printer_family": meta.get("printer_family") or row.get("printer_family"),
"filaments": meta.get("filaments") or [],
"weight_g": meta.get("weight_g") or 0,
"estimated_s": meta.get("estimated_s") or 0,
"thumb_entry": meta.get("thumb_entry") or "",
"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")),
},
)
def refresh_one_file(conn, file_id, watch_dir=None):
"""Eine Datei neu parsen (paint_index / ACE). None wenn nicht gefunden."""
row = store.get_file_row(conn, file_id)
if not row:
return None
stored = row.get("stored_path") or ""
path = Path(stored) if stored else None
if path is not None and path.is_file():
try:
meta = meta_for(path)
_apply_meta(conn, file_id, row, meta)
row = store.get_file_row(conn, file_id) or row
except (OSError, zipfile.BadZipFile):
pass
return store.file_out(row, watch_dir=watch_dir, conn=conn)
def filaments_need_paint_index(filaments):
rows = filaments if isinstance(filaments, list) else []
if len(rows) < 2:
return False
for item in rows:
if not isinstance(item, dict) or "paint_index" not in item:
return True
return False
def refresh_file_meta(conn, force=False):
@@ -76,27 +145,17 @@ def refresh_file_meta(conn, force=False):
meta = meta_for(path)
except (OSError, zipfile.BadZipFile):
continue
store.update_file(
conn,
row["id"],
{
"printer_family": meta.get("printer_family") or row.get("printer_family"),
"filaments": meta.get("filaments") or [],
"weight_g": meta.get("weight_g") or 0,
"estimated_s": meta.get("estimated_s") or 0,
"thumb_entry": meta.get("thumb_entry") or "",
"kind": meta.get("kind") or row.get("kind"),
"plate": meta.get("plate") or "",
"slice_meta": meta.get("slice_meta") or {},
},
)
_apply_meta(conn, row["id"], row, meta)
updated += 1
return updated
def safe_filename(name):
base = Path(name).name.replace("\\", "_").replace("/", "_").replace('"', "")
return base or "datei.gcode"
base = base.strip().strip(".")
if not base or base in (".", ".."):
return "datei.gcode"
return base
def unique_path(directory, filename):
@@ -113,16 +172,445 @@ def unique_path(directory, filename):
return candidate
def require_watch_dir(cfg):
folder = cfg.get("watch_dir")
if not folder:
raise GrottiError("Kein Slice-Ordner gesetzt — unter Einstellungen festlegen.", "watch")
root = Path(folder).resolve()
if not root.is_dir():
raise GrottiError("Der Slice-Ordner fehlt: %s" % root, "watch")
return root
def normalize_folder(rel):
"""Relativpfad unter watch_dir; leer = Wurzel. Kein .., begrenzte Tiefe."""
text = str(rel or "").strip().replace("\\", "/").strip("/")
if not text:
return ""
parts = []
for part in text.split("/"):
part = part.strip()
if not part or part in (".", ".."):
raise GrottiError("Ungültiger Ordnername.", "ordner")
if not _FOLDER_PART.match(part):
raise GrottiError(
"Ordnername nur Buchstaben, Ziffern, Leerzeichen, . _ -.",
"ordner",
)
parts.append(part)
if len(parts) > _MAX_FOLDER_DEPTH:
raise GrottiError("Ordner zu tief verschachtelt (max. %s)." % _MAX_FOLDER_DEPTH, "ordner")
return "/".join(parts)
def path_under_watch(watch_dir, rel_folder, name=None):
"""Absoluter Pfad unter watch_dir; wirft bei Escape."""
root = Path(watch_dir).resolve()
folder = normalize_folder(rel_folder)
target = root if not folder else (root / folder)
if name:
target = target / safe_filename(name)
resolved = target.resolve()
if resolved != root and root not in resolved.parents:
raise GrottiError("Pfad außerhalb des Slice-Ordners.", "ordner")
return resolved
def list_folders(cfg):
"""Unterordner unter watch_dir (auch leer), relativ, sortiert."""
try:
root = require_watch_dir(cfg)
except GrottiError:
return []
data_dir = cfg.get("data_dir")
found = set()
for path in root.rglob("*"):
if not path.is_dir():
continue
if data_dir is not None and skip_path(path, data_dir):
continue
try:
rel = path.resolve().relative_to(root).as_posix()
except ValueError:
continue
if rel and rel != ".":
found.add(rel)
return sorted(found, key=lambda s: s.lower())
def create_folder(cfg, rel):
root = require_watch_dir(cfg)
folder = normalize_folder(rel)
if not folder:
raise GrottiError("Ordnername fehlt.", "ordner")
target = path_under_watch(root, folder)
target.mkdir(parents=True, exist_ok=True)
return folder
def delete_folder(cfg, rel):
root = require_watch_dir(cfg)
folder = normalize_folder(rel)
if not folder:
raise GrottiError("Die Wurzel lässt sich nicht löschen.", "ordner")
target = path_under_watch(root, folder)
if not target.is_dir():
raise GrottiError("Der Ordner fehlt.", "ordner", 404)
try:
next(target.iterdir())
raise GrottiError("Ordner ist nicht leer.", "ordner")
except StopIteration:
pass
target.rmdir()
return folder
def _link_file_to_project_folder(conn, file_id, stored_path, watch_dir):
"""Wenn Pfad unter einem Projekt-Ordner liegt → project_id setzen."""
folder = store.file_folder(stored_path, "watch", watch_dir)
if not folder:
return
for row in store.list_project_folders(conn):
base = (row.get("folder") or "").strip().replace("\\", "/").strip("/")
if not base:
continue
if folder == base or folder.startswith(base + "/"):
store.set_file_project(conn, file_id, row["id"])
return
def move_file(conn, cfg, file_id, folder):
"""Watch- oder Upload-Datei in Slice-Unterordner (oder Wurzel); Upload wird Watch."""
root = require_watch_dir(cfg)
folder = normalize_folder(folder)
row = store.get_file_row(conn, file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
source = row.get("source") or ""
if source not in ("watch", "upload"):
raise GrottiError("Diese Datei lässt sich nicht verschieben.", "datei")
src = Path(row.get("stored_path") or "")
if not src.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
dest_dir = path_under_watch(root, 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, conn=conn)
if dest.exists() and dest.resolve() != src.resolve():
dest = unique_path(dest_dir, src.name)
try:
shutil.move(str(src), str(dest))
except OSError as exc:
raise GrottiError("Verschieben fehlgeschlagen: %s" % exc, "ordner") from exc
stat = dest.stat()
store.update_file_location(
conn,
file_id,
str(dest.resolve()),
name=dest.name,
size_bytes=stat.st_size,
mtime=stat.st_mtime,
source="watch",
)
_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, conn=conn)
def path_allowed_for_delete(stored_path, cfg):
"""Nur Dateien unter Slice-Ordner oder data/eingang."""
try:
resolved = Path(stored_path).resolve()
except (OSError, ValueError):
return False
roots = []
watch = cfg.get("watch_dir")
if watch:
roots.append(Path(watch).resolve())
data_dir = cfg.get("data_dir")
if data_dir:
roots.append((Path(data_dir) / "eingang").resolve())
for root in roots:
try:
if resolved == root or root in resolved.parents:
return True
except OSError:
continue
return False
def delete_library_file(conn, cfg, file_id):
"""Bibliotheksdatei löschen (Upload oder Watch) — DB + Datei auf der Platte."""
row = store.get_file_row(conn, file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
stored = row.get("stored_path") or ""
if stored and not path_allowed_for_delete(stored, cfg):
raise GrottiError("Löschen außerhalb von Slice-Ordner oder Eingang ist nicht erlaubt.", "datei")
store.delete_file(conn, file_id)
if stored:
Path(stored).unlink(missing_ok=True)
return row
def _ensure_print_suffix(new_name, old_name):
"""Endung .gcode / .3mf / .gcode.3mf behalten, wenn der Nutzer sie weglässt."""
name = safe_filename(new_name)
low = name.lower()
old_low = (old_name or "").lower()
if low.endswith(".gcode.3mf") or low.endswith(".3mf") or low.endswith(".gcode"):
return name
if old_low.endswith(".gcode.3mf"):
return name + ".gcode.3mf"
if old_low.endswith(".3mf"):
return name + ".3mf"
if old_low.endswith(".gcode"):
return name + ".gcode"
return name
def rename_library_file(conn, cfg, file_id, new_name):
"""Datei im selben Ordner umbenennen — Platte + DB."""
row = store.get_file_row(conn, file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
src = Path(row.get("stored_path") or "")
if not src.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
if not path_allowed_for_delete(str(src), cfg):
raise GrottiError("Umbenennen außerhalb von Slice-Ordner oder Eingang ist nicht erlaubt.", "datei")
final = _ensure_print_suffix(new_name, row.get("name") or src.name)
if not final or final in (".", ".."):
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"), conn=conn)
if dest.exists():
raise GrottiError("Unter diesem Namen liegt schon eine Datei.", "datei")
try:
src.rename(dest)
except OSError as exc:
raise GrottiError("Umbenennen fehlgeschlagen: %s" % exc, "datei") from exc
stat = dest.stat()
store.update_file_location(
conn,
file_id,
str(dest.resolve()),
name=dest.name,
size_bytes=stat.st_size,
mtime=stat.st_mtime,
)
row = store.get_file_row(conn, file_id)
return store.file_out(row, watch_dir=cfg.get("watch_dir"), conn=conn)
_PRINT_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
def is_print_filename(name):
low = (name or "").lower()
return any(low.endswith(suf) for suf in _PRINT_SUFFIXES)
def project_slug(name):
"""Ordner-sichere Kurzform aus Projektnamen."""
text = re.sub(r"[^\w\- äöüÄÖÜß.]+", "-", (name or "").strip(), flags=re.UNICODE)
text = re.sub(r"\s+", "-", text)
text = re.sub(r"-{2,}", "-", text).strip("-.")
text = text[:60] or "projekt"
return normalize_folder(text)
def ensure_project_folder(cfg, folder_rel):
"""Projektordner unter watch_dir anlegen; gibt normalisierten Relativpfad zurück."""
root = require_watch_dir(cfg)
folder = normalize_folder(folder_rel)
if not folder:
raise GrottiError("Projektordner fehlt.", "ordner")
path_under_watch(root, folder).mkdir(parents=True, exist_ok=True)
return folder
def default_project_folder(name):
return "Projekte/" + project_slug(name)
def link_watch_files_to_projects(conn, cfg):
"""Watch-Dateien in Projektordnern dem passenden Projekt zuordnen."""
watch = cfg.get("watch_dir")
if not watch:
return 0
projects = store.list_project_folders(conn)
if not projects:
return 0
linked = 0
for row in store.list_file_rows(conn):
if (row.get("source") or "") != "watch":
continue
folder = store.file_folder(row.get("stored_path"), "watch", watch)
if not folder:
continue
for proj in projects:
base = (proj.get("folder") or "").strip().replace("\\", "/").strip("/")
if not base:
continue
if folder == base or folder.startswith(base + "/"):
if row.get("project_id") != proj["id"]:
store.set_file_project(conn, row["id"], proj["id"])
linked += 1
break
return linked
def list_project_assets(cfg, folder_rel):
"""Nicht-Druckdateien im Projektordner (flach + Unterordner)."""
root = require_watch_dir(cfg)
folder = normalize_folder(folder_rel)
if not folder:
return []
base = path_under_watch(root, folder)
if not base.is_dir():
return []
items = []
for path in sorted(base.rglob("*")):
if not path.is_file() or skip_path(path, cfg["data_dir"]):
continue
if is_print_filename(path.name):
continue
try:
rel = path.resolve().relative_to(base).as_posix()
except ValueError:
continue
stat = path.stat()
items.append({
"name": path.name,
"rel": rel,
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
})
return items
def resolve_project_asset(cfg, folder_rel, rel_name):
"""Sicheren Pfad zu einer Asset-Datei im Projektordner."""
root = require_watch_dir(cfg)
folder = normalize_folder(folder_rel)
if not folder:
raise GrottiError("Projektordner fehlt.", "ordner")
base = path_under_watch(root, folder)
rel = str(rel_name or "").replace("\\", "/").strip("/")
if not rel or ".." in rel.split("/"):
raise GrottiError("Ungültiger Dateiname.", "datei")
target = (base / rel).resolve()
if base not in target.parents and target != base:
raise GrottiError("Pfad außerhalb des Projektordners.", "ordner")
if not target.is_file():
raise GrottiError("Die Datei fehlt.", "datei", 404)
return target
def save_project_asset(cfg, folder_rel, filename, content):
root = require_watch_dir(cfg)
folder = normalize_folder(folder_rel)
if not folder:
raise GrottiError("Projektordner fehlt.", "ordner")
dest_dir = path_under_watch(root, folder)
dest_dir.mkdir(parents=True, exist_ok=True)
path = unique_path(dest_dir, filename)
path.write_bytes(content)
return {
"name": path.name,
"rel": path.name,
"size_bytes": path.stat().st_size,
"mtime": path.stat().st_mtime,
}
def delete_project_asset(cfg, folder_rel, rel_name):
path = resolve_project_asset(cfg, folder_rel, rel_name)
path.unlink(missing_ok=True)
return True
def delete_project_disk_folder(cfg, folder_rel, force=False):
"""Projektordner auf der Platte entfernen (leer oder force inkl. Inhalt)."""
if not folder_rel:
return
try:
root = require_watch_dir(cfg)
except GrottiError:
return
folder = normalize_folder(folder_rel)
if not folder:
return
target = path_under_watch(root, folder)
if not target.is_dir():
return
if force:
shutil.rmtree(target, ignore_errors=True)
return
try:
next(target.iterdir())
raise GrottiError(
"Projektordner ist nicht leer — mit Bestätigung inkl. Inhalt löschen.",
"ordner",
)
except StopIteration:
target.rmdir()
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]
@@ -132,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)
@@ -142,8 +630,30 @@ def ensure_library_gcode(conn, cfg, host, filename):
return save_upload(conn, name, content, cfg)
def printer_file(row):
path = Path(row["stored_path"])
def resolve_stored_path(conn, row, data_dir=None):
"""Gespeicherter Pfad, ggf. nach Umzug von data_dir unter eingang/<name> wiederfinden."""
stored = (row or {}).get("stored_path") or ""
path = Path(stored) if stored else None
if path and path.is_file():
return path
name = (row or {}).get("name") or (path.name if path else "")
if not name or data_dir is None:
return None
candidate = Path(data_dir) / "eingang" / name
if not candidate.is_file():
return None
resolved = str(candidate.resolve())
file_id = (row or {}).get("id")
if file_id and conn is not None:
conn.execute("UPDATE files SET stored_path = %s WHERE id = %s", (resolved, file_id))
row["stored_path"] = resolved
return candidate.resolve()
def printer_file(row, conn=None, data_dir=None):
path = resolve_stored_path(conn, row, data_dir) if (conn is not None or data_dir is not None) else Path(row["stored_path"])
if path is None:
path = Path(row.get("stored_path") or "")
if not path.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
if row["kind"] == "gcode":
@@ -153,9 +663,9 @@ def printer_file(row):
return path.name, path.read_bytes(), row["plate"] or ""
def preview_png(row):
path = Path(row["stored_path"])
if not path.is_file():
def preview_png(row, conn=None, data_dir=None):
path = resolve_stored_path(conn, row, data_dir) if (conn is not None or data_dir is not None) else Path(row["stored_path"])
if path is None or not path.is_file():
return None
entry = row["thumb_entry"]
if row["kind"] == "gcode":
+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)
+156
View File
@@ -0,0 +1,156 @@
"""Wartungs-Teile und Laufzähler aus dem Protokoll."""
from __future__ import annotations
def _baseline(conn, host, part_key):
row = conn.execute(
"""
SELECT hours_at, meters_at FROM maintenance_log
WHERE printer_host = %s AND part_key = %s AND event IN ('install', 'reset')
ORDER BY created_at DESC, id DESC
LIMIT 1
""",
(host, part_key),
).fetchone()
if not row:
return 0.0, 0.0
return float(row.get("hours_at") or 0), float(row.get("meters_at") or 0)
def counters_for_host(conn, host):
"""Summe Druckzeit, Filament und Anzahl Läufe für einen Host."""
host = (host or "").strip()
if not host:
return {"hours": 0.0, "meters": 0.0, "runs": 0}
row = conn.execute(
"""
SELECT
COALESCE(SUM(duration_s), 0) AS duration_s,
COALESCE(SUM(filament_used_mm), 0) AS meters,
COUNT(*) AS runs
FROM runs
WHERE printer_host = %s AND state = 'completed'
""",
(host,),
).fetchone()
if not row:
return {"hours": 0.0, "meters": 0.0, "runs": 0}
return {
"hours": float(row.get("duration_s") or 0) / 3600.0,
"meters": float(row.get("meters") or 0),
"runs": int(row.get("runs") or 0),
}
def part_out(row, counters=None, conn=None):
counters = counters or {}
life_h = float(row.get("life_hours") or 0)
life_m = float(row.get("life_meters") or 0)
hours_total = float(counters.get("hours") or 0)
meters_total = float(counters.get("meters") or 0)
host = row["printer_host"]
part_key = row["part_key"]
base_h, base_m = (0.0, 0.0)
if conn is not None:
base_h, base_m = _baseline(conn, host, part_key)
hours_since = max(0.0, hours_total - base_h)
meters_since = max(0.0, meters_total - base_m)
installed = row.get("installed_at")
return {
"id": row["id"],
"printer_host": host,
"part_key": part_key,
"label": row.get("label") or "",
"installed_at": installed.isoformat() if hasattr(installed, "isoformat") else str(installed or ""),
"life_hours": life_h,
"life_meters": life_m,
"note": row.get("note") or "",
"hours_since": hours_since,
"meters_since": meters_since,
"overdue_hours": life_h > 0 and hours_since >= life_h,
"overdue_meters": life_m > 0 and meters_since >= life_m,
}
def list_for_api(conn, printers):
out = []
for printer in printers or []:
host = printer.get("host") or ""
counters = counters_for_host(conn, host)
rows = conn.execute(
"SELECT * FROM maintenance_parts WHERE printer_host = %s ORDER BY label, part_key, id",
(host,),
).fetchall()
out.append({
"host": host,
"name": printer.get("name") or host,
"counters": counters,
"parts": [part_out(r, counters, conn=conn) for r in rows],
})
return out
def create_part(conn, printer_host, part_key, label="", life_hours=0, life_meters=0, note=""):
printer_host = (printer_host or "").strip()
part_key = (part_key or "").strip()
if not printer_host or not part_key:
raise ValueError("Drucker und part_key nötig.")
row = conn.execute(
"""
INSERT INTO maintenance_parts (printer_host, part_key, label, life_hours, life_meters, note)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
""",
(
printer_host,
part_key,
(label or part_key).strip(),
float(life_hours or 0),
float(life_meters or 0),
(note or "").strip(),
),
).fetchone()
counters = counters_for_host(conn, printer_host)
conn.execute(
"""
INSERT INTO maintenance_log (printer_host, part_key, event, hours_at, meters_at, note)
VALUES (%s, %s, 'install', %s, %s, %s)
""",
(printer_host, part_key, counters["hours"], counters["meters"], (note or "").strip()),
)
return part_out(row, counters, conn=conn)
def reset_part(conn, part_id):
row = conn.execute(
"SELECT * FROM maintenance_parts WHERE id = %s",
(int(part_id),),
).fetchone()
if not row:
return None
host = row["printer_host"]
counters = counters_for_host(conn, host)
conn.execute(
"""
INSERT INTO maintenance_log (printer_host, part_key, event, hours_at, meters_at, note)
VALUES (%s, %s, 'reset', %s, %s, %s)
""",
(
host,
row["part_key"],
counters["hours"],
counters["meters"],
row.get("note") or "",
),
)
updated = conn.execute(
"""
UPDATE maintenance_parts
SET installed_at = now()
WHERE id = %s
RETURNING *
""",
(int(part_id),),
).fetchone()
fresh_counters = counters_for_host(conn, host)
return part_out(updated, fresh_counters, conn=conn)
+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": ""}
+323
View File
@@ -0,0 +1,323 @@
"""MCP-Server für Grotti — spricht nur die HTTP-API mit einem API-Token.
Umgebung:
GROTTI_URL Basis-URL (Standard http://127.0.0.1:8770)
GROTTI_TOKEN Bearer-Token (grt_…)
GROTTI_MCP_READ_ONLY=1 nur Lese-Tools
Start:
python -m grotti.mcp_server
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
READ_ONLY = os.environ.get("GROTTI_MCP_READ_ONLY", "").strip() in ("1", "true", "yes")
BASE = (os.environ.get("GROTTI_URL") or "http://127.0.0.1:8770").rstrip("/")
TOKEN = (os.environ.get("GROTTI_TOKEN") or "").strip()
def _api(method: str, path: str, body: dict | None = None):
if not TOKEN:
raise RuntimeError("GROTTI_TOKEN fehlt.")
url = BASE + "/api/v1" + path
data = None
headers = {
"Authorization": "Bearer " + TOKEN,
"Accept": "application/json",
}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
if not raw:
return None
return json.loads(raw.decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
try:
payload = json.loads(detail)
message = payload.get("error") or detail
except json.JSONDecodeError:
message = detail or str(exc)
raise RuntimeError(message) from exc
def _write_guard():
if READ_ONLY:
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
except ImportError:
print(
"Paket „mcp“ fehlt. Installieren: pip install mcp",
file=sys.stderr,
)
sys.exit(1)
server = FastMCP("grotti", instructions=INSTRUCTIONS)
@server.tool()
def list_printers(live: bool = True) -> str:
"""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. Host vorher über list_printers holen."""
rows = _api("GET", "/drucker?live=1") or []
for row in rows:
if row.get("host") == host:
return json.dumps(row, ensure_ascii=False, indent=2)
raise RuntimeError("Drucker nicht gefunden: %s" % host)
@server.tool()
def list_running() -> str:
"""Laufende Drucke und Abläufe."""
return json.dumps(_api("GET", "/laeuft"), ensure_ascii=False, indent=2)
@server.tool()
def list_queue(limit: int = 40) -> str:
"""Druckwarteschlange."""
return json.dumps(
_api("GET", "/druck-queue?limit=%d" % max(1, min(limit, 200))),
ensure_ascii=False,
indent=2,
)
@server.tool()
def list_events(limit: int = 30) -> str:
"""Letzte Ereignisse / Aktivität."""
return json.dumps(
_api("GET", "/ereignisse?limit=%d" % max(1, min(limit, 200))),
ensure_ascii=False,
indent=2,
)
@server.tool()
def list_files(limit: int = 50) -> str:
"""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)
@server.tool()
def list_spools() -> str:
"""Spulen / Materialbestand."""
return json.dumps(_api("GET", "/spulen"), ensure_ascii=False, indent=2)
@server.tool()
def auftraege_lesen(limit: int = 30, status: str = "") -> str:
"""Aufträge (optional nach Status filtern)."""
st = (status or "").strip().lower()
q = "?status=" + urllib.parse.quote(st) if st else ""
rows = _api("GET", "/auftraege" + q) or []
cap = max(1, min(limit, 200))
if len(rows) > cap:
rows = rows[:cap]
return json.dumps(rows, ensure_ascii=False, indent=2)
@server.tool()
def bedarf_lesen() -> str:
"""Filament-Bedarf (Queue vs. Spulenrest)."""
return json.dumps(_api("GET", "/bedarf"), ensure_ascii=False, indent=2)
@server.tool()
def wartung_lesen() -> str:
"""Wartungsteile und Zählerstände."""
return json.dumps(_api("GET", "/wartung"), ensure_ascii=False, indent=2)
@server.tool()
def get_version() -> str:
"""Grotti-Version."""
return json.dumps(_api("GET", "/version"), ensure_ascii=False, indent=2)
@server.tool()
def pause_printer(host: str) -> str:
"""Druck pausieren."""
_write_guard()
return json.dumps(
_api("POST", "/drucker/%s/pause" % urllib.parse.quote(host)),
ensure_ascii=False,
)
@server.tool()
def resume_printer(host: str) -> str:
"""Druck fortsetzen."""
_write_guard()
return json.dumps(
_api("POST", "/drucker/%s/fortsetzen" % urllib.parse.quote(host)),
ensure_ascii=False,
)
@server.tool()
def cancel_printer(host: str, confirm: bool = False) -> str:
"""Druck abbrechen. confirm muss true sein."""
_write_guard()
if not confirm:
raise RuntimeError("Abbruch braucht confirm=true.")
return json.dumps(
_api("POST", "/drucker/%s/abbrechen" % urllib.parse.quote(host)),
ensure_ascii=False,
)
@server.tool()
def clear_bed(host: str, confirm: bool = False) -> str:
"""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.")
return json.dumps(
_api(
"POST",
"/drucker/%s/abraeumen" % urllib.parse.quote(host),
{"confirm": True},
),
ensure_ascii=False,
)
@server.tool()
def enqueue_when_free(
file_id: int,
host: str = "",
confirm: bool = False,
bed_leveling: bool = False,
) -> str:
"""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.")
payload = {
"file_id": file_id,
"host": host,
"confirm": True,
"bed_leveling": bool(bed_leveling),
}
return json.dumps(_api("POST", "/druck-queue", payload), ensure_ascii=False, indent=2)
@server.tool()
def enqueue_print(
file_id: int,
host: str,
confirm: bool = False,
bed_leveling: bool = False,
) -> str:
"""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.")
if not host:
raise RuntimeError("host fehlt.")
payload = {
"file_id": file_id,
"host": host,
"confirm": True,
"bed_leveling": bool(bed_leveling),
}
return json.dumps(_api("POST", "/senden", payload), ensure_ascii=False, indent=2)
@server.tool()
def steer_temp(host: str, nozzle: float | None = None, bed: float | None = None) -> str:
"""Zieltemperaturen setzen (Whitelist)."""
_write_guard()
out = []
if nozzle is not None:
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
"action": "nozzle", "temp": nozzle,
}))
if bed is not None:
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
"action": "bed", "temp": bed,
}))
if not out:
raise RuntimeError("nozzle und/oder bed angeben.")
return json.dumps(out, ensure_ascii=False, indent=2)
@server.tool()
def steer_speed(host: str, speed: float) -> str:
"""Geschwindigkeitsfaktor in Prozent."""
_write_guard()
return json.dumps(
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
"action": "speed", "percent": speed,
}),
ensure_ascii=False,
)
@server.tool()
def steer_fan(host: str, fan: float) -> str:
"""Teilekühlung 0–100."""
_write_guard()
return json.dumps(
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
"action": "fan", "percent": fan,
}),
ensure_ascii=False,
)
@server.resource("grotti://version")
def resource_version() -> str:
return json.dumps(_api("GET", "/version"), ensure_ascii=False, indent=2)
server.run()
if __name__ == "__main__":
main()
+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 "",
}
+305
View File
@@ -0,0 +1,305 @@
CREATE TABLE IF NOT EXISTS projects (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
notes text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'idee',
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS spools (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
brand text NOT NULL DEFAULT '',
material_type text NOT NULL DEFAULT '',
color text NOT NULL DEFAULT '',
price_per_kg double precision NOT NULL DEFAULT 0,
density double precision NOT NULL DEFAULT 1.24,
volumetric double precision NOT NULL DEFAULT 12,
remaining_g double precision NOT NULL DEFAULT 1000,
printer_host text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS files (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
name text NOT NULL,
stored_path text NOT NULL,
source text NOT NULL,
kind text NOT NULL,
printer_family text NOT NULL DEFAULT '',
filaments jsonb NOT NULL DEFAULT '[]'::jsonb,
weight_g double precision NOT NULL DEFAULT 0,
estimated_s double precision NOT NULL DEFAULT 0,
thumb_entry text NOT NULL DEFAULT '',
plate text NOT NULL DEFAULT '',
size_bytes bigint NOT NULL DEFAULT 0,
mtime double precision NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (source, stored_path)
);
ALTER TABLE files ADD COLUMN IF NOT EXISTS slice_meta jsonb NOT NULL DEFAULT '{}'::jsonb;
CREATE TABLE IF NOT EXISTS sends (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
printer_host text NOT NULL,
filename text NOT NULL,
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE sends ADD COLUMN IF NOT EXISTS ace_map jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE sends ADD COLUMN IF NOT EXISTS bed_leveling boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS runs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
printer_host text NOT NULL,
printer_name text NOT NULL DEFAULT '',
filename text NOT NULL DEFAULT '',
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
state text NOT NULL DEFAULT '',
started_at timestamptz,
ended_at timestamptz,
duration_s double precision NOT NULL DEFAULT 0,
weight_g double precision NOT NULL DEFAULT 0,
filament_cost double precision NOT NULL DEFAULT 0,
energy_cost double precision NOT NULL DEFAULT 0,
moonraker_job_id text NOT NULL,
deducted boolean NOT NULL DEFAULT false,
needs_spool boolean NOT NULL DEFAULT false,
verdict text NOT NULL DEFAULT '',
material_type text NOT NULL DEFAULT '',
UNIQUE (printer_host, moonraker_job_id)
);
CREATE INDEX IF NOT EXISTS files_project ON files (project_id);
CREATE INDEX IF NOT EXISTS runs_started ON runs (started_at DESC);
CREATE TABLE IF NOT EXISTS printers (
id uuid PRIMARY KEY,
name text NOT NULL,
host text NOT NULL UNIQUE,
family text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE printers ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS ideas (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
body text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'offen',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS materials (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
brand text NOT NULL DEFAULT '',
material_type text NOT NULL DEFAULT '',
density double precision NOT NULL,
volumetric double precision NOT NULL,
volumetric_s1 double precision,
machines text NOT NULL DEFAULT 'beide',
price_per_kg double precision NOT NULL DEFAULT 0,
note text NOT NULL DEFAULT ''
);
ALTER TABLE spools ADD COLUMN IF NOT EXISTS colors jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS ace_slot smallint;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS material_id bigint REFERENCES materials (id) ON DELETE SET NULL;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS chargen_nr text;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS hersteller_charge text NOT NULL DEFAULT '';
ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text;
CREATE UNIQUE INDEX IF NOT EXISTS spools_chargen_nr ON spools (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
CREATE UNIQUE INDEX IF NOT EXISTS spools_code ON spools (code) WHERE code IS NOT NULL AND code <> '';
-- Ein ACE-Platz gehört höchstens einer Spule.
CREATE UNIQUE INDEX IF NOT EXISTS spools_host_slot
ON spools (printer_host, ace_slot)
WHERE printer_host <> '' AND ace_slot IS NOT NULL;
CREATE TABLE IF NOT EXISTS events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
host text NOT NULL DEFAULT '',
printer text NOT NULL DEFAULT '',
kind text NOT NULL,
message text NOT NULL DEFAULT '',
filename text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
read_at timestamptz
);
CREATE INDEX IF NOT EXISTS events_unread ON events (read_at, created_at DESC);
CREATE TABLE IF NOT EXISTS print_queue (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
file_id bigint NOT NULL REFERENCES files (id) ON DELETE CASCADE,
host text NOT NULL DEFAULT '',
family text NOT NULL DEFAULT '',
priority integer NOT NULL DEFAULT 100,
status text NOT NULL DEFAULT 'waiting',
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
ace_map jsonb NOT NULL DEFAULT '[]'::jsonb,
bed_leveling boolean NOT NULL DEFAULT false,
override_machine boolean NOT NULL DEFAULT false,
message text NOT NULL DEFAULT '',
steps jsonb NOT NULL DEFAULT '[]'::jsonb,
step_index integer NOT NULL DEFAULT 0,
repeat_total integer NOT NULL DEFAULT 1,
repeat_done integer NOT NULL DEFAULT 0,
phase text NOT NULL DEFAULT '',
phase_started_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS steps jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS step_index integer NOT NULL DEFAULT 0;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_total integer NOT NULL DEFAULT 1;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_done integer NOT NULL DEFAULT 0;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase text NOT NULL DEFAULT '';
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase_started_at timestamptz;
CREATE INDEX IF NOT EXISTS print_queue_waiting ON print_queue (status, priority, id);
CREATE INDEX IF NOT EXISTS print_queue_running_host ON print_queue (host, status) WHERE status = 'running';
-- Nach dem Druck: Platte manuell quittieren, bevor „wenn frei“ den nächsten Job startet.
CREATE TABLE IF NOT EXISTS bed_status (
host text PRIMARY KEY,
needs_clear boolean NOT NULL DEFAULT false,
cleared_at timestamptz,
message text NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username text NOT NULL UNIQUE,
password_hash text NOT NULL,
role text NOT NULL DEFAULT 'operator',
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
last_login timestamptz
);
CREATE TABLE IF NOT EXISTS api_tokens (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name text NOT NULL DEFAULT '',
token_hash text NOT NULL UNIQUE,
token_prefix text NOT NULL DEFAULT '',
role text NOT NULL DEFAULT 'operator',
created_at timestamptz NOT NULL DEFAULT now(),
last_used timestamptz,
revoked_at timestamptz,
expires_at timestamptz
);
CREATE INDEX IF NOT EXISTS api_tokens_user ON api_tokens (user_id) WHERE revoked_at IS NULL;
-- OIDC später: Identitäten an lokale User binden.
CREATE TABLE IF NOT EXISTS oauth_identities (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
provider text NOT NULL,
subject text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (provider, subject)
);
-- Druckbericht: Soll/Ist, Charge, Live-Fortschritt
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_planned_g double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_used_g double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_source text NOT NULL DEFAULT '';
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_used_mm double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_total_mm double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS progress_last double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS estimated_s double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS chargen_nr text;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS message text NOT NULL DEFAULT '';
ALTER TABLE runs ADD COLUMN IF NOT EXISTS objects jsonb NOT NULL DEFAULT '[]'::jsonb;
CREATE UNIQUE INDEX IF NOT EXISTS runs_chargen_nr
ON runs (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
ALTER TABLE events ADD COLUMN IF NOT EXISTS run_id bigint REFERENCES runs (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS events_run ON events (run_id) WHERE run_id IS NOT NULL;
-- Abzug je Spule (Messwert oder Schätzwert)
CREATE TABLE IF NOT EXISTS run_spools (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
ace_slot smallint,
weight_g double precision NOT NULL DEFAULT 0,
weight_planned_g double precision NOT NULL DEFAULT 0,
cost double precision NOT NULL DEFAULT 0,
is_estimate boolean NOT NULL DEFAULT false,
UNIQUE (run_id, spool_id, ace_slot)
);
CREATE INDEX IF NOT EXISTS run_spools_spool ON run_spools (spool_id);
CREATE INDEX IF NOT EXISTS run_spools_run ON run_spools (run_id);
-- Abschnitte bei Rollenwechsel mitten im Druck
CREATE TABLE IF NOT EXISTS run_segments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
ace_slot smallint,
mm_start double precision NOT NULL DEFAULT 0,
mm_end double precision,
created_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz
);
CREATE INDEX IF NOT EXISTS run_segments_run ON run_segments (run_id);
CREATE INDEX IF NOT EXISTS run_segments_open
ON run_segments (run_id) WHERE mm_end IS NULL;
-- Temperatur / Fortschritt während des Drucks
CREATE TABLE IF NOT EXISTS run_samples (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
sampled_at timestamptz NOT NULL DEFAULT now(),
nozzle double precision,
nozzle_target double precision,
bed double precision,
bed_target double precision,
chamber double precision,
fan double precision,
speed_factor double precision,
extrude_factor double precision,
layer integer,
progress double precision,
filament_used_mm double precision,
mmu_tool smallint,
nozzle_power double precision,
bed_power double precision
);
CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at);
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS nozzle_power double precision;
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS bed_power double precision;
-- Objekt-Chargen für Etiketten
CREATE TABLE IF NOT EXISTS run_labels (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
object_name text NOT NULL DEFAULT '',
chargen_nr text NOT NULL,
excluded boolean NOT NULL DEFAULT false,
UNIQUE (run_id, object_name)
);
CREATE UNIQUE INDEX IF NOT EXISTS run_labels_chargen ON run_labels (chargen_nr);
+6
View File
@@ -0,0 +1,6 @@
-- Heiße Abfragepfade für Sends, Läufe, Dateien und Ereignisse.
CREATE INDEX IF NOT EXISTS sends_host_filename ON sends (printer_host, filename);
CREATE INDEX IF NOT EXISTS sends_host_file ON sends (printer_host, file_id);
CREATE INDEX IF NOT EXISTS runs_host_state ON runs (printer_host, state);
CREATE INDEX IF NOT EXISTS files_name ON files (name);
CREATE INDEX IF NOT EXISTS events_host_kind_created ON events (host, kind, created_at DESC);
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE runs ADD COLUMN IF NOT EXISTS verdict_reason text NOT NULL DEFAULT '';
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_ok int NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_bad int NOT NULL DEFAULT 0;
+25
View File
@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS maintenance_parts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
printer_host text NOT NULL,
part_key text NOT NULL,
label text NOT NULL DEFAULT '',
installed_at timestamptz NOT NULL DEFAULT now(),
life_hours double precision NOT NULL DEFAULT 0,
life_meters double precision NOT NULL DEFAULT 0,
note text NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS maintenance_parts_host ON maintenance_parts (printer_host);
CREATE TABLE IF NOT EXISTS maintenance_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
printer_host text NOT NULL,
part_key text NOT NULL,
event text NOT NULL DEFAULT '',
hours_at double precision NOT NULL DEFAULT 0,
meters_at double precision NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
note text NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS maintenance_log_host ON maintenance_log (printer_host, created_at DESC);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE printers ADD COLUMN IF NOT EXISTS start_window jsonb NOT NULL DEFAULT '{}'::jsonb;
+63
View File
@@ -0,0 +1,63 @@
CREATE TABLE IF NOT EXISTS customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
email text NOT NULL DEFAULT '',
notes text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint REFERENCES customers (id) ON DELETE SET NULL,
title text NOT NULL,
status text NOT NULL DEFAULT 'idee',
due_at date,
notes text NOT NULL DEFAULT '',
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
qty int NOT NULL DEFAULT 1,
unit_price double precision NOT NULL DEFAULT 0,
material_type text NOT NULL DEFAULT '',
color text NOT NULL DEFAULT '',
done_ok int NOT NULL DEFAULT 0,
done_bad int NOT NULL DEFAULT 0,
note text NOT NULL DEFAULT ''
);
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
INSERT INTO orders (title, status, notes, quote, created_at)
SELECT
p.name,
CASE p.status
WHEN 'bereit' THEN 'angebot'
WHEN 'druckt' THEN 'druckt'
WHEN 'fertig' THEN 'fertig'
ELSE 'idee'
END,
p.notes,
p.quote,
p.created_at
FROM projects p
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.title = p.name AND o.created_at = p.created_at);
INSERT INTO orders (title, status, notes, created_at)
SELECT
i.title,
CASE i.status WHEN 'erledigt' THEN 'fertig' ELSE 'idee' END,
i.body,
i.created_at
FROM ideas i
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.title = i.title AND o.created_at = i.created_at);
CREATE INDEX IF NOT EXISTS orders_status ON orders (status, updated_at DESC);
CREATE INDEX IF NOT EXISTS order_items_order ON order_items (order_id);
CREATE INDEX IF NOT EXISTS runs_order_item ON runs (order_item_id) WHERE order_item_id IS NOT NULL;
+3
View File
@@ -0,0 +1,3 @@
-- Auto-Retry und Routing-Hinweise in der Warteschlange.
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS retry_count integer NOT NULL DEFAULT 0;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS wait_reason text NOT NULL DEFAULT '';
+8
View File
@@ -0,0 +1,8 @@
-- PA12-CF bleibt in Grotti die echte Sorte. Der ACE kennt nur PA6-CF.
UPDATE materials
SET material_type = 'PA12-CF'
WHERE name = 'Sunlu PA12-CF' AND material_type = 'PA';
UPDATE spools
SET material_type = 'PA12-CF'
WHERE name = 'Sunlu PA12-CF' AND material_type = 'PA';
+2
View File
@@ -0,0 +1,2 @@
-- Leere/alte Spulen aus der Alltagsliste nehmen
ALTER TABLE spools ADD COLUMN IF NOT EXISTS archived_at timestamptz;
+2
View File
@@ -0,0 +1,2 @@
-- Projekt-Arbeitsmappe: Relativordner unter dem Slice-Pfad
ALTER TABLE projects ADD COLUMN IF NOT EXISTS folder text NOT NULL DEFAULT '';
+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);
+387 -40
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 = {}
@@ -17,6 +20,16 @@ SLOT_CACHE = {}
MESH_CACHE = {}
OBJECT_CACHE = {}
THUMB_RE_LIMIT = 180_000
_CACHE_MAX = 128
def _trim_cache(cache, max_items=_CACHE_MAX):
"""Einfache Größenbegrenzung (älteste Keys zuerst bei dict-Reihenfolge)."""
while len(cache) > max_items:
try:
cache.pop(next(iter(cache)))
except StopIteration:
break
def matrix_rows(points):
@@ -50,31 +63,6 @@ def _raw_matrix(points):
return []
def fetch_bed_mesh(host):
"""Roh-Bettnetz von Moonraker (ohne UI-Spiegelung)."""
url = "http://%s/printer/objects/query?bed_mesh" % host
payload = fetch_json(url, timeout=10)
status = (payload.get("result") or {}).get("status") or {}
mesh = status.get("bed_mesh") or {}
matrix = _raw_matrix(mesh.get("probed_matrix"))
if not matrix:
matrix = _raw_matrix(mesh.get("mesh_matrix"))
mesh_min = mesh.get("mesh_min")
mesh_max = mesh.get("mesh_max")
if not (isinstance(mesh_min, (list, tuple)) and len(mesh_min) >= 2):
mesh_min = None
if not (isinstance(mesh_max, (list, tuple)) and len(mesh_max) >= 2):
mesh_max = None
if not matrix or mesh_min is None or mesh_max is None:
return None
return {
"matrix": matrix,
"mesh_min": [float(mesh_min[0]), float(mesh_min[1])],
"mesh_max": [float(mesh_max[0]), float(mesh_max[1])],
"profile": mesh.get("profile_name") or "",
}
def store_mesh(host, shown, profile=""):
if not shown:
return
@@ -118,11 +106,6 @@ class PrinterError(Exception):
self.message = message
def is_timeout(exc):
text = (getattr(exc, "message", None) or str(exc) or "").lower()
return "zu lange nicht geantwortet" in text or "timed out" in text
def fetch_json(url, timeout=4):
request = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -165,18 +148,53 @@ def post_json(host, path, payload, timeout=20):
raise PrinterError("Der Drucker ist nicht erreichbar.") from exc
def safe_upload_name(filename):
"""Dateiname für Moonraker/Rinkhals/gklib — nur ASCII.
`filename` vs. `filename*` mit CJK führte zu Upload unter `_` und Start
unter dem Originalnamen → Fehler 10115 „gcode … not found“.
"""
raw = (filename or "").replace("\\", "/")
parts = [part for part in raw.split("/") if part]
base = parts[-1] if parts else "job.gcode"
out = []
for ch in base:
if ch.isascii() and ch not in '"\\':
out.append(ch if ch not in " \t" else "_")
else:
out.append("_")
text = "".join(out)
while "__" in text:
text = text.replace("__", "_")
while "_-" in text or "-_" in text:
text = text.replace("_-", "-").replace("-_", "-")
text = text.strip("._-") or "job"
lower = text.lower()
if not (lower.endswith(".gcode") or lower.endswith(".gcode.3mf") or lower.endswith(".3mf") or lower.endswith(".acm")):
# Suffix vom Original behalten, falls nur der Stamm zerstört wurde
if base.lower().endswith(".gcode.3mf"):
text += ".gcode.3mf"
elif base.lower().endswith(".gcode"):
text += ".gcode"
elif base.lower().endswith(".acm"):
text += ".acm"
if parts:
parts[-1] = text
return "/".join(parts)
return text
def multipart(filename, content):
boundary = "----GrottiBoundary7f3a"
ascii_name = "".join(ch if ch.isascii() and ch not in '"\\' else "_" for ch in filename) or "job.gcode"
quoted = urllib.parse.quote(filename)
name = safe_upload_name(filename)
fields = (
"--%s\r\nContent-Disposition: form-data; name=\"root\"\r\n\r\ngcodes\r\n" % boundary
).encode("ascii")
head = (
"--%s\r\n"
"Content-Disposition: form-data; name=\"file\"; filename=\"%s\"; filename*=UTF-8''%s\r\n"
"Content-Disposition: form-data; name=\"file\"; filename=\"%s\"\r\n"
"Content-Type: application/octet-stream\r\n\r\n"
) % (boundary, ascii_name, quoted)
) % (boundary, name)
tail = ("\r\n--%s--\r\n" % boundary).encode("ascii")
return boundary, fields + head.encode("ascii") + content + tail
@@ -217,6 +235,7 @@ def upload_timeout(size):
def upload(host, filename, content, on_progress=None):
filename = safe_upload_name(filename)
boundary, body = multipart(filename, content)
stream = _UploadBody(body, on_progress=on_progress)
request = urllib.request.Request(
@@ -236,7 +255,8 @@ def upload(host, filename, content, on_progress=None):
except (urllib.error.URLError, TimeoutError) as exc:
raise PrinterError("Hochladen ist fehlgeschlagen.") from exc
item = (payload.get("result") or {}).get("item") or {}
return item.get("path") or filename
# Immer denselben ASCII-Namen nutzen, den wir hochgeladen haben
return safe_upload_name(item.get("path") or filename)
def start_print(host, filename, bed_leveling=False, ams_settings=None):
@@ -246,12 +266,14 @@ def start_print(host, filename, bed_leveling=False, ams_settings=None):
MQTT selbst (auto_leveling aus App-Property; ams_settings aus .acm /
Identity-Fallback). Bei bed_leveling oder gesetzter Grotti-ams_settings
startet Grotti per MQTTS :9883 und legt die Map explizit in die Payload.
Vor dem reinen Moonraker-Pfad: best-effort ensure_task_free — Rinkhals
wandelt Start oft intern zu MQTT um; hängende gkapi-Tasks (10101) blocken.
"""
use_mqtt = bool(bed_leveling) or bool(ams_settings)
if not use_mqtt:
return post_json(host, "/printer/print/start", {"filename": filename})
from grotti import anycubic_lan
if use_mqtt:
try:
return anycubic_lan.start_print(
host,
@@ -262,6 +284,12 @@ def start_print(host, filename, bed_leveling=False, ams_settings=None):
except anycubic_lan.LanError as exc:
raise PrinterError(exc.message) from exc
try:
anycubic_lan.ensure_task_free(host)
except Exception:
pass
return post_json(host, "/printer/print/start", {"filename": filename})
def command(host, action):
path = {
@@ -328,6 +356,11 @@ def steuern(host, action, params=None):
"""Whitelist-Steuerung ohne freie Konsole."""
params = params if isinstance(params, dict) else {}
action = (action or "").strip()
# MCP und ältere Clients schicken "value" statt temp/percent.
if "temp" not in params and params.get("value") is not None and action in ("nozzle", "bed", "dryer_start"):
params = {**params, "temp": params["value"]}
if "percent" not in params and params.get("value") is not None and action in ("speed", "flow", "fan"):
params = {**params, "percent": params["value"]}
def pct(key, default=100, lo=0, hi=200):
try:
@@ -373,6 +406,64 @@ def steuern(host, action, params=None):
if not re.fullmatch(r"[A-Za-z0-9._+\-]+", name):
raise PrinterError("Ungültiger Objektname.")
return gcode_script(host, "EXCLUDE_OBJECT NAME=%s" % name, timeout=20)
if action == "dryer_start":
try:
unit = int(params.get("id"))
except (TypeError, ValueError) as exc:
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
if unit not in (0, 1):
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
try:
temp = int(round(float(params.get("temp"))))
duration = int(round(float(params.get("duration"))))
except (TypeError, ValueError) as exc:
raise PrinterError("Temperatur und Dauer müssen Zahlen sein.") from exc
if temp < 35 or temp > 65:
raise PrinterError("Trockner-Temperatur außerhalb 35–65 °C.")
if duration < 1 or duration > 720:
raise PrinterError("Trockner-Dauer außerhalb 1–720 Minuten.")
return post_json(
host,
"/server/filament_hub/start_drying",
{"id": unit, "temp": temp, "duration": duration, "fan_speed": 0},
timeout=15,
)
if action == "dryer_stop":
try:
unit = int(params.get("id"))
except (TypeError, ValueError) as exc:
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
if unit not in (0, 1):
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
return post_json(host, "/server/filament_hub/stop_drying", {"id": unit}, timeout=15)
if action == "slot_setzen":
hub_id = _int_or_none(params.get("id"))
index = _int_or_none(params.get("index"))
if hub_id not in (0, 1) or index is None or index < 0 or index > 3:
raise PrinterError("ACE-Platz ungültig.")
material = str(params.get("type") or "").strip()
if not material:
raise PrinterError("Die Spule braucht eine Sorte.")
color = params.get("color")
if not isinstance(color, dict):
raise PrinterError("Die Spule braucht eine Farbe.")
try:
red = max(0, min(255, int(color.get("R"))))
green = max(0, min(255, int(color.get("G"))))
blue = max(0, min(255, int(color.get("B"))))
except (TypeError, ValueError) as exc:
raise PrinterError("Die Spule braucht eine Farbe.") from exc
return post_json(
host,
"/printer/filament_hub/set_filament_info",
{
"id": hub_id,
"index": index,
"type": material,
"color": {"R": red, "G": green, "B": blue},
},
timeout=15,
)
if action == "firmware_restart":
# GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32).
raise PrinterError(
@@ -419,6 +510,7 @@ def metadata(host, path):
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
data = {}
META_CACHE[key] = (now, data)
_trim_cache(META_CACHE)
return data
@@ -445,9 +537,11 @@ def embedded_preview(host, path):
text = response.read().decode("utf-8", "replace")
except (urllib.error.URLError, TimeoutError):
EMBEDDED[key] = None
_trim_cache(EMBEDDED)
return None
png = embedded_png(text)
EMBEDDED[key] = png
_trim_cache(EMBEDDED)
return png
@@ -555,6 +649,80 @@ def extract_slots(status):
return found[:8]
def _int_or_none(value):
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return int(value)
def extract_dryers(status):
"""Echte ACE-Boxen aus mmu_machine (num_gates > 0), Dummy-Unit bleibt weg."""
machine = (status or {}).get("mmu_machine")
if not isinstance(machine, dict):
return []
found = []
for unit_id in (0, 1):
unit = machine.get("unit_%d" % unit_id)
if not isinstance(unit, dict):
continue
gates = _int_or_none(unit.get("num_gates"))
if gates is None or gates < 1:
continue
found.append({
"id": unit_id,
"status": str(unit.get("dryer_status") or "stop"),
"temp": _int_or_none(unit.get("dryer_temp")) or 0,
"target": _int_or_none(unit.get("dryer_target_temp")) or 0,
"remain": _int_or_none(unit.get("dryer_remaining")) or 0,
"humidity": _int_or_none(unit.get("dryer_humidity")) or 0,
"gates": gates,
})
return found
def ace_hub_index(ace_slot):
"""Grotti-Platz 1–8 → Moonraker id (0/1) und index in der Box (0–3)."""
try:
slot = int(ace_slot)
except (TypeError, ValueError) as exc:
raise PrinterError("ACE-Platz muss 1–8 sein.") from exc
if slot < 1 or slot > 8:
raise PrinterError("ACE-Platz muss 1–8 sein.")
return (slot - 1) // 4, (slot - 1) % 4
def spool_rgb(spool):
"""Erste Farbe der Spule als RGB-Dict für set_filament_info."""
colors = (spool or {}).get("colors") or []
if isinstance(colors, str):
try:
colors = json.loads(colors)
except (json.JSONDecodeError, TypeError):
colors = []
hex_value = ""
if isinstance(colors, list):
for item in colors:
if isinstance(item, dict) and item.get("hex"):
hex_value = str(item["hex"]).strip()
break
if isinstance(item, str) and item.strip():
hex_value = item.strip()
break
if not hex_value:
hex_value = str((spool or {}).get("color") or "").strip()
if hex_value.startswith("#"):
hex_value = hex_value[1:]
if len(hex_value) == 8:
hex_value = hex_value[:6]
if len(hex_value) != 6 or any(ch not in "0123456789abcdefABCDEF" for ch in hex_value):
return None
return {
"R": int(hex_value[0:2], 16),
"G": int(hex_value[2:4], 16),
"B": int(hex_value[4:6], 16),
}
def slots(host, status=None):
if status is not None:
found = extract_slots(status)
@@ -599,6 +767,16 @@ def fan_values(status):
return speed, rpm
def _heater_power(entry):
"""Klipper-Einschaltdauer 0…1 (ED), sonst None."""
if not isinstance(entry, dict):
return None
power = entry.get("power")
if isinstance(power, (int, float)) and not isinstance(power, bool):
return max(0.0, min(1.0, float(power)))
return None
def _object_names(host):
now = time.time()
cached = OBJECT_CACHE.get(host)
@@ -714,6 +892,99 @@ def enrich_error_message(host, stats, status):
return " · ".join(bits) if bits else (raw or "Unbekannter Fehler — in Mainsail prüfen")
_FILAMENT_HINT_RE = re.compile(
r"filament|runout|out of fil|no fil|fil.?empty|10107|"
r"material.?leer|spule.?leer|filament.?gerissen|filament.?fehlt|"
r"filament.?sensor|out of material",
re.I,
)
def last_filament_gcode_hint(host, limit=40):
"""Letzte G-code-Zeile, die auf Filament-Ende / Runout hindeutet."""
try:
payload = fetch_json("http://%s/server/gcode_store?count=%d" % (host, int(limit)), timeout=3)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError):
return ""
items = ((payload.get("result") or {}).get("gcode_store") or [])
for item in reversed(items):
if not isinstance(item, dict):
continue
text = (item.get("message") or "").strip()
if not text or not _FILAMENT_HINT_RE.search(text):
continue
low = text.lower()
if "10107" in low:
return "Filament gerissen oder leer (10107)"
return text[:160]
return ""
def _mmu_tool_index(mmu):
"""Aktives ACE/MMU-Tool, sonst None. −1 / fehlend = kein Tool (Anycubic-Normalfall)."""
if not isinstance(mmu, dict):
return None
tool = mmu.get("tool")
if not isinstance(tool, (int, float)) or isinstance(tool, bool):
tool = mmu.get("gate")
if not isinstance(tool, (int, float)) or isinstance(tool, bool):
return None
tool_i = int(tool)
return tool_i if tool_i >= 0 else None
def filament_out_hint(mmu, message="", host=None):
"""Vermutetes Material-Ende bei Pause: (code, Text) oder (None, None).
Quellen: print_stats.message, mmu.reason_for_pause, gate_status bei aktivem Tool,
active_filament.empty nur mit aktivem Tool, optional G-code-Store (host).
Nicht: active_filament.empty allein — Anycubic ACE meldet empty=true auch
beim normalen Druck und bei manueller Pause (tool/gate = −1).
"""
text = (message or "").strip()
if text and _FILAMENT_HINT_RE.search(text):
if "10107" in text.lower():
return "message", "Filament gerissen oder leer (10107)"
return "message", text[:160]
if isinstance(mmu, dict) and mmu:
rfp = (mmu.get("reason_for_pause") or "").strip()
if rfp and _FILAMENT_HINT_RE.search(rfp):
if "10107" in rfp.lower():
return "reason", "Filament gerissen oder leer (10107)"
return "reason", rfp[:160]
tool_i = _mmu_tool_index(mmu)
states = mmu.get("gate_status")
# gate_status==0 nur, wenn Status differenziert (nicht alle 0 = Anycubic-Unknown)
if (
tool_i is not None
and isinstance(states, list)
and tool_i < len(states)
and int(states[tool_i] or 0) == 0
and any(int(s or 0) != 0 for s in states)
):
return "gate_empty", "ACE-Platz %d leer" % (tool_i + 1)
active = mmu.get("active_filament")
if (
tool_i is not None
and isinstance(active, dict)
and (
active.get("empty") is True
or str(active.get("empty") or "").lower() in ("1", "true")
)
):
return "active_empty", "Aktives Filament leer (ACE)"
if host:
hint = last_filament_gcode_hint(host)
if hint:
return "gcode", hint
return None, None
def exclude_status(host):
"""Klipper [exclude_object] — leer, wenn Modul fehlt oder Query scheitert."""
empty = {"objects": [], "excluded": [], "current": None}
@@ -823,6 +1094,8 @@ def query(host):
extras = []
if "probe" in names:
extras.append("probe")
if "mmu_machine" in names:
extras.append("mmu_machine")
url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras))
payload = fetch_json(url)
status = payload["result"]["status"]
@@ -830,6 +1103,21 @@ def query(host):
card = status.get("virtual_sdcard") or {}
extruder = status.get("extruder") or {}
bed = status.get("heater_bed") or {}
mmu = status.get("mmu") or {}
mmu_tool = mmu.get("tool")
if not isinstance(mmu_tool, (int, float)) or isinstance(mmu_tool, bool):
mmu_tool = mmu.get("gate")
if isinstance(mmu_tool, (int, float)) and not isinstance(mmu_tool, bool):
mmu_tool = int(mmu_tool)
if mmu_tool < 0:
mmu_tool = None
else:
mmu_tool = None
filament_used = stats.get("filament_used")
if not isinstance(filament_used, (int, float)) or isinstance(filament_used, bool):
filament_used = None
else:
filament_used = float(filament_used)
raw_name = stats.get("filename") or ""
filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else ""
meta = metadata(host, raw_name) if raw_name else {}
@@ -863,6 +1151,15 @@ def query(host):
message = stats.get("message") or ""
if (state or "").lower() == "error":
message = enrich_error_message(host, stats, status)
pause_code, pause_reason = None, ""
filament_empty = False
if (state or "").lower() == "paused":
# G-code-Store nur bei Pause abfragen — sonst unnötige Last.
pause_code, pause_reason = filament_out_hint(mmu, message, host=host)
filament_empty = bool(pause_code)
if filament_empty and pause_reason:
if not message or message.lower() in ("paused", "pause", "pausiert"):
message = pause_reason
toolhead = status.get("toolhead") or {}
axis_max = toolhead.get("axis_maximum") or []
z_max = None
@@ -872,6 +1169,8 @@ def query(host):
"ok": True,
"state": state,
"message": message,
"filament_empty": filament_empty,
"pause_reason": pause_reason or "",
"homed_axes": toolhead.get("homed_axes") or "",
"position": toolhead.get("position") or [],
"z_max": z_max,
@@ -884,12 +1183,21 @@ def query(host):
"elapsed": stats.get("print_duration") or 0,
"nozzle": extruder.get("temperature"),
"nozzle_target": extruder.get("target"),
"nozzle_power": _heater_power(extruder),
"bed": bed.get("temperature"),
"bed_target": bed.get("target"),
"bed_power": _heater_power(bed),
"chamber": chamber,
"chamber_target": chamber_target,
"filament_used": filament_used,
"filament_total": meta.get("filament_total") or 0,
"mmu_tool": mmu_tool,
"material": card.get("filament_type") or meta.get("filament_type") or "",
"weight": meta.get("filament_weight_total") or 0,
"weight": (
sum(float(x or 0) for x in meta["filament_weight_total"])
if isinstance(meta.get("filament_weight_total"), list)
else (meta.get("filament_weight_total") or 0)
),
"height": meta.get("object_height") or 0,
"layer_height": meta.get("layer_height") or card.get("layer_height") or 0,
"estimated": meta.get("estimated_time") or 0,
@@ -899,7 +1207,7 @@ def query(host):
"/api/v1/vorschau?host=%s&embedded=%s" % (quoted_host, urllib.parse.quote(raw_name))
if raw_name and EMBEDDED.get((host, raw_name)) else ""
),
"camera": "/api/v1/kamera?host=%s" % quoted_host,
**_camera_fields(host, quoted_host),
**mesh_fields(host, shown, mesh_profile),
"fan": fan_speed,
"fan_rpm": fan_rpm,
@@ -907,10 +1215,22 @@ def query(host):
"speed_factor": speed_factor,
"extrude_factor": extrude_factor,
"slots": slots(host, status),
"dryers": extract_dryers(status),
"exclude": exclude_status(host),
"anycubic_task": _anycubic_task_field(host),
}
def _anycubic_task_field(host):
"""gkapi-Task aus print_info (TTL-Cache in anycubic_lan), best-effort."""
try:
from grotti import anycubic_lan
return anycubic_lan.anycubic_task(host)
except Exception:
return None
def snapshot(printers):
def one(printer):
card = {
@@ -922,11 +1242,17 @@ def snapshot(printers):
}
try:
card.update(query(printer["host"]))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as error:
except Exception as error:
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
log.warning("snapshot %s: %s", printer.get("host"), error)
card.update({
"ok": False,
"state": "offline",
"message": str(error),
"filament_empty": False,
"pause_reason": "",
"camera": "",
"has_camera": False,
"fan": None,
"fan_rpm": None,
"fans": {"part": None, "chamber": None, "aux": None},
@@ -935,6 +1261,8 @@ def snapshot(printers):
"speed_factor": None,
"extrude_factor": None,
"slots": [],
"dryers": [],
"anycubic_task": None,
})
card.update(mesh_fields(printer["host"]))
return card
@@ -966,5 +1294,24 @@ def camera_image(host):
return CAMERA_CACHE[host][2]
def open_camera_stream(host):
"""Öffnet den MJPEG-Stream am Drucker. Aufrufer schließt die Response."""
url = "http://%s/webcam/?action=stream" % host
request = urllib.request.Request(url)
return urllib.request.urlopen(request, timeout=30)
def _camera_fields(host, quoted_host):
"""Kamera-URL nur, wenn nicht als tot bekannt (Cache-Miss = erst versuchen)."""
snap = "/api/v1/kamera?host=%s" % quoted_host
stream = "/api/v1/kamera/stream?host=%s" % quoted_host
cached = CAMERA_CACHE.get(host)
if cached is not None and cached[2] is None:
return {"camera": "", "camera_stream": "", "has_camera": False}
if cached is not None and cached[2] is not None:
return {"camera": snap, "camera_stream": stream, "has_camera": True}
return {"camera": snap, "camera_stream": stream, "has_camera": None}
def embedded_bytes(host, name):
return EMBEDDED.get((host, name))
+53 -6
View File
@@ -1,8 +1,9 @@
"""Telegram-Benachrichtigungen (Bot API)."""
"""Telegram- und ntfy-Benachrichtigungen."""
from __future__ import annotations
import json
import threading
import urllib.error
import urllib.parse
import urllib.request
@@ -33,7 +34,12 @@ def send_telegram(cfg, text):
return False
ALERT_GROUP = {"abbruch": "fehler"}
ALERT_GROUP = {
"abbruch": "fehler",
"bedarf": "queue",
"wartung": "queue",
"auftrag": "queue",
}
DEFAULT_OFF = {"ablauf", "start"}
@@ -43,11 +49,52 @@ def alert_enabled(cfg, kind):
return bool(alerts.get(key, key not in DEFAULT_OFF))
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60):
"""Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster."""
def ntfy_configured(cfg):
ntfy = (cfg or {}).get("ntfy") or {}
return bool(ntfy.get("enabled") and ntfy.get("topic"))
def send_ntfy(cfg, text):
if not ntfy_configured(cfg):
return False
ntfy = cfg["ntfy"]
server = str(ntfy.get("server") or "https://ntfy.sh").rstrip("/")
topic = str(ntfy.get("topic") or "").strip()
if not topic:
return False
url = "%s/%s" % (server, urllib.parse.quote(topic, safe=""))
headers = {"Title": "Grotti", "Priority": "default"}
token = str(ntfy.get("token") or "").strip()
if token:
headers["Authorization"] = "Bearer %s" % token
request = urllib.request.Request(
url,
data=text[:4000].encode("utf-8"),
method="POST",
headers=headers,
)
try:
with urllib.request.urlopen(request, timeout=12) as response:
return 200 <= response.status < 300
except (urllib.error.URLError, TimeoutError, OSError):
return False
def _push_alerts(cfg, message):
send_telegram(cfg, message)
send_ntfy(cfg, message)
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60, run_id=None):
"""Ereignis immer speichern; Telegram/ntfy nur einmal pro Art und Drucker im Zeitfenster."""
from grotti import store
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s)
store.add_event(conn, host, printer, kind, message, filename or "")
store.add_event(conn, host, printer, kind, message, filename or "", run_id=run_id)
if not recent and alert_enabled(cfg, kind):
send_telegram(cfg, message)
threading.Thread(
target=_push_alerts,
args=(cfg, message),
name="grotti-notify",
daemon=True,
).start()
+460
View File
@@ -0,0 +1,460 @@
"""Aufträge, Kunden und Positionen."""
from __future__ import annotations
from datetime import date
from psycopg.types.json import Json
from grotti import store
ORDER_STATUSES = frozenset({
"idee",
"angebot",
"bestaetigt",
"druckt",
"fertig",
"geliefert",
"bezahlt",
})
PROJECT_TO_ORDER = {
"idee": "idee",
"bereit": "angebot",
"druckt": "druckt",
"fertig": "fertig",
}
IDEA_TO_ORDER = {
"offen": "idee",
"spaeter": "idee",
"erledigt": "fertig",
}
def normalize_status(value):
s = (value or "idee").strip().lower()
if s not in ORDER_STATUSES:
raise ValueError("Unbekannter Auftragsstatus.")
return s
def customer_out(row):
return {
"id": row["id"],
"name": row["name"],
"email": row.get("email") or "",
"notes": row.get("notes") or "",
"created_at": store.iso(row["created_at"]),
}
def order_item_out(row, file_name=None):
return {
"id": row["id"],
"order_id": row["order_id"],
"file_id": row.get("file_id"),
"file_name": file_name or "",
"qty": int(row.get("qty") or 1),
"unit_price": float(row.get("unit_price") or 0),
"material_type": row.get("material_type") or "",
"color": row.get("color") or "",
"done_ok": int(row.get("done_ok") or 0),
"done_bad": int(row.get("done_bad") or 0),
"note": row.get("note") or "",
}
def order_out(row, customer=None, items=None, progress=None):
data = {
"id": row["id"],
"customer_id": row.get("customer_id"),
"title": row["title"],
"status": row["status"],
"due_at": row["due_at"].isoformat() if row.get("due_at") else None,
"notes": row.get("notes") or "",
"quote": row.get("quote") or {},
"created_at": store.iso(row["created_at"]),
"updated_at": store.iso(row.get("updated_at") or row["created_at"]),
}
if customer is not None:
data["customer"] = customer
if items is not None:
data["items"] = items
if progress is not None:
data["progress"] = progress
return data
def order_progress(conn, order_id):
row = conn.execute(
"""
SELECT
COALESCE(SUM(qty), 0) AS target,
COALESCE(SUM(LEAST(done_ok, qty)), 0) AS done_ok,
COALESCE(SUM(done_bad), 0) AS done_bad
FROM order_items
WHERE order_id = %s
""",
(order_id,),
).fetchone()
target = int(row["target"] or 0)
done = int(row["done_ok"] or 0)
return {
"target": target,
"done": done,
"done_bad": int(row["done_bad"] or 0),
"label": "%d von %d" % (done, target) if target else "0 von 0",
}
def list_customers(conn):
rows = conn.execute(
"SELECT * FROM customers ORDER BY name, id"
).fetchall()
return [customer_out(r) for r in rows]
def get_customer(conn, customer_id):
row = conn.execute("SELECT * FROM customers WHERE id = %s", (customer_id,)).fetchone()
return customer_out(row) if row else None
def create_customer(conn, name, email="", notes=""):
row = conn.execute(
"""
INSERT INTO customers (name, email, notes)
VALUES (%s, %s, %s)
RETURNING *
""",
(name.strip(), (email or "").strip(), (notes or "").strip()),
).fetchone()
return customer_out(row)
def update_customer(conn, customer_id, fields):
sets = []
values = []
for key in ("name", "email", "notes"):
if key in fields:
sets.append("%s = %%s" % key)
values.append(fields[key])
if not sets:
return get_customer(conn, customer_id)
values.append(customer_id)
row = conn.execute(
"UPDATE customers SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
values,
).fetchone()
return customer_out(row) if row else None
def delete_customer(conn, customer_id):
row = conn.execute(
"DELETE FROM customers WHERE id = %s RETURNING id",
(customer_id,),
).fetchone()
return bool(row)
def list_orders(conn, status=None):
where = ""
params = []
if status:
where = "WHERE o.status = %s"
params.append(status)
rows = conn.execute(
"""
SELECT o.*, c.name AS customer_name
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
%s
ORDER BY o.updated_at DESC, o.id DESC
""" % where,
params,
).fetchall()
out = []
for row in rows:
prog = order_progress(conn, row["id"])
customer = None
if row.get("customer_id"):
customer = {"id": row["customer_id"], "name": row.get("customer_name") or ""}
out.append(order_out(row, customer=customer, progress=prog))
return out
def get_order(conn, order_id):
row = conn.execute(
"""
SELECT o.*, c.name AS customer_name, c.email AS customer_email
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.id = %s
""",
(order_id,),
).fetchone()
if not row:
return None
items_rows = conn.execute(
"""
SELECT oi.*, f.name AS file_name
FROM order_items oi
LEFT JOIN files f ON f.id = oi.file_id
WHERE oi.order_id = %s
ORDER BY oi.id
""",
(order_id,),
).fetchall()
items = [order_item_out(r, r.get("file_name")) for r in items_rows]
customer = None
if row.get("customer_id"):
customer = {
"id": row["customer_id"],
"name": row.get("customer_name") or "",
"email": row.get("customer_email") or "",
}
return order_out(
row,
customer=customer,
items=items,
progress=order_progress(conn, order_id),
)
def create_order(conn, title, status="idee", customer_id=None, due_at=None, notes="", quote=None):
status = normalize_status(status)
due = None
if due_at:
if isinstance(due_at, date):
due = due_at
else:
due = date.fromisoformat(str(due_at)[:10])
row = conn.execute(
"""
INSERT INTO orders (customer_id, title, status, due_at, notes, quote)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
""",
(
customer_id,
title.strip(),
status,
due,
(notes or "").strip(),
Json(quote if isinstance(quote, dict) else {}),
),
).fetchone()
return get_order(conn, row["id"])
def update_order(conn, order_id, fields):
sets = ["updated_at = now()"]
values = []
if "title" in fields:
sets.append("title = %s")
values.append(fields["title"])
if "status" in fields:
sets.append("status = %s")
values.append(normalize_status(fields["status"]))
if "customer_id" in fields:
sets.append("customer_id = %s")
values.append(fields["customer_id"])
if "due_at" in fields:
sets.append("due_at = %s")
due = fields["due_at"]
if due in ("", None):
values.append(None)
elif isinstance(due, date):
values.append(due)
else:
values.append(date.fromisoformat(str(due)[:10]))
if "notes" in fields:
sets.append("notes = %s")
values.append(fields["notes"] or "")
if "quote" in fields:
sets.append("quote = %s")
values.append(Json(fields["quote"] if isinstance(fields["quote"], dict) else {}))
if len(sets) <= 1:
return get_order(conn, order_id)
values.append(order_id)
row = conn.execute(
"UPDATE orders SET %s WHERE id = %%s RETURNING id" % ", ".join(sets),
values,
).fetchone()
return get_order(conn, row["id"]) if row else None
def delete_order(conn, order_id):
row = conn.execute("DELETE FROM orders WHERE id = %s RETURNING id", (order_id,)).fetchone()
return bool(row)
def create_order_item(conn, order_id, file_id=None, qty=1, unit_price=0, material_type="", color="", note=""):
if not conn.execute("SELECT 1 FROM orders WHERE id = %s", (order_id,)).fetchone():
return None
try:
qty_n = max(1, int(qty))
except (TypeError, ValueError):
qty_n = 1
row = conn.execute(
"""
INSERT INTO order_items (
order_id, file_id, qty, unit_price, material_type, color, note
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING *
""",
(
order_id,
file_id,
qty_n,
float(unit_price or 0),
(material_type or "").strip(),
(color or "").strip(),
(note or "").strip(),
),
).fetchone()
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (order_id,))
file_name = ""
if file_id:
f = store.get_file_row(conn, file_id)
file_name = (f or {}).get("name") or ""
return order_item_out(row, file_name)
def get_order_item(conn, item_id):
row = conn.execute(
"""
SELECT oi.*, f.name AS file_name
FROM order_items oi
LEFT JOIN files f ON f.id = oi.file_id
WHERE oi.id = %s
""",
(item_id,),
).fetchone()
return order_item_out(row, row.get("file_name")) if row else None
def update_order_item(conn, item_id, fields):
sets = []
values = []
for key in ("file_id", "material_type", "color", "note"):
if key in fields:
sets.append("%s = %%s" % key)
values.append(fields[key])
if "qty" in fields:
try:
qty_n = max(1, int(fields["qty"]))
except (TypeError, ValueError):
qty_n = 1
sets.append("qty = %s")
values.append(qty_n)
if "unit_price" in fields:
sets.append("unit_price = %s")
values.append(float(fields["unit_price"] or 0))
if not sets:
return get_order_item(conn, item_id)
values.append(item_id)
row = conn.execute(
"""
UPDATE order_items SET %s
WHERE id = %%s
RETURNING order_id
""" % ", ".join(sets),
values,
).fetchone()
if not row:
return None
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (row["order_id"],))
return get_order_item(conn, item_id)
def delete_order_item(conn, item_id):
row = conn.execute(
"DELETE FROM order_items WHERE id = %s RETURNING order_id",
(item_id,),
).fetchone()
if row:
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (row["order_id"],))
return bool(row)
def apply_verdict_delta(conn, order_item_id, old_ok, old_bad, new_ok, new_bad):
"""Erhöht done_ok/done_bad an der Position — nur Delta zum vorherigen Lauf-Stand."""
if not order_item_id:
return
d_ok = int(new_ok or 0) - int(old_ok or 0)
d_bad = int(new_bad or 0) - int(old_bad or 0)
if d_ok == 0 and d_bad == 0:
return
conn.execute(
"""
UPDATE order_items
SET done_ok = done_ok + %s, done_bad = done_bad + %s
WHERE id = %s
""",
(d_ok, d_bad, order_item_id),
)
item = conn.execute(
"SELECT order_id FROM order_items WHERE id = %s",
(order_item_id,),
).fetchone()
if item:
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (item["order_id"],))
def enqueue_order_item(
conn,
order_id,
item_id,
host="",
family="",
priority=100,
spool_id=None,
ace_map=None,
bed_leveling=False,
override=False,
):
item = conn.execute(
"SELECT * FROM order_items WHERE id = %s AND order_id = %s",
(item_id, order_id),
).fetchone()
if not item:
raise ValueError("Position fehlt.")
file_id = item.get("file_id")
if not file_id:
raise ValueError("Position ohne Datei.")
if not store.get_file_row(conn, file_id):
raise ValueError("Datei fehlt.")
row = store.enqueue_print(
conn,
file_id,
host or "",
family or "",
priority,
spool_id,
ace_map if isinstance(ace_map, list) else [],
bool(bed_leveling),
bool(override),
order_item_id=item["id"],
)
conn.execute("UPDATE orders SET updated_at = now(), status = CASE WHEN status IN ('idee', 'angebot', 'bestaetigt') THEN 'druckt' ELSE status END WHERE id = %s", (order_id,))
return row
def attach_order_item_to_live_run(conn, host, filename, order_item_id):
"""Verknüpft gestarteten Lauf mit Auftragsposition."""
if not order_item_id or not host:
return
base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1]
conn.execute(
"""
UPDATE runs
SET order_item_id = COALESCE(order_item_id, %s)
WHERE printer_host = %s
AND (filename = %s OR filename LIKE %s)
AND state IN ('printing', 'paused', 'in_progress', 'completed')
AND ended_at IS NULL
""",
(order_item_id, host, base, "%/" + base),
)
+113 -9
View File
@@ -17,6 +17,8 @@ TRAILER_RE = re.compile(rb";\s*referenced_tools\s*=", re.I)
RINKHALS_FINGERPRINT = b"; processed by MmuAcePatcher"
_SLICER_RE = re.compile(rb"^;.*generated by ([A-Za-z]*) .*$|^; (BambuStudio) .*$", re.I)
_TOOL_RE = re.compile(rb"^(?:MMU_CHANGE_TOOL(?:_STANDALONE)? .*?TOOL=|T)(\d{1,2})", re.I)
# Orca `; filament_colour_type` — gklib parsed wie `filament_colour` und panickt (10111).
_COLOUR_TYPE_LINE_RE = re.compile(rb"(?m)^;\s*filament_colour_type\b[^\n]*\n?")
_AUTHORIZED_SLICERS = {
"prusaslicer",
"superslicer",
@@ -91,40 +93,111 @@ def color_hex(channels):
return "#%02x%02x%02x" % tuple(rgb)
def profile_name(raw):
"""Orca-Profil → Katalogname: `Sunlu PLA Meta @Kobra 3` → `Sunlu PLA Meta`."""
text = (raw or "").strip().strip('"').strip("'").strip()
if " @" in text:
text = text.rsplit(" @", 1)[0].strip()
return text
def filament_profiles(comments):
"""Profilnamen aus `; filament_settings_id`, Index = Slicer-Slot / paint_index."""
raw = (comments or {}).get("filament_settings_id") or ""
if not raw.strip():
fallback = profile_name((comments or {}).get("default_filament_profile") or "")
return [fallback] if fallback else []
return [profile_name(part) for part in raw.split(";") if profile_name(part)]
def attach_profiles(filaments, comments):
"""Hängt `profile` (Sorte) an Filament-Einträge anhand des paint_index."""
profiles = filament_profiles(comments)
if not filaments:
return filaments
if not profiles:
return filaments
for index, row in enumerate(filaments):
if not isinstance(row, dict):
continue
try:
paint = int(row.get("paint_index"))
except (TypeError, ValueError):
paint = index
name = ""
if 0 <= paint < len(profiles):
name = profiles[paint]
elif len(filaments) == 1 and len(profiles) == 1:
name = profiles[0]
if name:
row["profile"] = name
return filaments
def filaments_from_paint_info(raw):
"""Farben aus `; paint_info` — inkl. originalem `paint_index` (T-Nummer).
Bambu/Anycubic nutzen oft Lücken (T1 + T10 bei 16 Slots). Die Liste bleibt
kompakt (nur belegte Farben), aber jeder Eintrag trägt den Slicer-Index,
damit `.acm` und ACE-Map denselben Wert wie `Tn` im G-code schreiben.
"""
try:
items = json.loads(raw or "")
except (TypeError, json.JSONDecodeError):
return []
if not isinstance(items, list):
return []
found = []
found = {}
next_fallback = 0
for item in items:
if not isinstance(item, dict):
continue
try:
index = int(item.get("paint_index"))
except (TypeError, ValueError):
index = len(found)
while len(found) <= index:
found.append({"type": "", "color": ""})
found[index] = {
index = next_fallback
if index < 0:
continue
next_fallback = max(next_fallback, index + 1)
row = {
"type": str(item.get("material_type") or "").strip(),
"color": color_hex(item.get("paint_color")),
"paint_index": index,
}
return [row for row in found if row["type"] or row["color"]]
if row["type"] or row["color"]:
found[index] = row
return [found[key] for key in sorted(found)]
def filaments_of(comments):
painted = filaments_from_paint_info(comments.get("paint_info"))
if painted:
return painted
return attach_profiles(painted, comments)
types = [part.strip() for part in (comments.get("filament_type") or "").split(";") if part.strip()]
colors = [part.strip() for part in (comments.get("filament_colour") or comments.get("filament_color") or "").split(";")]
return [
{"type": kind, "color": colors[index] if index < len(colors) else ""}
rows = [
{
"type": kind,
"color": colors[index] if index < len(colors) else "",
"paint_index": index,
}
for index, kind in enumerate(types)
]
return attach_profiles(rows, comments)
def tools_used_in_gcode(content):
"""T-/MMU-Werkzeuge aus dem G-code (wie Rinkhals mmu_ace_metadata)."""
if not content:
return set()
if isinstance(content, str):
content = content.encode("utf-8", "replace")
found = set()
for line in _gcode_lines(content):
match = _TOOL_RE.match(line)
if match:
found.add(int(match.group(1)))
return found
def _gcode_lines(content):
@@ -173,6 +246,36 @@ def _paint_tool_indexes(content):
return found
def gcode_is_multicolor(content, tools=None):
"""Mehr als ein Slicer-/ACE-Tool → Multicolor (Header nicht antasten)."""
if not content or content[:2] == b"PK":
return False
indexes = set()
for value in tools or []:
try:
indexes.add(int(value))
except (TypeError, ValueError):
continue
for line in _gcode_lines(content):
match = _TOOL_RE.match(line)
if match:
indexes.add(int(match.group(1)))
indexes |= _paint_tool_indexes(content)
return len(indexes) > 1
def strip_filament_colour_type(content, tools=None):
"""`; filament_colour_type` nur bei Einfarbig entfernen (gklib 10111).
Multicolor unverändert — ACE/paint_index und `; filament_colour` bleiben.
"""
if not content or content[:2] == b"PK":
return content
if gcode_is_multicolor(content, tools=tools):
return content
return _COLOUR_TYPE_LINE_RE.sub(b"", content)
def prepare_rinkhals_gcode(content, tools=None):
"""Kopfzeile für Rinkhals plus `; referenced_tools`, wenn sie fehlt.
@@ -182,6 +285,7 @@ def prepare_rinkhals_gcode(content, tools=None):
"""
if not content or content[:2] == b"PK":
return content
content = strip_filament_colour_type(content, tools=tools)
first_end = content.find(b"\n")
first = content if first_end < 0 else content[:first_end]
if first.endswith(b"\r"):
Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff. Show more