Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db6a85a703 | ||
|
|
5081212212 | ||
|
|
2b1e36634a | ||
|
|
d1eb87ba25 | ||
|
|
5166ed6530 | ||
|
|
f034c5e626 | ||
|
|
f1f79c2ce2 | ||
|
|
4ffe1dcfc6 | ||
|
|
dd0e7ebc6b | ||
|
|
67a89d1794 |
No files matched your search
@@ -10,3 +10,8 @@ slice/*
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
_tmp_*
|
_tmp_*
|
||||||
|
%s
|
||||||
|
|
||||||
|
# gebaute .deb (Skript legt sie hier ab)
|
||||||
|
packaging/dist/*
|
||||||
|
!packaging/dist/.gitkeep
|
||||||
+158
@@ -4,6 +4,164 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [3.2.0] – 2026-10-18
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|||||||
@@ -1,120 +1,156 @@
|
|||||||
# Grotti
|
<p align="center">
|
||||||
|
<img src="docs/brand/readme-banner.png" alt="Grotti" width="720">
|
||||||
|
</p>
|
||||||
|
|
||||||
Steuert die 3D-Drucker im Haus von einer Weboberfläche aus: Live-Zustand, Dateien senden, Warteschlange, Abläufe für den Kobra 3 (drucken, abkühlen, abschieben, prüfen), Material, Projekte und ein Protokoll, was wann passiert ist.
|
<p align="center">
|
||||||
|
<strong>Farm-Manager für Moonraker-Drucker</strong><br>
|
||||||
|
Eine Oberfläche für die ganze Farm: Wand, Warteschlange, Material, Abläufe und Protokoll.<br>
|
||||||
|
<em>Benannt nach der Mühle Grotti — sie mahlt ohne Pause, was man ihr aufträgt.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
Der Name kommt von der Mühle Grotti: sie mahlt ohne Pause, was man ihr aufträgt.
|
<p align="center">
|
||||||
|
<a href="https://git.gundel.dev/dg/grotti"><img src="https://img.shields.io/badge/Version-3.7.0-e85d04?style=flat-square" alt="Version 3.7.0"></a>
|
||||||
|
<img src="https://img.shields.io/badge/Python-3.11+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
|
||||||
|
<img src="https://img.shields.io/badge/PostgreSQL-14+-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
|
||||||
|
<img src="https://img.shields.io/badge/Moonraker-Klipper-2ecc71?style=flat-square" alt="Moonraker">
|
||||||
|
<img src="https://img.shields.io/badge/UI-Deutsch-111827?style=flat-square" alt="UI Deutsch">
|
||||||
|
<img src="https://img.shields.io/badge/Self--hosted-lokal-6b7280?style=flat-square" alt="Self-hosted">
|
||||||
|
</p>
|
||||||
|
|
||||||
Die Drucker laufen mit Rinkhals (Kobra 3, Kobra S1). Grotti spricht sie über Moonraker an.
|
<p align="center">
|
||||||
|
<a href="#features">Features</a> ·
|
||||||
|
<a href="#screenshots">Screenshots</a> ·
|
||||||
|
<a href="#schnellstart">Schnellstart</a> ·
|
||||||
|
<a href="#api">API</a> ·
|
||||||
|
<a href="CHANGELOG.md">Changelog</a> ·
|
||||||
|
<a href="docs/community/README.md">Community</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
Aktuelle Version: siehe `grotti/version.py`, Änderungen in [CHANGELOG.md](CHANGELOG.md).
|
---
|
||||||
|
|
||||||
## Voraussetzungen
|
<p align="center">
|
||||||
|
<img src="docs/community/01-druckerwand.png" alt="Druckerwand" width="900">
|
||||||
|
</p>
|
||||||
|
|
||||||
- Python 3.11
|
## Features
|
||||||
- PostgreSQL (eine leere Datenbank `grotti` reicht, das Schema legt Grotti selbst an)
|
|
||||||
- Drucker im selben Netz mit Moonraker auf Port 80
|
|
||||||
|
|
||||||
## Einrichten
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| **Druckerwand & Cockpit** | Live-Status, Temps, Pause/Cancel, Farm-ETA |
|
||||||
|
| **Warteschlange** | Priorität, „nächster freier“, Material-Match, ACE-Map |
|
||||||
|
| **Dateien & Projekte** | Slice-Watch, Senden, Umbenennen, Projektordner |
|
||||||
|
| **Kobra-3-Ablauf** | Druck → Abkühlen → Schieben → Tasten |
|
||||||
|
| **Material** | Spulen, Restgewicht, Bedarf, Chargen |
|
||||||
|
| **Aufträge & Statistik** | Kunden, Lieferschein, Kosten (€ Material+Strom) |
|
||||||
|
| **Betrieb** | Auth (Session/`grt_…`), Telegram/ntfy, PWA |
|
||||||
|
|
||||||
|
Stack: **Python · Flask · PostgreSQL · Moonraker** (Rinkhals auf Kobra 3 / S1).
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/community/06-jetzt.png" alt="Jetzt-Liste" width="440">
|
||||||
|
|
||||||
|
<img src="docs/community/02-warteschlange.png" alt="Warteschlange" width="440">
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/community/09-dateien.png" alt="Dateien" width="440">
|
||||||
|
|
||||||
|
<img src="docs/community/03-material.png" alt="Material" width="440">
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/community/05-statistik.png" alt="Statistik" width="440">
|
||||||
|
|
||||||
|
<img src="docs/community/07-cockpit.png" alt="Cockpit" width="440">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Weitere Bilder und Post-Text: [`docs/community/`](docs/community/README.md).
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
**Voraussetzungen:** Python 3.11, PostgreSQL (leere DB `grotti`), Drucker mit Moonraker im LAN.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd D:\Projekte\Grotti
|
cd D:\Projekte\Grotti
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.\.venv\Scripts\pip install -r requirements.txt
|
.\.venv\Scripts\pip install -r requirements.txt
|
||||||
copy config.example.json config.json
|
copy config.example.json config.json
|
||||||
```
|
|
||||||
|
|
||||||
`config.json` anpassen:
|
|
||||||
|
|
||||||
| Schlüssel | Bedeutung |
|
|
||||||
| --- | --- |
|
|
||||||
| `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. |
|
|
||||||
| `database_url` | PostgreSQL-Verbindung. |
|
|
||||||
| `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. |
|
|
||||||
| `watch_dir` | Slice-Ordner (unter Einstellungen setzen). Leer = nur Upload. |
|
|
||||||
| `token` | Wenn gesetzt, braucht die API `Authorization: Bearer <token>`. |
|
|
||||||
| `fqdn` | Öffentliche Basis-URL für Spulen-Etiketten-QR. Leer = Request-Host. |
|
|
||||||
| `energy` | Watt und Strompreis für die Kosten im Protokoll. |
|
|
||||||
| `telegram`, `alerts` | Bot und welche Ereignisse als Nachricht kommen. |
|
|
||||||
| `auto_queue` | Wenn `false`, startet die Warteschlange nichts Neues. |
|
|
||||||
|
|
||||||
Die Drucker stehen beim ersten Start in `drucker.json` und landen dann in der Datenbank. Danach werden sie unter Einstellungen gepflegt.
|
|
||||||
|
|
||||||
Schema-Änderungen laufen beim Start über `grotti/migrations/` (Tabelle `schema_version`). Vor Major-Updates die Datenbank sichern (`pg_dump`).
|
|
||||||
|
|
||||||
```json
|
|
||||||
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
|
|
||||||
```
|
|
||||||
|
|
||||||
Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen.
|
|
||||||
|
|
||||||
## Starten
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\.venv\Scripts\python.exe server.py
|
.\.venv\Scripts\python.exe server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Dann <http://127.0.0.1:8770/> öffnen. Bedienungsanleitung mit Screenshots in der Oberfläche unter **Doku** (Kapitel `Anleitung-*`), Technik-Notizen darunter.
|
Dann <http://127.0.0.1:8770/>. Bedienung unter **Doku** in der App (`docs/Anleitung-*.md`).
|
||||||
|
|
||||||
`config.json` und `data/` sind lokal und nicht im Git.
|
### Raspberry Pi (`.deb`)
|
||||||
|
|
||||||
|
Auf dem Pi (arm64 / Bookworm) bauen und installieren — PostgreSQL und systemd-Dienst werden mit eingerichtet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packaging/build-deb.sh
|
||||||
|
sudo apt install ./packaging/dist/grotti_*_arm64.deb
|
||||||
|
journalctl -u grotti -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Details: [docs/Anleitung-Installation-Pi.md](docs/Anleitung-Installation-Pi.md). Öffentliches apt-Repo folgt später.
|
||||||
|
|
||||||
|
`config.json` und `data/` bleiben lokal (nicht im Git). Drucker zuerst in `drucker.json`, danach unter Einstellungen.
|
||||||
|
|
||||||
|
| Schlüssel | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| `host`, `port` | Oberfläche (Standard `127.0.0.1:8770`) |
|
||||||
|
| `database_url` | PostgreSQL |
|
||||||
|
| `watch_dir` | Slice-Ordner (oder leer = nur Upload) |
|
||||||
|
| `energy` / `telegram` / `alerts` | Kosten und Benachrichtigungen |
|
||||||
|
|
||||||
|
Schema-Migrationen laufen beim Start (`grotti/migrations/`). Vor Major-Updates: `pg_dump`.
|
||||||
|
|
||||||
## Ordner
|
## Ordner
|
||||||
|
|
||||||
| Pfad | Inhalt |
|
| Pfad | Inhalt |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `server.py` | Start, Datenbank vorbereiten, Hintergrund-Threads |
|
| `server.py` | Start, DB, 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`) |
|
| `grotti/` | Moonraker, Queue, Senden, Abräumen, Watcher, Store |
|
||||||
| `web/` | Flask-App (`app.py`), Seiten (`templates/`), CSS und JS (`static/`) |
|
| `web/` | Flask, Templates, CSS/JS, Branding |
|
||||||
| `docs/` | Bedienungsanleitung (`Anleitung-*.md`) unter `/doku`; Technik unter `docs/internal/` (nicht in der App) |
|
| `docs/` | Anleitungen (`/doku`), Community-Screens, Technik unter `internal/` |
|
||||||
| `web/static/doku/` | Screenshots für die Anleitung |
|
| `CHANGELOG.md` | Versionen |
|
||||||
| `CHANGELOG.md` | Änderungen je Version |
|
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bearer grt_…` (API-Token unter Einstellungen). Optional noch Shared-`token` in der Config (Migrationsbrücke).
|
Alles unter `/api/v1`, JSON. Auth: Session oder `Authorization: Bearer grt_…` (kein CSRF). Referenz: [`docs/internal/API.md`](docs/internal/API.md), OpenAPI-Skizze [`docs/internal/openapi.yaml`](docs/internal/openapi.yaml).
|
||||||
|
|
||||||
| Methode und Pfad | Zweck |
|
| Methode und Pfad | Zweck |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `POST /auth/setup`, `/auth/login`, `/auth/logout` | Ersten Admin, Login, Logout |
|
| `POST /auth/setup`, `/auth/login`, `/auth/token`, `/auth/logout` | Admin, Session-Login, App-Token, Logout |
|
||||||
| `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens |
|
| `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens |
|
||||||
| `GET /version` | Version |
|
| `GET /version` | Version, `api`, `features` |
|
||||||
| `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe |
|
| `GET`/`PATCH /einstellungen`, `POST /telegram/test`, `/ntfy/test` | Config und Push-Probe |
|
||||||
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag |
|
| `GET /drucker?live=1` | Drucker mit Live-Zustand |
|
||||||
| `POST /drucker` · `PATCH`/`DELETE /drucker/<id>` | Drucker anlegen/ändern (Admin) |
|
| `POST /drucker/<host>/<aktion>` | `pause`, `fortsetzen`/`resume`, `abbrechen`/`cancel` |
|
||||||
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
|
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE, `clear_error`, E-Stop |
|
||||||
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE |
|
|
||||||
| `POST /drucker/<host>/abraeumen` | Platte geräumt (`confirm`) |
|
| `POST /drucker/<host>/abraeumen` | Platte geräumt (`confirm`) |
|
||||||
| `GET /kamera/<host>`, `/vorschau/<host>` | Snapshot / Job-Thumbnail |
|
| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek (Filter `family`, `material`, `project_id`, …) |
|
||||||
| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek, Upload, Metadaten |
|
| `POST /senden`, `GET`/`POST /druck-queue` | Senden und Warteschlange (`confirm`) |
|
||||||
| `POST /dateien/neu-lesen`, `/dateien/ordner`, `/dateien/<id>/verschieben` | Watch-Sync, Ordner, Verschieben |
|
| `GET`/`POST /projekte`, `…/einreihen`, Assets | Projekte |
|
||||||
| `POST /senden`, `GET /sende-auftraege` | Sofort senden (`confirm`), Fortschritt |
|
| `GET /spulen`, `POST /spulen/<id>/einlegen` | Material |
|
||||||
| `GET`/`POST /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange (`confirm` beim Einreihen) |
|
| `GET /laeufe`, `/statistik`, `/ereignisse`, `/stream` | Protokoll, Statistik, SSE |
|
||||||
| `GET /laeuft`, `/aufgaben` | Direktstarts und Platte-belegt |
|
|
||||||
| `POST /ablauf`, `/schieben-testen` | Ablauf Kobra 3 / Schieben-Test |
|
|
||||||
| `GET /spulen`, `POST /spulen`, `POST /spulen/<id>/einlegen` | Spulen und Einlegen |
|
|
||||||
| `GET /spulen/<id>/akte`, `/spulen/by-code` | Spulenakte, Scan `S-…` |
|
|
||||||
| `GET`/`POST /materialien`, `/farben` | Sorten und Farbpalette |
|
|
||||||
| `GET /laeufe`, `/laeufe/aktuell`, `/laeufe/<id>` | Protokoll und Druckbericht |
|
|
||||||
| `GET /statistik`, `/statistik.csv` | Auslastung und CSV |
|
|
||||||
| `GET`/`POST /projekte`, `/ideen` | Projekte und Ideen-Backlog |
|
|
||||||
| `GET /ereignisse`, `POST /ereignisse/gelesen` | Aktivität und Alerts |
|
|
||||||
| `POST /kalkulation` | Vorkalkulation Material+Strom |
|
|
||||||
|
|
||||||
MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN` (siehe `docs/internal/MCP.md`).
|
MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` / `GROTTI_TOKEN` → `docs/internal/MCP.md`.
|
||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
- Login Pflicht (Setup legt den ersten Admin an). Rollen: Admin, Operator.
|
- Login Pflicht. Rollen: Admin, Operator.
|
||||||
- Druckstarts brauchen `confirm: true`. Die Oberfläche fragt per Zwei-Klick.
|
- Druckstarts brauchen `confirm: true` (Zwei-Klick in der UI).
|
||||||
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
|
- Keine freie G-code-Konsole; kein `FIRMWARE_RESTART` über Grotti.
|
||||||
- Für Internet: Reverse Proxy mit TLS; nicht den Entwickler-Server öffentlich freigeben.
|
- 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.
|
- SemVer in `grotti/version.py`, Einträge in [CHANGELOG.md](CHANGELOG.md).
|
||||||
- Jede Änderung kommt in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
|
- Release: `[Unreleased]` → `[x.y.z] – Datum`, Tag `vX.Y.Z`.
|
||||||
- Beim Release: Version in `grotti/version.py` setzen, `[Unreleased]` in `[x.y.z] – Datum` umbenennen, Git-Tag `vX.Y.Z`.
|
- CSS/JS-Cache: `?v=` in `layout.html` mitziehen.
|
||||||
- Ändert sich `web/static/grotti.css` oder `api.js`, in `layout.html` den `?v=` hochzählen.
|
|
||||||
|
|
||||||
## Repo
|
---
|
||||||
|
|
||||||
Eigenes Repository: <https://git.gundel.dev/dg/grotti>. Doku zum Tool gehört nach `docs/`.
|
<p align="center">
|
||||||
|
<img src="web/static/brand/logo.png" alt="" width="72"><br>
|
||||||
|
<sub>Repo: <a href="https://git.gundel.dev/dg/grotti">git.gundel.dev/dg/grotti</a></sub>
|
||||||
|
</p>
|
||||||
@@ -7,6 +7,12 @@
|
|||||||
"token": "",
|
"token": "",
|
||||||
"secret_key": "",
|
"secret_key": "",
|
||||||
"fqdn": "",
|
"fqdn": "",
|
||||||
|
"etikett": {
|
||||||
|
"width_mm": 70,
|
||||||
|
"height_mm": 40
|
||||||
|
},
|
||||||
|
"meldung_relay": "https://git.gundel.dev/hooks/grotti-meldung",
|
||||||
|
"log_level": "INFO",
|
||||||
"auto_queue": true,
|
"auto_queue": true,
|
||||||
"auto_retry": true,
|
"auto_retry": true,
|
||||||
"oauth": {
|
"oauth": {
|
||||||
|
|||||||
+18
-4
@@ -1,6 +1,6 @@
|
|||||||
# ACE und Spulen
|
# ACE und Spulen
|
||||||
|
|
||||||
Stand **18.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
|
Stand **21.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -12,27 +12,41 @@ Stand **18.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Date
|
|||||||
|
|
||||||
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.
|
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).
|
Etiketten mit QR: [Material und mehr](Anleitung-Material.md).
|
||||||
|
|
||||||
## ACE-Map beim Senden
|
## ACE-Map beim Senden
|
||||||
|
|
||||||
Unter **Dateien → Optionen → ACE-Zuordnung**:
|
Unter **Dateien → Optionen → ACE-Zuordnung**:
|
||||||
|
|
||||||
- Jede Farbe aus der Datei (`paint_index`) einem **ACE-Platz** zuordnen
|
- Jede Farbe aus der Datei (`Tn` / Slicer-`paint_index`) einem **ACE-Platz** zuordnen
|
||||||
- Liegt unter Material eine Spule auf dem Platz, zeigt die Liste Name, Farbe und Restgramm mit
|
- 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
|
- 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
|
- 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.
|
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
|
## Typische Fehler
|
||||||
|
|
||||||
| Symptom | Check |
|
| Symptom | Check |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| „cannot parse the file“ | ACE-Zuordnung vollständig? Farben richtig? |
|
| „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. |
|
| Material-Warnung | Eingelegte Sorte ≠ Datei. Der Start geht trotzdem. PA12-CF steht in Grotti so, an den ACE geht PA6-CF. |
|
||||||
| Rest zu gering | Andere Spule oder leichteres Teil |
|
| Rest zu gering | Warnung unter Dateien („nur … g, Datei braucht … g“). Der Start geht trotzdem; leer pausiert der Drucker. |
|
||||||
| ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten |
|
| 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
|
## Weiter
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Im Auftrag **Lieferschein** öffnen: Positionen, Chargen `D-…`, druckbar. Dana
|
|||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
|
- [Projekte](Anleitung-Projekte.md) (Arbeitsmappe / Konstruktion — nicht dasselbe wie Auftrag)
|
||||||
- [Warteschlange](Anleitung-Warteschlange.md)
|
- [Warteschlange](Anleitung-Warteschlange.md)
|
||||||
- [Material](Anleitung-Material.md)
|
- [Material](Anleitung-Material.md)
|
||||||
- [Protokoll / Bericht](Anleitung-Oberflaeche.md)
|
- [Protokoll / Bericht](Anleitung-Oberflaeche.md)
|
||||||
@@ -1,31 +1,34 @@
|
|||||||
# Dateien senden
|
# Dateien senden
|
||||||
|
|
||||||
Stand **17.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
|
Stand **18.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Bibliothek
|
## Bibliothek
|
||||||
|
|
||||||
- Suche nach Name, Material und Projekt
|
- Kompakte Filterleiste: Suche, Maschine, Projekt, Material, Ordner, Sortierung
|
||||||
- Filter: Maschine, Projekt, Material, Ordner (Unterordner unter dem Slice-Ordner; Uploads als „Upload“)
|
- 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
|
- Sortierung: Neueste, Name, Material, Ordner, Druckzeit, Gewicht — bei Material/Ordner mit Gruppenköpfen
|
||||||
- Auswahl der Filter bleibt im Browser merken
|
- Auswahl der Filter bleibt im Browser merken
|
||||||
- Vorschaubild, Material, Zeit, Gewicht, Schichthöhe
|
- Vorschaubild, Material, Zeit, Gewicht, Schichthöhe
|
||||||
- Info-Popup mit Slice-Metadaten (Kopf und Footer)
|
- Info-Popup mit Slice-Metadaten (Kopf und Footer)
|
||||||
- **Hochladen** per Knopf oder Drag & Drop
|
- **Hochladen** per Knopf oder Drag & Drop
|
||||||
- Mit gesetztem Slice-Ordner ([Einstellungen](Anleitung-Einstellungen.md)): **Ordner anlegen**, Watch-Dateien per Auswahl **verschieben** (Datei bleibt in der Bibliothek dieselbe, inkl. Projekt)
|
- 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
|
- **Ordner neu lesen**, wenn unter Einstellungen ein Slice-Ordner gesetzt ist
|
||||||
|
|
||||||
Ohne Slice-Ordner geht nur manueller Upload — das reicht für den Alltag. Uploads liegen flach unter „Upload“ und lassen sich in der Bibliothek nicht in Slice-Ordner schieben.
|
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
|
## Senden: drei Schritte
|
||||||
|
|
||||||
1. **Datei** wählen (Klick in der Liste).
|
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.
|
2. **Drucker** wählen — Kacheln zeigen Live-Zustand und eingelegte Rolle. Passt die Maschine nicht, ist die Kachel blass.
|
||||||
3. **Wann:**
|
3. **Start:**
|
||||||
- **Jetzt** — sofort senden und starten (Zwei-Klick)
|
- **Dieser Drucker** — frei und Platte klar → sofort starten; sonst Warteschlange für diesen Drucker
|
||||||
- **Wenn frei** — in die 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.
|
||||||
- **Nächster freier** — erster passender freier Drucker
|
|
||||||
|
|
||||||
Auswahl merkt sich der Browser (`localStorage`).
|
Auswahl merkt sich der Browser (`localStorage`).
|
||||||
|
|
||||||
@@ -44,7 +47,7 @@ Große Dateien brauchen oft **eine Minute**. In der Sidebar unter **Senden** sie
|
|||||||
## Nach dem Start
|
## Nach dem Start
|
||||||
|
|
||||||
- Fortschritt auf der [Wand](Anleitung-Wand.md) und in der Sidebar **Läuft jetzt**
|
- Fortschritt auf der [Wand](Anleitung-Wand.md) und in der Sidebar **Läuft jetzt**
|
||||||
- Bei „Wenn frei“: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md)
|
- Bei Warteschlange / Nächster passender: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md)
|
||||||
- Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map)
|
- Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map)
|
||||||
|
|
||||||
## Aus Orca
|
## Aus Orca
|
||||||
@@ -53,6 +56,7 @@ Nach dem G-code-Export kann Orca die Datei direkt nach Grotti laden und diese Se
|
|||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
|
- [Projekte](Anleitung-Projekte.md)
|
||||||
- [Orca an Grotti](Anleitung-Orca.md)
|
- [Orca an Grotti](Anleitung-Orca.md)
|
||||||
- [ACE und Spulen](Anleitung-ACE.md)
|
- [ACE und Spulen](Anleitung-ACE.md)
|
||||||
- [Warteschlange](Anleitung-Warteschlange.md)
|
- [Warteschlange](Anleitung-Warteschlange.md)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Einrichtung
|
# Einrichtung
|
||||||
|
|
||||||
Stand **15.10.2026**. Grotti auf dem Haus-PC installieren und den Browser öffnen. Die Drucker brauchen vorher Moonraker (Rinkhals auf Kobra 3 und S1).
|
Stand **24.10.2026**. Grotti auf dem Haus-PC oder Raspberry Pi installieren und den Browser öffnen. Die Drucker brauchen vorher Moonraker (Rinkhals auf Kobra 3 und S1).
|
||||||
|
|
||||||
## Was du brauchst
|
## Was du brauchst
|
||||||
|
|
||||||
@@ -54,10 +54,12 @@ Dann <http://127.0.0.1:8770/> öffnen. Beim ersten Start legst du den Admin-Benu
|
|||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
- Druckstart nur mit Bestätigung (Zwei-Klick in der UI bzw. `confirm: true` in der API).
|
- Druckstart über die API nur mit `confirm: true` (Dateien-UI: ein Klick).
|
||||||
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
|
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
|
||||||
- Benutzer und API-Tokens unter Einstellungen. Für Zugriff aus dem Internet: Reverse Proxy mit TLS, nicht den Entwickler-Server öffentlich freigeben.
|
- Benutzer und API-Tokens unter Einstellungen. App-Kopplung: Token erzeugen und QR scannen, oder `POST /api/v1/auth/token` mit Benutzer/Passwort. Push aufs Handy: **ntfy** (kein FCM). API-Referenz: intern `docs/internal/API.md`.
|
||||||
|
- Für Zugriff aus dem Internet: Reverse Proxy mit TLS, nicht den Entwickler-Server öffentlich freigeben.
|
||||||
- `config.json` und `data/` bleiben lokal, nicht im Git.
|
- `config.json` und `data/` bleiben lokal, nicht im Git.
|
||||||
|
- Raspberry Pi: [Installation auf dem Pi](Anleitung-Installation-Pi.md) (`.deb`, Umzug Windows→Pi).
|
||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Stand **18.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Te
|
|||||||
|
|
||||||
## Automatik
|
## Automatik
|
||||||
|
|
||||||
Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen „wenn frei“-Starts. Laufende Abläufe und manuelle „Jetzt“-Starts bleiben möglich.
|
Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen Queue-Starts. Laufende Abläufe und manuelle Sofort-Starts bleiben möglich.
|
||||||
|
|
||||||
## Slice-Ordner
|
## Slice-Ordner
|
||||||
|
|
||||||
@@ -31,13 +31,17 @@ Pro Drucker: Name, Host, Familie, **gesperrt**, optional **Start-Zeitfenster**.
|
|||||||
|
|
||||||
Öffentliche Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host aus dem aktuellen Request. Wichtig, wenn du Etiketten von einem Handy scannst, das nicht `127.0.0.1` kennt.
|
Ö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, 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.
|
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
|
## Benutzer und API-Tokens
|
||||||
|
|
||||||
Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`). Ein Shared-`token` in der Config-Datei gilt nicht mehr.
|
Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`), dazu ein QR mit Server-URL und Token zum Scannen. Alternativ stellt die App `POST /api/v1/auth/token` mit Benutzer/Passwort aus. Ein Shared-`token` in der Config-Datei gilt nicht mehr.
|
||||||
|
|
||||||
MCP (optional): Token erzeugen, dann `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN`. Für nur Lesen: `GROTTI_MCP_READ_ONLY=1`.
|
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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Material und mehr
|
# Material und mehr
|
||||||
|
|
||||||
Stand **18.10.2026**. Spulen, Etiketten, Aufträge, Protokoll, Statistik, Kameras, Aktivität.
|
Stand **18.10.2026**. Spulen, Etiketten, Aufträge, Protokoll, Statistik, Aktivität.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ 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.
|
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. Formular bei Auswahl oder „Neue Spule“. 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.
|
**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.
|
**Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
||||||
|
|
||||||
@@ -26,9 +26,9 @@ Unter **Aufträge** Kunden, Positionen und Fortschritt. Dateien lassen sich eine
|
|||||||
|
|
||||||
## Protokoll und Statistik
|
## Protokoll und Statistik
|
||||||
|
|
||||||
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Strom-Aufschlüsselung mit Tortendiagramm, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
|
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Kosten-Aufschlüsselung Filament + Strom mit Tortendiagramm, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option (nicht während der Druck noch läuft).
|
||||||
|
|
||||||
Nach Filament-Ende: neue Spule vom Regal dem Drucker/ACE-Platz zuweisen, am Gerät laden, an der Wand **Weiter**.
|
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.
|
Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis.
|
||||||
|
|
||||||
@@ -38,16 +38,12 @@ Abzug nach dem Druck nimmt das Ist aus `filament_used` (Abbruch nur anteilig). M
|
|||||||
|
|
||||||
## Kameras
|
## Kameras
|
||||||
|
|
||||||
`/kameras` — alle Kameras nebeneinander mit Live-Zustand. Oben **Snapshot** oder **Stream** wählbar (gilt für diesen Browser, auch im Cockpit). Stream ist flüssiger, hält aber je sichtbarer Kamera eine Verbindung.
|
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
|
## 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.
|
Zeitstrahl: Starts, Schritte, Fertig, Fehler, Abbrüche, offline. Filter nach Drucker, Art, Zeitraum. Ungelesene Alerts sind markiert; in der Sidebar kannst du sie als gelesen setzen.
|
||||||
|
|
||||||
## Ideen
|
|
||||||
|
|
||||||
`/ideen` — Notizen und Ideen für später. Kein Druckfluss, nur Merkliste.
|
|
||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
- [ACE und Spulen](Anleitung-ACE.md)
|
- [ACE und Spulen](Anleitung-ACE.md)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Stand **18.10.2026**. Wie die App aufgebaut ist — Menü, Jetzt-Liste, Desktop
|
|||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
Links (Desktop), gruppiert: **Live** (Drucker, Dateien, Kameras, Warteschlange, Aufträge), **Rückschau** (Protokoll, Statistik, Aktivität), **Verwalten** (Material, Wartung). Darüber im Inhaltsbereich die **Topbar**: Suche, Live-Chips, Einstellungen, Doku und Benutzer-Menü (Abmelden, Version).
|
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.
|
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.
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ Unter **Einstellungen → Dieser Browser**:
|
|||||||
- **Regal (Touch)** — größere Bedienelemente, Sidebar oft eingeklappt; auf schmalem Viewport die untere Leiste. Alle Menüpunkte bleiben in der Sidebar sichtbar.
|
- **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
|
- **Darstellung** — Dunkel, Hell oder System
|
||||||
- **Zeitzone** — Browser oder fest (z. B. Europe/Berlin) für Uhrzeiten in Protokoll und Bericht
|
- **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 je Kamera). Auch umschaltbar unter Kameras.
|
- **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`).
|
Shortcut in der URL: `?shelf=1` bzw. `?shelf=0`. Nur dieser Browser (`localStorage`).
|
||||||
|
|
||||||
@@ -46,3 +46,4 @@ Drucker-Cockpit: Alltag (Job, ETA, Rolle, Steuerung der Aktionen) oben; **Techni
|
|||||||
|
|
||||||
- [Druckerwand](Anleitung-Wand.md)
|
- [Druckerwand](Anleitung-Wand.md)
|
||||||
- [Dateien senden](Anleitung-Dateien.md)
|
- [Dateien senden](Anleitung-Dateien.md)
|
||||||
|
- [Projekte](Anleitung-Projekte.md)
|
||||||
+44
-13
@@ -1,21 +1,25 @@
|
|||||||
# Orca an Grotti
|
# Orca an Grotti
|
||||||
|
|
||||||
Stand **17.10.2026**. Nach dem Export in OrcaSlicer landet der G-code in der Bibliothek und die Seite [Dateien senden](Anleitung-Dateien.md) öffnet sich mit der Datei schon gewählt. Drucker, ACE-Platz und Einreihen wählst du dort wie gewohnt.
|
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
|
## Einmal einrichten
|
||||||
|
|
||||||
1. Grotti starten und im Browser anmelden (die Session bleibt für die Senden-Seite).
|
1. Grotti auf dem Pi erreichbar machen und im Browser anmelden (die Session bleibt für die Senden-Seite).
|
||||||
2. Unter **Einstellungen → API-Token** ein Token anlegen (Rolle Operator reicht). Den Wert `grt_…` nur einmal sichtbar.
|
2. Unter **Einstellungen → API-Token** ein Token anlegen (Rolle Operator reicht). Den Wert `grt_…` nur einmal sichtbar.
|
||||||
3. Datei `data/orca-senden.json` anlegen (gleicher Ordner wie `config.json`):
|
3. Datei `data/orca-senden.json` anlegen (im Grotti-Repo unter Windows, gleicher Ordner wie lokal `config.json`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"url": "http://127.0.0.1:8770",
|
"url": "http://10.10.1.41:8770",
|
||||||
"token": "grt_…"
|
"token": "grt_…",
|
||||||
|
"browser": "app",
|
||||||
|
"watch_dir_local": "D:/Slice"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`url` ist die Adresse, unter der du Grotti im Browser öffnest.
|
`url` ist die Adresse des Pi (LAN-IP oder Hostname).
|
||||||
|
`browser`: `app` (Standard) = eigenes Desktop-Fenster; `tab` = klassischer Browser-Tab.
|
||||||
|
`watch_dir_local`: Windows-Pfad desselben Slice-Ordners, den Grotti auf dem Pi beobachtet. Ohne diesen Eintrag lädt das Skript jede Exportdatei zusätzlich hoch — in der Bibliothek erscheint sie dann doppelt (einmal Watch mit Projekt, einmal Upload).
|
||||||
|
|
||||||
4. Im **Prozessprofil** (Druckqualität, z. B. „0.20mm Standard“): **Sonstiges → Nachbearbeitungsskripte**, eine Zeile:
|
4. Im **Prozessprofil** (Druckqualität, z. B. „0.20mm Standard“): **Sonstiges → Nachbearbeitungsskripte**, eine Zeile:
|
||||||
|
|
||||||
@@ -25,13 +29,31 @@ D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_send
|
|||||||
|
|
||||||
Gilt für **OrcaSlicer** und **AnycubicSlicerNext**. Pfade anpassen. Der Slicer hängt den Export-Pfad selbst an.
|
Gilt für **OrcaSlicer** und **AnycubicSlicerNext**. Pfade anpassen. Der Slicer hängt den Export-Pfad selbst an.
|
||||||
|
|
||||||
Unter **Gerät / Drucker** keinen Print-Host (Moonraker) eintragen — sonst geht der Job am Grotti-Senden vorbei.
|
### Gerät-Reiter = Grotti (nur OrcaSlicer)
|
||||||
|
|
||||||
## Ablauf
|
Damit unter **Gerät** die Grotti-Oberfläche erscheint:
|
||||||
|
|
||||||
1. In Orca slicen und **G-code exportieren** (Platte schreiben). Als Ziel den Slice-Ordner wählen — **Unterordner** (z. B. `slice\Gridfinity\…`) gehen: Grotti legt die Datei dort ab und liest den Watch neu. Export außerhalb des Slice-Ordners landet als Upload (ohne Ordner-Verschieben).
|
1. Orca → Drucker-Dropdown → **Physikalischer Drucker** (Verbindung/Stecker-Symbol).
|
||||||
2. Der Browser öffnet `/dateien?file=…` mit der neuen Datei.
|
2. **Host-Typ:** z. B. `Octo/Klipper` (egal, solange du nicht „Drucken“ an den Host schickst).
|
||||||
3. Drucker wählen, unter Optionen Farbe → ACE-Platz, **Jetzt** / **Wenn frei** / **Nächster freier**, dann **Starten**.
|
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:
|
Test ohne Orca:
|
||||||
|
|
||||||
@@ -43,11 +65,20 @@ D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_send
|
|||||||
|
|
||||||
| Symptom | Check |
|
| 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 |
|
| Meldungsfenster „Config fehlt“ | `data/orca-senden.json` anlegen |
|
||||||
| „Grotti nicht erreichbar“ | Server läuft? `url` stimmt? |
|
| „Grotti nicht erreichbar“ | Server läuft? `url` stimmt? |
|
||||||
| „Token fehlt oder stimmt nicht“ | Token neu anlegen, in der JSON eintragen |
|
| „Token fehlt oder stimmt nicht“ | Token neu anlegen, in der JSON eintragen |
|
||||||
| Browser ohne Datei / Login | Einmal anmelden; Deep-Link behält `?file=` nach dem Login |
|
| 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. |
|
||||||
| Skript läuft nicht | Export wirklich speichern; Zeile im **Druckerprofil**; Python-Pfad prüfen |
|
| 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
|
## Weiter
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -49,6 +49,10 @@ Oft noch Verarbeitung auf dem Drucker. In der Sidebar unter **Senden** den Forts
|
|||||||
|
|
||||||
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.
|
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
|
## Wo nachschauen
|
||||||
|
|
||||||
- Sidebar: Alerts, Läuft, Wartet, Senden
|
- Sidebar: Alerts, Läuft, Wartet, Senden
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Druckerwand
|
# Druckerwand
|
||||||
|
|
||||||
Stand **18.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
|
Stand **24.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ Stand **18.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet da
|
|||||||
Pro Drucker siehst du:
|
Pro Drucker siehst du:
|
||||||
|
|
||||||
- Zustand (Chip), Fertig-Uhrzeit, Fortschrittsbalken
|
- Zustand (Chip), Fertig-Uhrzeit, Fortschrittsbalken
|
||||||
|
- Kleines Kamerabild (Snapshot ca. alle 3 s), wenn eine Webcam erreichbar ist — Klick öffnet das Cockpit
|
||||||
- Restzeit, Schicht, Laufzeit
|
- Restzeit, Schicht, Laufzeit
|
||||||
- Temperaturen Düse/Bett (mit Ziel)
|
- Temperaturen Düse/Bett (mit Ziel)
|
||||||
- Dateiname, bei ACE oft Farb-Swatches
|
- Dateiname, bei ACE oft Farb-Swatches
|
||||||
@@ -18,7 +19,7 @@ Aktionen auf der Karte brauchen oft **zwei Klicks** (erster Klick „scharf“,
|
|||||||
|
|
||||||
| Aktion | Wann |
|
| Aktion | Wann |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Pause** / **Weiter** | Während des Drucks; nach Materialwechsel Spule in Grotti an den Drucker hängen, dann **Weiter** |
|
| **Pause** / **Weiter** | Während des Drucks; nach Materialwechsel gelber Hinweis mit ACE-Platz → Link zu Material (Drucker/Platz vorausgefüllt), Speichern, dann **Weiter**. Dieselbe Charge läuft weiter. |
|
||||||
| **Abbrechen** | Druck oder Pause |
|
| **Abbrechen** | Druck oder Pause |
|
||||||
| **Fehler löschen** | Zustand Fehler — räumt den hängenden Fehler, Heizung aus |
|
| **Fehler löschen** | Zustand Fehler — räumt den hängenden Fehler, Heizung aus |
|
||||||
| **Platte geräumt** | Nach Fertig, wenn die Platte wieder frei ist |
|
| **Platte geräumt** | Nach Fertig, wenn die Platte wieder frei ist |
|
||||||
@@ -34,11 +35,12 @@ URL mit Hash: `/#<host>` (z. B. nach Klick auf die Karte).
|
|||||||
|
|
||||||
Inhalt:
|
Inhalt:
|
||||||
|
|
||||||
- **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg. Modus Snapshot/Stream unter Einstellungen → Dieser Browser (oder Kameras-Seite)
|
- **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
|
- 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)
|
- **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
|
- **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an
|
||||||
- **Verlauf** Temperatur und ED des aktuellen oder letzten Drucks; Link zum Druckbericht (dort Strom nach Grundlast/Düse/Bett)
|
- **Verlauf** Temperatur und ED des aktuellen oder letzten Drucks; Links zu Datei und Druckbericht (dort Kosten: Filament + Grundlast/Düse/Bett)
|
||||||
|
- **Als Nächstes** (nur wenn der Drucker frei und die Platte quittiert ist): **Nochmal** der letzten Datei, Hinweis zur Warteschlange, passende Dateien nach Familie/Material — Start mit Standardwerten oder **Anpassen…** unter Dateien
|
||||||
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
|
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
|
||||||
- **Bettnetz**-Heatmap, wenn vorhanden
|
- **Bettnetz**-Heatmap, wenn vorhanden
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Warteschlange und Ablauf
|
# Warteschlange und Ablauf
|
||||||
|
|
||||||
Stand **18.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
|
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).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
@@ -1,6 +1,8 @@
|
|||||||
# Grotti — Community-Vorstellung
|
# Grotti — Community-Vorstellung
|
||||||
|
|
||||||
Screenshots aus dem laufenden Betrieb (v3.2.0, Okt. 2026). Zielgruppe: mittlere bis große Printfarmen.
|
Screenshots aus dem laufenden Betrieb (v3.4.0, Okt. 2026). Zielgruppe: mittlere bis große Printfarmen.
|
||||||
|
|
||||||
|
Logo und Repo-Banner: [`docs/brand/`](../brand/). Haupt-README: [`../../README.md`](../../README.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## 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 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.
|
- **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.
|
- **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.
|
- **Normale Drucke** und „Nochmal“ schieben nicht — nach dem Druck muss man auf der Wand quittieren.
|
||||||
|
|
||||||
@@ -73,8 +73,6 @@ Grotti-Schalter „Trockenlauf / PROBE statt z_push“ noch nicht gebaut — bis
|
|||||||
|
|
||||||
## Backlog (später)
|
## 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.
|
- **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).
|
- **Kobra S1** — eigener Abräum-Ablauf (geschlossen, Tür).
|
||||||
|
|
||||||
|
|||||||
@@ -134,8 +134,23 @@ Ohne Map: Rinkhals füllt oft Identity T0→Platz0 … — deshalb Grotti bei ei
|
|||||||
| Slots anzeigen | Moonraker `mmu` (Rinkhals) | `mmu_ace.py` |
|
| Slots anzeigen | Moonraker `mmu` (Rinkhals) | `mmu_ace.py` |
|
||||||
| Kein Firmware-Restart | abgelehnt | Rinkhals #32, `patch_klipper_restart` |
|
| 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
|
## Offene Grenzen
|
||||||
|
|
||||||
- Quellcode von **gkapi** (Task-State-Machine, exakte 10101-Bedingungen) ist nicht öffentlich.
|
- 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.
|
- 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.
|
- 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
|
||||||
|
|
||||||
@@ -8,13 +8,13 @@ Jede Spule in Grotti bekommt automatisch eine `chargen_nr` der Form `S-YYYYMMDD-
|
|||||||
|
|
||||||
Scannen: auf Material den Code tippen oder per USB-Handscanner (Tastatur + Enter). Lookup `GET /api/v1/spulen/by-code/<code>`. QR auf dem Etikett öffnet `/material?spool=<id>`.
|
Scannen: auf Material den Code tippen oder per USB-Handscanner (Tastatur + Enter). Lookup `GET /api/v1/spulen/by-code/<code>`. QR auf dem Etikett öffnet `/material?spool=<id>`.
|
||||||
|
|
||||||
Etikett: `/etikett/<id>`, Druck über den Browser. QR per lokalem `qrcode.min.js` (`web/static/`). Basis-URL für den QR kommt aus Einstellungen → **FQDN** (`config.json` Key `fqdn`); leer = Host der aktuellen Anfrage. Beispiel: `https://grotti.example.de/material?spool=12`.
|
Etikett: `/etikett/<id>`, Druck über den Browser. QR per lokalem `qrcode.min.js` (`web/static/`). Basis-URL für den QR kommt aus Einstellungen → **FQDN** (`config.json` Key `fqdn`); leer = Host der aktuellen Anfrage. Beispiel: `https://grotti.example.de/material?spool=12`. Format (Breite/Höhe, `@page`) aus Einstellungen → **Etiketten** (`config.json` Key `etikett`, Standard 70×40 mm).
|
||||||
|
|
||||||
## Druck-Chargen
|
## 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.
|
`D-YYYYMMDD-0001` vergibt der Watcher am aktiven Lauf (History-Job, sonst Live). Die nächste Nummer zählt Läufe und schon vergebene Objekt-Etiketten, damit eine Charge nicht doppelt entsteht. `adopt_live_run` übernimmt die Nummer nur auf einen fertigen History-Lauf ohne eigene Charge. Objekt-Etiketten (`D-…-01`) wandern nur mit, wenn der Ziel-Lauf dieselbe Charge behält; sonst fallen sie mit dem Live-Lauf weg und werden unter der verbleibenden Charge neu vergeben.
|
||||||
|
|
||||||
Druck über `/etikett/lauf/<id>?mode=druck|objekt`. Auf dem Bogen: Charge, **Soll … g** (Slicer-Filamentmenge), Drucker, Material (Abzug oder Send/ACE/Lauf-Spule), QR 20 mm im 70‑mm-Bogen. Wand: Knopf **Etikett** bei Druck/Pause. Urteil Ausschuss ändert die Charge nicht — gedruckte Bögen verwerfen.
|
Druck über `/etikett/lauf/<id>?mode=druck|objekt`. Auf dem Bogen: Charge, **Soll … g** (Slicer-Filamentmenge), Drucker, Material (Abzug oder Send/ACE/Lauf-Spule), QR proportional zur Etikettenhöhe. Wand: Knopf **Etikett** bei Druck/Pause. Urteil Ausschuss ändert die Charge nicht — gedruckte Bögen verwerfen.
|
||||||
|
|
||||||
## 3D-Pfad
|
## 3D-Pfad
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ In der Bibliothek gespeichert:
|
|||||||
|
|
||||||
| Feld | Bedeutung |
|
| 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 |
|
| `weight_g` / `estimated_s` | Filamentgewicht und Druckzeit |
|
||||||
| `printer_family` | aus `printer_model` |
|
| `printer_family` | aus `printer_model` |
|
||||||
| `slice_meta` | jsonb: `layer_height`, `layers`, `max_z_mm`, `nozzle_mm`, `temp_nozzle`, `temp_bed`, `slicer` |
|
| `slice_meta` | jsonb: `layer_height`, `layers`, `max_z_mm`, `nozzle_mm`, `temp_nozzle`, `temp_bed`, `slicer` |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Grotti: Alltag und Oberfläche
|
# Grotti: Alltag und Oberfläche
|
||||||
|
|
||||||
Stand **18.10.2026**, v3.2.0.
|
Stand **18.10.2026**, v3.3.0.
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# MCP-Server
|
# MCP-Server
|
||||||
|
|
||||||
Stand **15.10.2026**. Agenten steuern Grotti nur über die HTTP-API mit einem API-Token.
|
Stand **19.10.2026**. Agenten steuern Grotti nur über die HTTP-API mit einem API-Token.
|
||||||
|
|
||||||
|
Beim Connect bekommt der Client die Server-Anweisung aus `grotti/mcp_server.py` (`INSTRUCTIONS`): Deutsch antworten, Host und Datei-id nachschlagen, erst lesen dann schreiben, `confirm=true` nur auf klaren Wunsch, Warteschlange vor Sofortstart, Filament-Pause nicht blind fortsetzen, `clear_bed` ist nur eine Quittung, kein G-code und kein Firmware-Restart.
|
||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# Makros und G-code: Kobra S1 und Kobra 3
|
||||||
|
|
||||||
|
Stand **19.10.2026**. Interne Referenz — nicht unter `/doku`. Messwerte und Bettnetz-Story: `D:\Projekte\3DP\FDM\Wissen\Drucker\` (`Kobra S1.md`, `Kobra 3.md`). Stack: [Anycubic-Stack](Anycubic-Stack.md).
|
||||||
|
|
||||||
|
Quellen: `printer/gcode/help` (11.10. auf den Maschinen), GoKlipper/`extras_ace.go`, Rinkhals-RE `GOKLIPPER_ACE_GCODE_COMMANDS.md`, Grotti-Policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht tun
|
||||||
|
|
||||||
|
| Nicht | Warum | Stattdessen |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `FIRMWARE_RESTART` / `RESTART` | gklib Deadlock bis Power-Cycle (Rinkhals #32) | Display-Reboot oder Strom; hängenden Job: **Fehler löschen** (`SDCARD_RESET_FILE`) |
|
||||||
|
| `LEVIQ` / `LEVIQ2` aus Mainsail | Heizung + volle Nivellierfolge, fährt unerwartet | Am Display Auto-Level; Einzelschritte nur bewusst |
|
||||||
|
| `SAVE_CONFIG` aus Mainsail | schreibt u. a. Z-Offset mit | Dauerhaft nur gezielt / nach Display-Nivellieren |
|
||||||
|
| Nur Moonraker-Cancel | Anycubic-Task bleibt → Start-Code **10101** | Cancel + MQTT-stop (Grotti `abbrechen`) |
|
||||||
|
| Community-`ACE_SET_SLOT` / manuelles `G9111` als Grotti-Pfad | Grotti mappt über `.acm` + MQTT | Map in der App; Slice-`G9111` im Start-G-code belassen |
|
||||||
|
| `minimum_cruise_ratio` auf **0** (S1) | Moonraker hängt auf dieser Firmware | bei **0,05** belassen |
|
||||||
|
| `G28 Y` mit Teilen auf dem Bett (K3) | Sensorless-Home nach **vorn** über die Teile | Bett leeren; Abräumen ohne `G28 Z` |
|
||||||
|
| Blind `FEED` / `FEED_FILAMENT` ohne Unwind | Blockade bis Neustart (RE-Doku) | Unload/Unwind-Sequenz einhalten |
|
||||||
|
| `SET_KINEMATIC_POSITION` mit erfundener Z nach Stepper-aus | Portal sackt → Null daneben, Probe „No trigger“ oder Crash | echte letzte XYZ; oder Bett leer + `G28` |
|
||||||
|
| Freie G-code-Konsole in Grotti für Experimente | Policy | nur bekannte, bestätigte Scripts |
|
||||||
|
| Abräumen-Makro in `printer.cfg` pflegen | Rinkhals schreibt Config neu | Grotti schickt Scripts per Moonraker |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vanilla-Klipper-Alltag (beide Typen)
|
||||||
|
|
||||||
|
Befehl → was er macht. Anycubic-Hinweis nur wo nötig.
|
||||||
|
|
||||||
|
### Homes und Bewegung
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `G28` | Homed die genannten Achsen (ohne Argument: alle). **K3:** Y-Home fährt nach vorn — Bett muss leer sein. |
|
||||||
|
| `G0` / `G1` | Lineare Bewegung / Extrusion. Absolute/relative Modi wie üblich (`G90`/`G91`, `M82`/`M83`). |
|
||||||
|
| `M400` | Wartet, bis alle Moves fertig sind. |
|
||||||
|
| `SET_VELOCITY_LIMIT` | Limits für Speed/Accel zur Laufzeit. |
|
||||||
|
| `SET_KINEMATIC_POSITION` | Setzt die interne Position ohne zu fahren. Nach `M84`/Leerlauf riskant — siehe „Nicht tun“. |
|
||||||
|
| `SET_IDLE_TIMEOUT` | Idle-Zeit bis Stepper aus. Abräumen hält Idle bewusst länger. |
|
||||||
|
| `M84` / `M18` | Stepper aus → Achsen unhomed. Slice-Ende K3 macht das oft; Abräumen entfernt es beim Upload. |
|
||||||
|
|
||||||
|
### Heizen
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `M104` / `M109` | Düse Soll / warten bis erreicht. |
|
||||||
|
| `M140` / `M190` | Bett Soll / warten. |
|
||||||
|
| `TURN_OFF_HEATERS` | Düse und Bett aus. |
|
||||||
|
| `PID_CALIBRATE` | PID für Extruder oder Bett kalibrieren. Danach oft `SAVE_CONFIG` — auf diesen Maschinen vorsichtig. |
|
||||||
|
| `M106` / `M107` | Teilkühlung an / aus. |
|
||||||
|
|
||||||
|
### Drucklauf
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `PAUSE` / `RESUME` / `CANCEL_PRINT` | Rinkhals-Makros um Pause/Weiter/Abbruch. Moonraker-Cancel allein räumt den Anycubic-Task nicht. |
|
||||||
|
| `SDCARD_RESET_FILE` | Löscht hängenden virtual-sdcard-/Druckstatus („Fehler löschen“ in Grotti). |
|
||||||
|
| `PRINT_STATS` | Status des laufenden/letzten Jobs (Objekt auch über Moonraker). |
|
||||||
|
|
||||||
|
### Bettnetz und Probe
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `BED_MESH_CALIBRATE` | Tastet das Netz und schaltet es aktiv. |
|
||||||
|
| `BED_MESH_OUTPUT` | Gibt die Punkte aus, ohne zu fahren. |
|
||||||
|
| `BED_MESH_MAP` | Schreibt das interpolierte Netz in die Konsole. |
|
||||||
|
| `BED_MESH_CLEAR` | Schaltet die Korrektur aus. |
|
||||||
|
| `BED_MESH_OFFSET` | Verschiebt das Netz in X/Y. |
|
||||||
|
| `BED_MESH_PROFILE` | `LOAD` / `SAVE` / `REMOVE` für Profile. Dauerhaft erst mit `SAVE_CONFIG`. |
|
||||||
|
| `PROBE` | Ein Tastpunkt an der aktuellen Position. |
|
||||||
|
| `PROBE_ACCURACY` | Mehrfach tasten, Streuung ausgeben. |
|
||||||
|
| `PROBE_CALIBRATE` | Sensor kalibrieren (nicht der Z-Regler am Display). |
|
||||||
|
| `Z_OFFSET_APPLY_PROBE` | Laufenden G-code-Offset in `probe.z_offset` schreiben. |
|
||||||
|
| `SET_GCODE_OFFSET` | Offset nur für den aktuellen Auftrag. |
|
||||||
|
| `SAVE_CONFIG` | Schreibt Config dauerhaft — **nicht** blind aus Mainsail. |
|
||||||
|
|
||||||
|
### Diagnose
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `QUERY_ENDSTOPS` / `M119` | Endstop-Zustände. |
|
||||||
|
| `GET_POSITION` / `M114` | Toolhead-/Stepper-Position. |
|
||||||
|
| `QUERY_ADC` | ADC-Werte (z. B. Thermistoren). |
|
||||||
|
| `DUMP_TMC` | TMC-Register (Stepper nennen). |
|
||||||
|
|
||||||
|
### Slice-Start Anycubic
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `G9111` | Vorheizen aus dem Slicer: typisch `bedTemp=…` und `extruderTemp=…`. Steht im Maschinen-Start-G-code. Grotti ersetzt das nicht und startet auch nicht darüber. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ACE (gemeinsam)
|
||||||
|
|
||||||
|
Zwei Namenswelten:
|
||||||
|
|
||||||
|
1. **Open-Source** GoKlipper `extras_ace.go` (BunnyACE-Stil): `ACE_CHANGE_TOOL`, `ACE_FEED`, `ACE_RETRACT`, `ACE_SET_SLOT`, `ACE_QUERY_SLOTS`, `ACE_START_DRYING` / `ACE_STOP_DRYING`, Feed-Assist, Endless Spool, …
|
||||||
|
2. **Ausgeliefertes Binary** (Rinkhals-RE): u. a. `FEED_FILAMENT`, `UNWIND_FILAMENT`, `UNWIND_ALL_FILAMENT`, `SET_CURRENT_FILAMENT`, `ACE_INFO`, `ACE_STATUS`, Cutter-/Pipe-Befehle.
|
||||||
|
|
||||||
|
Grotti nutzt **keine** Community-Makros `ACE_SET_SLOT` / manuelles Tool-Mapping per G-code. Zuordnung: Sidecar `.acm` + MQTT `ams_settings` ([Senden-Queue](Senden-Queue.md), [ACE-Live](ACE-Live.md)). Slots anzeigen: Moonraker-Objekt `mmu` (Rinkhals). Trocknen: Moonraker `POST /server/filament_hub/start_drying` (nicht zwingend der G-code).
|
||||||
|
|
||||||
|
| Befehl (Auswahl) | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `ACE_CHANGE_TOOL` / Tool-Macros `T0`… | Werkzeugwechsel über ACE. |
|
||||||
|
| `ACE_FEED` / `FEED_FILAMENT` | Filament vom Gate zur Düse laden. **Vorher unloaden.** |
|
||||||
|
| `ACE_RETRACT` / `UNWIND_FILAMENT` | Zurückziehen / parkieren. |
|
||||||
|
| `ACE_SET_SLOT` | Inventar Slot setzen — Grotti meidet das. |
|
||||||
|
| `ACE_START_DRYING` / `ACE_STOP_DRYING` | Trockner; alternativ `filament_hub`-API. |
|
||||||
|
| `ACE_ENABLE_ENDLESS_SPOOL` / `…_DISABLE` / `…_STATUS` | Endless Spool. |
|
||||||
|
|
||||||
|
Details und Parameter: Rinkhals `docs/GOKLIPPER_ACE_GCODE_COMMANDS.md` (lokal unter `D:\Projekte\_ref\Rinkhals\`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kobra S1 — Anycubic-Makros
|
||||||
|
|
||||||
|
Config-Sektion heißt `[leviQ3]`, die Befehle auf dem Gerät heißen **`LEVIQ2`**. Stecken in GoKlipper. Hilfe gelesen 11.10. über `printer/gcode/help`.
|
||||||
|
|
||||||
|
### LeviQ2
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `LEVIQ2` | Ganze Nivellierfolge: heizen, wischen, antasten. **Nicht** aus Mainsail. `SYNC=enable` startet danach neu. |
|
||||||
|
| `LEVIQ2_PREHEATING` | Vorheizen: Bett 55 °C, Düse 170 °C, dann `HOME_XY`. `S` = Düsentemp, `E` = Temp nach dem Wischen. |
|
||||||
|
| `LEVIQ2_WIPING` | Wischen: Netz löschen, Düse auf 140 °C, intern `G9112`, dann `G28 Z`. |
|
||||||
|
| `LEVIQ2_PROBE` | Antasten: `BED_MESH_CALIBRATE`, Lüfter/Bett aus, Z auf 20 mm, `HOME_XY`, Düse aus. |
|
||||||
|
| `LEVIQ_SET_ZERO` | Z-Basis und Home-Position auf 0. |
|
||||||
|
| `LEVIQ2_AUTO_ZOFFSET` | Löscht laut Hilfe den Koeffizienten K. |
|
||||||
|
| `LEVIQ2_AUTO_ZOFFSET_ON_OFF` | Schaltet automatischen Z-Offset. |
|
||||||
|
| `LEVIQ2_TEMP_OFFSET` | Temperaturausgleich (`expansion_factor` / `zoffset_base` in der Config). Fehlt im veröffentlichten Quelltext. |
|
||||||
|
| `HOME_XY` | Nur X/Y homen (von LeviQ-Schritten genutzt). |
|
||||||
|
|
||||||
|
Netz: 5×5, X/Y 5–245 mm. Details: Wissen `Kobra S1.md`.
|
||||||
|
|
||||||
|
### Wipe (Pad hinten)
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `WIPE_ENTER` | Anfahrt Pad (Y 250 → X 81, Y 273). |
|
||||||
|
| `WIPE_NOZZLE` | Wischbahn; wiederholt die Strecke X 96↔81. |
|
||||||
|
| `WIPE_STOP` | Kurze Stopp-Bahnen, dann X 75. |
|
||||||
|
| `WIPE_EXIT` | Weg vom Pad (X 83, Y 250). |
|
||||||
|
| `TO_THROW_POSITION` | Position zum Abwerfen (~X 47, Y 276). |
|
||||||
|
| `MOVE_TO_SWEEP_POSITION` | Wisch-/Kehrposition (gleiche Gegend). |
|
||||||
|
| `MOVE_TO_IMPACT_POSITION` | Weg vom Pad nach vorn rechts (Y 25, X 261). |
|
||||||
|
| `PRINT_LINE` | Purge-Linie bei Y 251, Z 0,28 bis X 125. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kobra 3 — Anycubic-Makros
|
||||||
|
|
||||||
|
Befehle gelesen 11.10. auf `da26` (`92ee`/`6e41` gleiche `printer.generated.cfg`). Im veröffentlichten Quelltext heißen Teile noch `LEVIQ3_*` / `LEVIQ2_PROBE` — auf den drei Druckern gelten die Namen unten.
|
||||||
|
|
||||||
|
### LeviQ
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `LEVIQ` | Ganze Folge inkl. Z-Offset-Rechnung. **Nicht** aus Mainsail. `SYNC=enable` startet danach neu. |
|
||||||
|
| `LEVIQ_PREHEATING` | Z-Basis 0, Bett 60 °C, Düse 170 °C, `HOME_XY`, auf Düse warten. `S` / `E` wie bei S1. |
|
||||||
|
| `LEVIQ_WIPING` | Netz löschen, Düse 140 °C, `G9112`, `G28 Z`. |
|
||||||
|
| `LEVIQ_PROBE` | `BED_MESH_CALIBRATE`, Lüfter/Bett aus, Z auf 30 mm, `HOME_XY`, Düse aus. |
|
||||||
|
| `LEVIQ_SET_ZERO` | Z-Basis und Home auf 0. |
|
||||||
|
| `LEVIQ_AUTO_ZOFFSET` | Löscht Koeffizient K (Hilfe). |
|
||||||
|
| `LEVIQ_AUTO_ZOFFSET_ON_OFF` | Automatischen Z-Offset schalten (oft an). |
|
||||||
|
| `LEVIQ_TEMP_OFFSET` | Temperaturausgleich (Hilfezeile „LEVIQ3 TEMP OFFSET“). Fehlt im Open-Source-Code. |
|
||||||
|
| `HOME_XY` | Nur X/Y homen. |
|
||||||
|
|
||||||
|
Netz: 5×5, X 15–240, Y 15–235 mm. Details: Wissen `Kobra 3.md`.
|
||||||
|
|
||||||
|
### Wipe
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `WIPE_NOZZLE` | Dreimal `SINGLE_WIPE_NOZZLE` (Y 260/259, X 46–90, Z −0,8↔1, Lüfter an). |
|
||||||
|
| `SINGLE_WIPE_NOZZLE` | Eine einzelne Wischbahn. |
|
||||||
|
| `MOVE_HEAT_POS` | Z 15, dann X 278 (Heiz-/Parknahe Position). |
|
||||||
|
| `UNDERLINE` | Purge-Linie Z 0,28 bis X 180, ~40 mm Extrusion. |
|
||||||
|
| `MOVE_END_POS` | Ende/Park X 5, Y 242. |
|
||||||
|
|
||||||
|
### Wägezelle
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `CS1237_DUMP` | Debug-Ausgabe zum CS1237 (Z-Sensor / Kraft). Moonraker liefert keine laufende Gramm-Anzeige; `probe.last_z_result` fehlt. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Siehe auch
|
||||||
|
|
||||||
|
- [Anycubic-Stack](Anycubic-Stack.md) — MQTT-Start/Stop, 10101, kein Firmware-Restart
|
||||||
|
- [Senden-Queue](Senden-Queue.md) — `.acm`, kein Community-ACE-Makro
|
||||||
|
- [Abraeumen](Abraeumen.md) — K3 Farm-Scripts statt Druckermakro
|
||||||
|
- [ACE-Live](ACE-Live.md) — Live-Mapping und Trailer
|
||||||
@@ -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“
|
## 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
|
## 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 |
|
| `fertig` | Watcher, `printing`→`complete` | nein, Platte unter „Aufgaben“ | an |
|
||||||
| `fehler` | Watcher, Zustand `error` | ja | an |
|
| `fehler` | Watcher, Zustand `error` | ja | an |
|
||||||
| `abbruch` | Watcher, `printing`/`paused`→`cancelled`/`standby` | ja | wie `fehler` |
|
| `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 |
|
| `spule` | Watcher, Rest unter Job-Gewicht | ja | an |
|
||||||
| `queue` | Dispatcher: Queue-Fehler, Ablauf gestoppt | ja | an |
|
| `queue` | Dispatcher: Queue-Fehler, Ablauf gestoppt | ja | an |
|
||||||
| `start` | Watcher: Druck startet, Pause, Fortsetzen | nein | aus |
|
| `start` | Watcher: Druck startet, Pause, Fortsetzen | nein | aus |
|
||||||
|
|||||||
@@ -3,3 +3,11 @@
|
|||||||
Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App.
|
Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App.
|
||||||
|
|
||||||
Nutzer-Handbuch: `docs/Anleitung-*.md`.
|
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 |
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -73,13 +73,182 @@ def normalize_mapping(raw):
|
|||||||
ams = int(item.get("ams_index"))
|
ams = int(item.get("ams_index"))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
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
|
continue
|
||||||
seen.add(paint)
|
seen.add(paint)
|
||||||
out.append({"paint_index": paint, "ams_index": ams})
|
out.append({"paint_index": paint, "ams_index": ams})
|
||||||
return out
|
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):
|
def build_acm(filaments, mapping, slots):
|
||||||
filaments = filaments or []
|
filaments = filaments or []
|
||||||
slots = slots or []
|
slots = slots or []
|
||||||
@@ -88,7 +257,7 @@ def build_acm(filaments, mapping, slots):
|
|||||||
for item in normalize_mapping(mapping):
|
for item in normalize_mapping(mapping):
|
||||||
paint = item["paint_index"]
|
paint = item["paint_index"]
|
||||||
ams = item["ams_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 {}
|
slot = slot_by_index.get(ams) or {}
|
||||||
paint_color = color_channels(filament.get("color")) or [0, 0, 0]
|
paint_color = color_channels(filament.get("color")) or [0, 0, 0]
|
||||||
ams_color = color_channels(slot.get("color")) or paint_color
|
ams_color = color_channels(slot.get("color")) or paint_color
|
||||||
|
|||||||
@@ -287,6 +287,10 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
|
|||||||
text, code = _report_error(body, "Druckabbruch abgelehnt")
|
text, code = _report_error(body, "Druckabbruch abgelehnt")
|
||||||
state["error"] = text
|
state["error"] = text
|
||||||
state["code"] = code
|
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
|
# start: heating / leveling / printing / preheating = angenommen
|
||||||
# stop: stoped / stopped / done = angenommen
|
# stop: stoped / stopped / done = angenommen
|
||||||
state["done"] = True
|
state["done"] = True
|
||||||
@@ -477,6 +481,88 @@ def anycubic_task(host, use_cache=True):
|
|||||||
return None
|
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):
|
def cancel_print(host, timeout=15):
|
||||||
"""MQTT-Stop wie Rinkhals Docs: action stop, taskid -1 (Anycubic-Task)."""
|
"""MQTT-Stop wie Rinkhals Docs: action stop, taskid -1 (Anycubic-Task)."""
|
||||||
request = {
|
request = {
|
||||||
|
|||||||
+27
-2
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
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):
|
def _group_key(material_type, color):
|
||||||
return (
|
return (
|
||||||
@@ -10,6 +14,27 @@ def _group_key(material_type, color):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
def compute(conn):
|
||||||
"""Wartende Jobs × Dateigewicht vs. Spulenrest, gruppiert nach Sorte/Farbe."""
|
"""Wartende Jobs × Dateigewicht vs. Spulenrest, gruppiert nach Sorte/Farbe."""
|
||||||
queue_rows = conn.execute(
|
queue_rows = conn.execute(
|
||||||
@@ -52,6 +77,7 @@ def compute(conn):
|
|||||||
"""
|
"""
|
||||||
SELECT material_type, color, COALESCE(SUM(remaining_g), 0) AS remaining_g
|
SELECT material_type, color, COALESCE(SUM(remaining_g), 0) AS remaining_g
|
||||||
FROM spools
|
FROM spools
|
||||||
|
WHERE archived_at IS NULL
|
||||||
GROUP BY material_type, color
|
GROUP BY material_type, color
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -67,8 +93,7 @@ def compute(conn):
|
|||||||
)
|
)
|
||||||
shortfall = []
|
shortfall = []
|
||||||
for item in needed:
|
for item in needed:
|
||||||
key = _group_key(item["material_type"], item["color"])
|
have = _stock_for_need(stock_map, item["material_type"], item["color"])
|
||||||
have = stock_map.get(key, 0.0)
|
|
||||||
gap = item["weight_g"] - have
|
gap = item["weight_g"] - have
|
||||||
if gap > 0.5:
|
if gap > 0.5:
|
||||||
shortfall.append({
|
shortfall.append({
|
||||||
|
|||||||
+75
-4
@@ -1,6 +1,7 @@
|
|||||||
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
|
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -9,11 +10,24 @@ from grotti.errors import GrottiError
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
|
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
|
||||||
CONFIG_PATH = ROOT / "config.json"
|
|
||||||
DEFAULT_WATCH_REL = "slice"
|
DEFAULT_WATCH_REL = "slice"
|
||||||
_SETTINGS_LOCK = threading.Lock()
|
_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):
|
def _tariff(raw):
|
||||||
tariff = raw.get("tariff") if isinstance(raw, dict) else None
|
tariff = raw.get("tariff") if isinstance(raw, dict) else None
|
||||||
if not isinstance(tariff, dict) or not tariff:
|
if not isinstance(tariff, dict) or not tariff:
|
||||||
@@ -110,6 +124,14 @@ def _oauth(raw):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _http_threads(raw):
|
||||||
|
try:
|
||||||
|
count = int(raw.get("http_threads") or 16)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
count = 16
|
||||||
|
return max(2, min(32, count))
|
||||||
|
|
||||||
|
|
||||||
def _ensure_secret_key(path, raw):
|
def _ensure_secret_key(path, raw):
|
||||||
key = str(raw.get("secret_key") or "").strip()
|
key = str(raw.get("secret_key") or "").strip()
|
||||||
if key:
|
if key:
|
||||||
@@ -121,9 +143,11 @@ def _ensure_secret_key(path, raw):
|
|||||||
|
|
||||||
|
|
||||||
def load():
|
def load():
|
||||||
path = CONFIG_PATH
|
path = config_path()
|
||||||
if not path.exists():
|
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"))
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
host = (raw.get("host") or "127.0.0.1").strip()
|
host = (raw.get("host") or "127.0.0.1").strip()
|
||||||
token = raw.get("token") or ""
|
token = raw.get("token") or ""
|
||||||
@@ -139,6 +163,9 @@ def load():
|
|||||||
if not data_dir.is_absolute():
|
if not data_dir.is_absolute():
|
||||||
data_dir = ROOT / data_dir
|
data_dir = ROOT / data_dir
|
||||||
watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
|
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 {
|
return {
|
||||||
"host": host,
|
"host": host,
|
||||||
"port": int(raw.get("port") or 8770),
|
"port": int(raw.get("port") or 8770),
|
||||||
@@ -149,13 +176,16 @@ def load():
|
|||||||
"secret_key": secret_key,
|
"secret_key": secret_key,
|
||||||
"oauth": _oauth(raw),
|
"oauth": _oauth(raw),
|
||||||
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
|
"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),
|
"energy": _energy(raw),
|
||||||
"telegram": _telegram(raw),
|
"telegram": _telegram(raw),
|
||||||
"ntfy": _ntfy(raw),
|
"ntfy": _ntfy(raw),
|
||||||
"alerts": _alerts(raw),
|
"alerts": _alerts(raw),
|
||||||
"auto_queue": bool(raw.get("auto_queue", True)),
|
"auto_queue": bool(raw.get("auto_queue", True)),
|
||||||
"auto_retry": bool(raw.get("auto_retry", True)),
|
"auto_retry": bool(raw.get("auto_retry", True)),
|
||||||
|
"http_threads": _http_threads(raw),
|
||||||
"_raw": raw,
|
"_raw": raw,
|
||||||
"_path": path,
|
"_path": path,
|
||||||
}
|
}
|
||||||
@@ -204,6 +234,36 @@ def default_watch_suggestion():
|
|||||||
return str((ROOT / DEFAULT_WATCH_REL).resolve())
|
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):
|
def settings_public(cfg):
|
||||||
tg = cfg.get("telegram") or {}
|
tg = cfg.get("telegram") or {}
|
||||||
oauth = cfg.get("oauth") or {}
|
oauth = cfg.get("oauth") or {}
|
||||||
@@ -213,6 +273,7 @@ def settings_public(cfg):
|
|||||||
"auto_queue": bool(cfg.get("auto_queue", True)),
|
"auto_queue": bool(cfg.get("auto_queue", True)),
|
||||||
"auto_retry": bool(cfg.get("auto_retry", True)),
|
"auto_retry": bool(cfg.get("auto_retry", True)),
|
||||||
"fqdn": str(cfg.get("fqdn") or ""),
|
"fqdn": str(cfg.get("fqdn") or ""),
|
||||||
|
"etikett": _etikett({"etikett": cfg.get("etikett") or {}}),
|
||||||
"legacy_token_set": bool(cfg.get("token")),
|
"legacy_token_set": bool(cfg.get("token")),
|
||||||
"energy": dict(cfg.get("energy") or {}),
|
"energy": dict(cfg.get("energy") or {}),
|
||||||
"telegram": {
|
"telegram": {
|
||||||
@@ -248,6 +309,7 @@ def save_settings(
|
|||||||
auto_retry=None,
|
auto_retry=None,
|
||||||
fqdn=None,
|
fqdn=None,
|
||||||
watch_dir=None,
|
watch_dir=None,
|
||||||
|
etikett=None,
|
||||||
):
|
):
|
||||||
"""Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
|
"""Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
|
||||||
with _SETTINGS_LOCK:
|
with _SETTINGS_LOCK:
|
||||||
@@ -261,6 +323,7 @@ def save_settings(
|
|||||||
auto_retry=auto_retry,
|
auto_retry=auto_retry,
|
||||||
fqdn=fqdn,
|
fqdn=fqdn,
|
||||||
watch_dir=watch_dir,
|
watch_dir=watch_dir,
|
||||||
|
etikett=etikett,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -274,6 +337,7 @@ def _save_settings_locked(
|
|||||||
auto_retry=None,
|
auto_retry=None,
|
||||||
fqdn=None,
|
fqdn=None,
|
||||||
watch_dir=None,
|
watch_dir=None,
|
||||||
|
etikett=None,
|
||||||
):
|
):
|
||||||
path = cfg.get("_path") or CONFIG_PATH
|
path = cfg.get("_path") or CONFIG_PATH
|
||||||
raw = dict(cfg.get("_raw") or {})
|
raw = dict(cfg.get("_raw") or {})
|
||||||
@@ -296,6 +360,13 @@ def _save_settings_locked(
|
|||||||
normalized = normalize_fqdn(fqdn)
|
normalized = normalize_fqdn(fqdn)
|
||||||
raw["fqdn"] = normalized
|
raw["fqdn"] = normalized
|
||||||
cfg["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:
|
if energy is not None:
|
||||||
raw_energy = {
|
raw_energy = {
|
||||||
"watts": float(energy.get("watts", 40)),
|
"watts": float(energy.get("watts", 40)),
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ def _yesterday_stats(conn):
|
|||||||
|
|
||||||
def build_text(conn, watcher_latest=None, printers=None):
|
def build_text(conn, watcher_latest=None, printers=None):
|
||||||
stats = _yesterday_stats(conn)
|
stats = _yesterday_stats(conn)
|
||||||
waiting = len([j for j in store.list_print_queue(conn, limit=200) if j.get("status") == "waiting"])
|
waiting = len([
|
||||||
|
j for j in store.list_print_queue(conn, limit=200)["items"]
|
||||||
|
if j.get("status") == "waiting"
|
||||||
|
])
|
||||||
eta = jetzt_mod.farm_eta([
|
eta = jetzt_mod.farm_eta([
|
||||||
{"remain": (live or {}).get("remain") or 0}
|
{"remain": (live or {}).get("remain") or 0}
|
||||||
for live in (watcher_latest or [])
|
for live in (watcher_latest or [])
|
||||||
|
|||||||
+5
-1
@@ -13,6 +13,10 @@ from psycopg import sql
|
|||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
from psycopg_pool import ConnectionPool
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
|
from grotti import logutil
|
||||||
|
|
||||||
|
log = logutil.get("grotti.db")
|
||||||
|
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
|
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
|
||||||
|
|
||||||
@@ -123,7 +127,7 @@ def prepare(url):
|
|||||||
if not exists:
|
if not exists:
|
||||||
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
|
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
|
||||||
except psycopg.Error as exc:
|
except psycopg.Error as exc:
|
||||||
print("Datenbank anlegen: %s" % exc, flush=True)
|
log.warning("Datenbank anlegen: %s", exc)
|
||||||
try:
|
try:
|
||||||
with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
|
with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
|
||||||
apply_migrations(conn)
|
apply_migrations(conn)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Moonraker-Treiber. Rinkhals ist eine Richtlinie darauf, kein zweiter Client.
|
||||||
|
|
||||||
|
Kobra 3 und S1: kein Firmware-Restart (steckt in moonraker.steuern),
|
||||||
|
ACE-Start über MQTT, Kamera als MJPEG unter /webcam/, Bettnetz aus bed_mesh.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from grotti import moonraker
|
||||||
|
|
||||||
|
|
||||||
|
class MoonrakerDriver:
|
||||||
|
transport = "moonraker"
|
||||||
|
rinkhals = True
|
||||||
|
firmware_restart = False
|
||||||
|
|
||||||
|
def snapshot(self, printers):
|
||||||
|
return moonraker.snapshot(printers)
|
||||||
|
|
||||||
|
def query(self, host):
|
||||||
|
return moonraker.query(host)
|
||||||
|
|
||||||
|
def slots(self, host, status=None):
|
||||||
|
return moonraker.slots(host, status)
|
||||||
|
|
||||||
|
def upload(self, host, filename, content, on_progress=None):
|
||||||
|
return moonraker.upload(host, filename, content, on_progress=on_progress)
|
||||||
|
|
||||||
|
def start_print(self, host, filename, bed_leveling=False, ams_settings=None):
|
||||||
|
return moonraker.start_print(
|
||||||
|
host, filename, bed_leveling=bed_leveling, ams_settings=ams_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
def command(self, host, action):
|
||||||
|
name = (action or "").strip().lower()
|
||||||
|
if name == "cancel":
|
||||||
|
name = "abbrechen"
|
||||||
|
elif name == "resume":
|
||||||
|
name = "fortsetzen"
|
||||||
|
return moonraker.command(host, name)
|
||||||
|
|
||||||
|
def clear_print_error(self, host):
|
||||||
|
return moonraker.clear_print_error(host)
|
||||||
|
|
||||||
|
def heaters_off(self, host):
|
||||||
|
return moonraker.heaters_off(host)
|
||||||
+6
-2
@@ -111,7 +111,7 @@ def build(conn, watcher_latest, printers=None, send_jobs=None):
|
|||||||
"SELECT DISTINCT host FROM print_queue WHERE status = 'running'"
|
"SELECT DISTINCT host FROM print_queue WHERE status = 'running'"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
}
|
}
|
||||||
queue = store.list_print_queue(conn, limit=80)
|
queue = store.list_print_queue(conn, limit=80)["items"]
|
||||||
waiting = [j for j in queue if j.get("status") == "waiting"]
|
waiting = [j for j in queue if j.get("status") == "waiting"]
|
||||||
errors = [j for j in queue if j.get("status") == "error"]
|
errors = [j for j in queue if j.get("status") == "error"]
|
||||||
running_q = [j for j in queue if j.get("status") == "running"]
|
running_q = [j for j in queue if j.get("status") == "running"]
|
||||||
@@ -255,13 +255,17 @@ def build(conn, watcher_latest, printers=None, send_jobs=None):
|
|||||||
live_by_host[host]["display_step_index"] = job.get("display_step_index")
|
live_by_host[host]["display_step_index"] = job.get("display_step_index")
|
||||||
live_by_host[host]["step_index"] = job.get("step_index")
|
live_by_host[host]["step_index"] = job.get("step_index")
|
||||||
else:
|
else:
|
||||||
|
phase = (job.get("phase") or "").lower()
|
||||||
|
starting = phase in ("starting", "await_print")
|
||||||
running.append({
|
running.append({
|
||||||
"host": host,
|
"host": host,
|
||||||
"name": job.get("printer_name") or host,
|
"name": job.get("printer_name") or host,
|
||||||
"state": "running",
|
"state": "starting" if starting else "running",
|
||||||
|
"starting": starting,
|
||||||
"progress": job.get("progress") or 0,
|
"progress": job.get("progress") or 0,
|
||||||
"remain": job.get("remain") or 0,
|
"remain": job.get("remain") or 0,
|
||||||
"filename": job.get("filename") or "",
|
"filename": job.get("filename") or "",
|
||||||
|
"message": job.get("message") or "",
|
||||||
"queue_id": job.get("id"),
|
"queue_id": job.get("id"),
|
||||||
"is_ablauf": bool(job.get("is_ablauf")),
|
"is_ablauf": bool(job.get("is_ablauf")),
|
||||||
})
|
})
|
||||||
|
|||||||
+727
-150
File diff suppressed because it is too large.
Load diff
+377
-28
@@ -46,7 +46,14 @@ def sync_watch(conn, cfg, force=False):
|
|||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if not force and _LAST_WATCH_SYNC and now - _LAST_WATCH_SYNC < _WATCH_MIN_INTERVAL_S:
|
if not force and _LAST_WATCH_SYNC and now - _LAST_WATCH_SYNC < _WATCH_MIN_INTERVAL_S:
|
||||||
return {"gelesen": 0, "hinweis": "", "skipped": True}
|
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 = []
|
seen = []
|
||||||
|
gelesen = 0
|
||||||
for path in folder.rglob("*"):
|
for path in folder.rglob("*"):
|
||||||
if not path.is_file() or skip_path(path, cfg["data_dir"]):
|
if not path.is_file() or skip_path(path, cfg["data_dir"]):
|
||||||
continue
|
continue
|
||||||
@@ -54,13 +61,71 @@ def sync_watch(conn, cfg, force=False):
|
|||||||
if not (lower.endswith(".gcode") or lower.endswith(".3mf")):
|
if not (lower.endswith(".gcode") or lower.endswith(".3mf")):
|
||||||
continue
|
continue
|
||||||
try:
|
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)
|
store.upsert_scanned(conn, meta_for(path), force=force)
|
||||||
seen.append(str(path.resolve()))
|
gelesen += 1
|
||||||
except (OSError, zipfile.BadZipFile):
|
except (OSError, zipfile.BadZipFile):
|
||||||
continue
|
continue
|
||||||
store.forget_missing_watch(conn, seen)
|
store.forget_missing_watch(conn, seen)
|
||||||
|
link_watch_files_to_projects(conn, cfg)
|
||||||
|
dropped = drop_upload_watch_duplicates(conn, cfg)
|
||||||
_LAST_WATCH_SYNC = now
|
_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):
|
def refresh_file_meta(conn, force=False):
|
||||||
@@ -80,20 +145,7 @@ def refresh_file_meta(conn, force=False):
|
|||||||
meta = meta_for(path)
|
meta = meta_for(path)
|
||||||
except (OSError, zipfile.BadZipFile):
|
except (OSError, zipfile.BadZipFile):
|
||||||
continue
|
continue
|
||||||
store.update_file(
|
_apply_meta(conn, row["id"], row, meta)
|
||||||
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 {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
updated += 1
|
updated += 1
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
@@ -213,33 +265,134 @@ def delete_folder(cfg, rel):
|
|||||||
return folder
|
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):
|
def move_file(conn, cfg, file_id, folder):
|
||||||
"""Watch-Datei in Unterordner (oder Wurzel) verschieben; ID bleibt."""
|
"""Watch- oder Upload-Datei in Slice-Unterordner (oder Wurzel); Upload wird Watch."""
|
||||||
root = require_watch_dir(cfg)
|
root = require_watch_dir(cfg)
|
||||||
folder = normalize_folder(folder)
|
folder = normalize_folder(folder)
|
||||||
row = store.get_file_row(conn, file_id)
|
row = store.get_file_row(conn, file_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
||||||
if row.get("source") != "watch":
|
source = row.get("source") or ""
|
||||||
raise GrottiError(
|
if source not in ("watch", "upload"):
|
||||||
"Nur Dateien aus dem Slice-Ordner lassen sich verschieben.",
|
raise GrottiError("Diese Datei lässt sich nicht verschieben.", "datei")
|
||||||
"watch",
|
|
||||||
)
|
|
||||||
src = Path(row.get("stored_path") or "")
|
src = Path(row.get("stored_path") or "")
|
||||||
if not src.is_file():
|
if not src.is_file():
|
||||||
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
||||||
dest_dir = path_under_watch(root, folder)
|
dest_dir = path_under_watch(root, folder)
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
dest = dest_dir / src.name
|
dest = dest_dir / src.name
|
||||||
if dest.resolve() == src.resolve():
|
if dest.resolve() == src.resolve() and source == "watch":
|
||||||
return store.file_out(row, watch_dir=root)
|
return store.file_out(row, watch_dir=root, conn=conn)
|
||||||
if dest.exists():
|
if dest.exists() and dest.resolve() != src.resolve():
|
||||||
dest = unique_path(dest_dir, src.name)
|
dest = unique_path(dest_dir, src.name)
|
||||||
try:
|
try:
|
||||||
shutil.move(str(src), str(dest))
|
shutil.move(str(src), str(dest))
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise GrottiError("Verschieben fehlgeschlagen: %s" % exc, "ordner") from exc
|
raise GrottiError("Verschieben fehlgeschlagen: %s" % exc, "ordner") from exc
|
||||||
stat = dest.stat()
|
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(
|
store.update_file_location(
|
||||||
conn,
|
conn,
|
||||||
file_id,
|
file_id,
|
||||||
@@ -249,19 +402,215 @@ def move_file(conn, cfg, file_id, folder):
|
|||||||
mtime=stat.st_mtime,
|
mtime=stat.st_mtime,
|
||||||
)
|
)
|
||||||
row = store.get_file_row(conn, file_id)
|
row = store.get_file_row(conn, file_id)
|
||||||
return store.file_out(row, watch_dir=root)
|
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):
|
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 = cfg["data_dir"] / "eingang"
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
path = unique_path(directory, filename)
|
path = unique_path(directory, name)
|
||||||
path.write_bytes(content)
|
path.write_bytes(content)
|
||||||
meta = meta_for(path)
|
meta = meta_for(path)
|
||||||
meta["project_id"] = project_id
|
meta["project_id"] = project_id
|
||||||
return store.insert_upload(conn, meta)
|
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):
|
def ensure_library_gcode(conn, cfg, host, filename):
|
||||||
"""Wenn die laufende Datei fehlt: einmal vom Drucker nach Grotti ziehen."""
|
"""Wenn die laufende Datei fehlt: einmal vom Drucker nach Grotti ziehen."""
|
||||||
base = (filename or "").replace("\\", "/").split("/")[-1]
|
base = (filename or "").replace("\\", "/").split("/")[-1]
|
||||||
@@ -271,7 +620,7 @@ def ensure_library_gcode(conn, cfg, host, filename):
|
|||||||
if known:
|
if known:
|
||||||
path = Path(known.get("stored_path") or "")
|
path = Path(known.get("stored_path") or "")
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
return store.file_out(known)
|
return store.file_out(known, conn=conn)
|
||||||
from grotti import moonraker
|
from grotti import moonraker
|
||||||
|
|
||||||
pulled = moonraker.download_gcode(host, base)
|
pulled = moonraker.download_gcode(host, base)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Startet den Marlin-Dienst und lässt ihn laufen.
|
||||||
|
|
||||||
|
Grotti beendet den Prozess nicht. Ein Neustart oder Absturz der Web-App
|
||||||
|
schließt die USB-Leitung nicht. Lauscht der Dienst schon, wird kein zweiter
|
||||||
|
gestartet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
UNIT = "grotti-marlin.service"
|
||||||
|
_CREATE_BREAKAWAY = 0x01000000
|
||||||
|
_DETACHED = 0x00000008
|
||||||
|
_NEW_GROUP = 0x00000200
|
||||||
|
_NO_WINDOW = 0x08000000
|
||||||
|
|
||||||
|
_enabled = False
|
||||||
|
_supervisor = None
|
||||||
|
_guard = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def service_url():
|
||||||
|
return (os.environ.get("GROTTI_MARLIN_URL") or "http://127.0.0.1:8771").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def health_check(url=None):
|
||||||
|
target = (url or service_url()) + "/gesundheit"
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(target, timeout=0.4) as response:
|
||||||
|
return response.status == 200
|
||||||
|
except (OSError, urllib.error.URLError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def unit_installed(run=None):
|
||||||
|
if os.name == "nt" or not shutil.which("systemctl"):
|
||||||
|
return False
|
||||||
|
run = run or subprocess.run
|
||||||
|
try:
|
||||||
|
probe = run(
|
||||||
|
["systemctl", "cat", UNIT],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=3,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return False
|
||||||
|
return getattr(probe, "returncode", 1) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def start_unit(run=None):
|
||||||
|
"""Eigene systemd-Unit, außerhalb von grotti.service. Rückgabe: gestartet versucht."""
|
||||||
|
if not unit_installed(run):
|
||||||
|
return False
|
||||||
|
systemctl = shutil.which("systemctl") or "systemctl"
|
||||||
|
run = run or subprocess.run
|
||||||
|
cmd = [systemctl, "start", UNIT]
|
||||||
|
if shutil.which("sudo"):
|
||||||
|
cmd = ["sudo", "-n", *cmd]
|
||||||
|
try:
|
||||||
|
run(cmd, capture_output=True, timeout=20, check=False)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def spawn_detached(data_dir, popen=None):
|
||||||
|
"""Prozess ohne Konsole und ohne Sitzung von Grotti. Grotti-Ende lässt ihn leben."""
|
||||||
|
folder = Path(data_dir)
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path = folder / "marlin-dienst.log"
|
||||||
|
log_file = open(log_path, "ab", buffering=0)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PYTHONPATH"] = str(ROOT) + os.pathsep + env.get("PYTHONPATH", "")
|
||||||
|
env["PYTHONUNBUFFERED"] = "1"
|
||||||
|
args = [sys.executable, "-m", "grotti.marlin_daemon"]
|
||||||
|
popen = popen or subprocess.Popen
|
||||||
|
kwargs = {
|
||||||
|
"args": args,
|
||||||
|
"cwd": str(ROOT),
|
||||||
|
"env": env,
|
||||||
|
"stdin": subprocess.DEVNULL,
|
||||||
|
"stdout": log_file,
|
||||||
|
"stderr": subprocess.STDOUT,
|
||||||
|
"close_fds": os.name != "nt",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
flags = _DETACHED | _NEW_GROUP | _NO_WINDOW
|
||||||
|
try:
|
||||||
|
proc = popen(creationflags=flags | _CREATE_BREAKAWAY, **kwargs)
|
||||||
|
except OSError:
|
||||||
|
proc = popen(creationflags=flags, **kwargs)
|
||||||
|
else:
|
||||||
|
proc = popen(start_new_session=True, **kwargs)
|
||||||
|
finally:
|
||||||
|
log_file.close()
|
||||||
|
return proc.pid
|
||||||
|
|
||||||
|
|
||||||
|
def launch(data_dir, run=None, popen=None):
|
||||||
|
if start_unit(run):
|
||||||
|
return "systemd"
|
||||||
|
spawn_detached(data_dir, popen=popen)
|
||||||
|
return "prozess"
|
||||||
|
|
||||||
|
|
||||||
|
class MarlinSupervisor:
|
||||||
|
def __init__(self, data_dir, healthy=None, start=None, wait_s=4):
|
||||||
|
self.data_dir = Path(data_dir)
|
||||||
|
self.healthy = healthy or health_check
|
||||||
|
self.start = start or (lambda folder: launch(folder))
|
||||||
|
self.wait_s = wait_s
|
||||||
|
self.error = ""
|
||||||
|
self._last_attempt = 0.0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
running = bool(self.healthy())
|
||||||
|
if running:
|
||||||
|
self.error = ""
|
||||||
|
return {"running": running, "message": "" if running else self.error}
|
||||||
|
|
||||||
|
def ensure(self):
|
||||||
|
if self.healthy():
|
||||||
|
self.error = ""
|
||||||
|
return {"running": True, "started": False, "message": ""}
|
||||||
|
with self._lock:
|
||||||
|
if self.healthy():
|
||||||
|
self.error = ""
|
||||||
|
return {"running": True, "started": False, "message": ""}
|
||||||
|
now = time.monotonic()
|
||||||
|
if self._last_attempt and now - self._last_attempt < 8:
|
||||||
|
return {"running": False, "started": False, "message": self.error}
|
||||||
|
self._last_attempt = now
|
||||||
|
try:
|
||||||
|
self.start(self.data_dir)
|
||||||
|
except OSError as exc:
|
||||||
|
self.error = "Der Marlin-Dienst ließ sich nicht starten: %s" % exc
|
||||||
|
return {"running": False, "started": False, "message": self.error}
|
||||||
|
if self._wait():
|
||||||
|
self.error = ""
|
||||||
|
return {"running": True, "started": True, "message": ""}
|
||||||
|
self.error = "Der Marlin-Dienst ist nicht hochgekommen. Log: %s" % (
|
||||||
|
self.data_dir / "marlin-dienst.log"
|
||||||
|
)
|
||||||
|
return {"running": False, "started": True, "message": self.error}
|
||||||
|
|
||||||
|
def _wait(self):
|
||||||
|
deadline = time.monotonic() + self.wait_s
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if self.healthy():
|
||||||
|
return True
|
||||||
|
time.sleep(0.05)
|
||||||
|
return bool(self.healthy())
|
||||||
|
|
||||||
|
|
||||||
|
def disable():
|
||||||
|
global _enabled, _supervisor
|
||||||
|
with _guard:
|
||||||
|
_enabled = False
|
||||||
|
_supervisor = None
|
||||||
|
|
||||||
|
|
||||||
|
def enable(data_dir):
|
||||||
|
global _enabled, _supervisor
|
||||||
|
with _guard:
|
||||||
|
_supervisor = MarlinSupervisor(data_dir)
|
||||||
|
_enabled = True
|
||||||
|
return _supervisor
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_if_enabled():
|
||||||
|
with _guard:
|
||||||
|
supervisor = _supervisor if _enabled else None
|
||||||
|
if supervisor:
|
||||||
|
return supervisor.ensure()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def current_status():
|
||||||
|
with _guard:
|
||||||
|
supervisor = _supervisor if _enabled else None
|
||||||
|
if supervisor:
|
||||||
|
return supervisor.status()
|
||||||
|
return {"running": health_check(), "message": ""}
|
||||||
+52
-13
@@ -57,6 +57,51 @@ def _write_guard():
|
|||||||
raise RuntimeError("MCP läuft im Read-only-Modus (GROTTI_MCP_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():
|
def main():
|
||||||
try:
|
try:
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
@@ -67,23 +112,17 @@ def main():
|
|||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
server = FastMCP(
|
server = FastMCP("grotti", instructions=INSTRUCTIONS)
|
||||||
"grotti",
|
|
||||||
instructions=(
|
|
||||||
"Grotti steuert die 3D-Drucker-Farm. Destruktive Aktionen brauchen "
|
|
||||||
"confirm=true. Kein freier G-code, kein Firmware-Restart."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@server.tool()
|
@server.tool()
|
||||||
def list_printers(live: bool = True) -> str:
|
def list_printers(live: bool = True) -> str:
|
||||||
"""Druckerliste mit optionalem Live-Zustand."""
|
"""Alle Drucker. live=true liefert Zustand, Temps, Fortschritt, Dateiname. Host ist die IP."""
|
||||||
q = "?live=1" if live else "?live=0"
|
q = "?live=1" if live else "?live=0"
|
||||||
return json.dumps(_api("GET", "/drucker" + q), ensure_ascii=False, indent=2)
|
return json.dumps(_api("GET", "/drucker" + q), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@server.tool()
|
@server.tool()
|
||||||
def get_printer(host: str) -> str:
|
def get_printer(host: str) -> str:
|
||||||
"""Einen Drucker anhand Host/IP aus der Live-Liste."""
|
"""Einen Drucker anhand host (IP) aus der Live-Liste. Host vorher über list_printers holen."""
|
||||||
rows = _api("GET", "/drucker?live=1") or []
|
rows = _api("GET", "/drucker?live=1") or []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if row.get("host") == host:
|
if row.get("host") == host:
|
||||||
@@ -115,7 +154,7 @@ def main():
|
|||||||
|
|
||||||
@server.tool()
|
@server.tool()
|
||||||
def list_files(limit: int = 50) -> str:
|
def list_files(limit: int = 50) -> str:
|
||||||
"""Dateien in der Bibliothek."""
|
"""Dateien in der Bibliothek. Die numerische id ist die file_id fürs Einreihen und Starten."""
|
||||||
rows = _api("GET", "/dateien") or []
|
rows = _api("GET", "/dateien") or []
|
||||||
return json.dumps(rows[: max(1, min(limit, 200))], ensure_ascii=False, indent=2)
|
return json.dumps(rows[: max(1, min(limit, 200))], ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@@ -181,7 +220,7 @@ def main():
|
|||||||
|
|
||||||
@server.tool()
|
@server.tool()
|
||||||
def clear_bed(host: str, confirm: bool = False) -> str:
|
def clear_bed(host: str, confirm: bool = False) -> str:
|
||||||
"""Platte als geräumt quittieren (kein physisches Schieben)."""
|
"""Quittung, dass der Nutzer die Platte geleert hat. Schiebt nichts. confirm muss true sein. Danach kann die Warteschlange den nächsten Job starten."""
|
||||||
_write_guard()
|
_write_guard()
|
||||||
if not confirm:
|
if not confirm:
|
||||||
raise RuntimeError("Quittierung braucht confirm=true.")
|
raise RuntimeError("Quittierung braucht confirm=true.")
|
||||||
@@ -201,7 +240,7 @@ def main():
|
|||||||
confirm: bool = False,
|
confirm: bool = False,
|
||||||
bed_leveling: bool = False,
|
bed_leveling: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Datei in die Warteschlange („wenn frei“). confirm muss true sein."""
|
"""Datei in die Warteschlange. host leer: Grotti wählt den nächsten Drucker mit passender Sorte. host gesetzt: dieser Drucker, sobald frei und Platte klar. confirm muss true sein. Normaler Weg zum Drucken."""
|
||||||
_write_guard()
|
_write_guard()
|
||||||
if not confirm:
|
if not confirm:
|
||||||
raise RuntimeError("Einreihen braucht confirm=true.")
|
raise RuntimeError("Einreihen braucht confirm=true.")
|
||||||
@@ -220,7 +259,7 @@ def main():
|
|||||||
confirm: bool = False,
|
confirm: bool = False,
|
||||||
bed_leveling: bool = False,
|
bed_leveling: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Sofort senden und starten. confirm muss true sein."""
|
"""Sofort an diesen host senden und starten. Nur bei freiem Drucker (standby, Platte frei) und klarem Sofortstart-Wunsch. confirm muss true sein."""
|
||||||
_write_guard()
|
_write_guard()
|
||||||
if not confirm:
|
if not confirm:
|
||||||
raise RuntimeError("Start braucht confirm=true.")
|
raise RuntimeError("Start braucht confirm=true.")
|
||||||
|
|||||||
@@ -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 "",
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Leere/alte Spulen aus der Alltagsliste nehmen
|
||||||
|
ALTER TABLE spools ADD COLUMN IF NOT EXISTS archived_at timestamptz;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Projekt-Arbeitsmappe: Relativordner unter dem Slice-Pfad
|
||||||
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS folder text NOT NULL DEFAULT '';
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 <> '';
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
+47
-26
@@ -8,8 +8,11 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from grotti import logutil
|
||||||
from grotti.parse import embedded_png
|
from grotti.parse import embedded_png
|
||||||
|
|
||||||
|
log = logutil.get("grotti.moonraker")
|
||||||
|
|
||||||
META_CACHE = {}
|
META_CACHE = {}
|
||||||
CAMERA_CACHE = {}
|
CAMERA_CACHE = {}
|
||||||
EMBEDDED = {}
|
EMBEDDED = {}
|
||||||
@@ -917,11 +920,27 @@ def last_filament_gcode_hint(host, limit=40):
|
|||||||
return ""
|
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):
|
def filament_out_hint(mmu, message="", host=None):
|
||||||
"""Vermutetes Material-Ende bei Pause: (code, Text) oder (None, None).
|
"""Vermutetes Material-Ende bei Pause: (code, Text) oder (None, None).
|
||||||
|
|
||||||
Quellen: print_stats.message, ACE/mmu (active_filament.empty, gate_status),
|
Quellen: print_stats.message, mmu.reason_for_pause, gate_status bei aktivem Tool,
|
||||||
optional G-code-Store (nur wenn host gesetzt).
|
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()
|
text = (message or "").strip()
|
||||||
if text and _FILAMENT_HINT_RE.search(text):
|
if text and _FILAMENT_HINT_RE.search(text):
|
||||||
@@ -930,33 +949,35 @@ def filament_out_hint(mmu, message="", host=None):
|
|||||||
return "message", text[:160]
|
return "message", text[:160]
|
||||||
|
|
||||||
if isinstance(mmu, dict) and mmu:
|
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")
|
active = mmu.get("active_filament")
|
||||||
if isinstance(active, dict) and (
|
if (
|
||||||
active.get("empty") is True
|
tool_i is not None
|
||||||
or str(active.get("empty") or "").lower() in ("1", "true")
|
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)"
|
return "active_empty", "Aktives Filament leer (ACE)"
|
||||||
|
|
||||||
tool = mmu.get("tool")
|
|
||||||
if not isinstance(tool, (int, float)) or isinstance(tool, bool):
|
|
||||||
tool = mmu.get("gate")
|
|
||||||
if isinstance(tool, (int, float)) and not isinstance(tool, bool) and int(tool) >= 0:
|
|
||||||
tool_i = int(tool)
|
|
||||||
states = mmu.get("gate_status")
|
|
||||||
if isinstance(states, list) and tool_i < len(states) and states[tool_i] == 0:
|
|
||||||
return "gate_empty", "ACE-Platz %d leer" % (tool_i + 1)
|
|
||||||
|
|
||||||
# ACE pausiert und kein aktives Filament → oft Runout ohne leeren gate_status
|
|
||||||
if mmu.get("is_paused") and isinstance(active, dict):
|
|
||||||
kind = (
|
|
||||||
active.get("filament_type")
|
|
||||||
or active.get("type")
|
|
||||||
or active.get("material")
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
if not str(kind).strip():
|
|
||||||
return "mmu_paused", "ACE pausiert — kein Filament aktiv"
|
|
||||||
|
|
||||||
if host:
|
if host:
|
||||||
hint = last_filament_gcode_hint(host)
|
hint = last_filament_gcode_hint(host)
|
||||||
if hint:
|
if hint:
|
||||||
@@ -1223,7 +1244,7 @@ def snapshot(printers):
|
|||||||
card.update(query(printer["host"]))
|
card.update(query(printer["host"]))
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
|
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
|
||||||
print("snapshot %s: %s" % (printer.get("host"), error), flush=True)
|
log.warning("snapshot %s: %s", printer.get("host"), error)
|
||||||
card.update({
|
card.update({
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"state": "offline",
|
"state": "offline",
|
||||||
|
|||||||
+113
-9
@@ -17,6 +17,8 @@ TRAILER_RE = re.compile(rb";\s*referenced_tools\s*=", re.I)
|
|||||||
RINKHALS_FINGERPRINT = b"; processed by MmuAcePatcher"
|
RINKHALS_FINGERPRINT = b"; processed by MmuAcePatcher"
|
||||||
_SLICER_RE = re.compile(rb"^;.*generated by ([A-Za-z]*) .*$|^; (BambuStudio) .*$", re.I)
|
_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)
|
_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 = {
|
_AUTHORIZED_SLICERS = {
|
||||||
"prusaslicer",
|
"prusaslicer",
|
||||||
"superslicer",
|
"superslicer",
|
||||||
@@ -91,40 +93,111 @@ def color_hex(channels):
|
|||||||
return "#%02x%02x%02x" % tuple(rgb)
|
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):
|
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:
|
try:
|
||||||
items = json.loads(raw or "")
|
items = json.loads(raw or "")
|
||||||
except (TypeError, json.JSONDecodeError):
|
except (TypeError, json.JSONDecodeError):
|
||||||
return []
|
return []
|
||||||
if not isinstance(items, list):
|
if not isinstance(items, list):
|
||||||
return []
|
return []
|
||||||
found = []
|
found = {}
|
||||||
|
next_fallback = 0
|
||||||
for item in items:
|
for item in items:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
index = int(item.get("paint_index"))
|
index = int(item.get("paint_index"))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
index = len(found)
|
index = next_fallback
|
||||||
while len(found) <= index:
|
if index < 0:
|
||||||
found.append({"type": "", "color": ""})
|
continue
|
||||||
found[index] = {
|
next_fallback = max(next_fallback, index + 1)
|
||||||
|
row = {
|
||||||
"type": str(item.get("material_type") or "").strip(),
|
"type": str(item.get("material_type") or "").strip(),
|
||||||
"color": color_hex(item.get("paint_color")),
|
"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):
|
def filaments_of(comments):
|
||||||
painted = filaments_from_paint_info(comments.get("paint_info"))
|
painted = filaments_from_paint_info(comments.get("paint_info"))
|
||||||
if painted:
|
if painted:
|
||||||
return painted
|
return attach_profiles(painted, comments)
|
||||||
types = [part.strip() for part in (comments.get("filament_type") or "").split(";") if part.strip()]
|
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(";")]
|
colors = [part.strip() for part in (comments.get("filament_colour") or comments.get("filament_color") or "").split(";")]
|
||||||
return [
|
rows = [
|
||||||
{"type": kind, "color": colors[index] if index < len(colors) else ""}
|
{
|
||||||
|
"type": kind,
|
||||||
|
"color": colors[index] if index < len(colors) else "",
|
||||||
|
"paint_index": index,
|
||||||
|
}
|
||||||
for index, kind in enumerate(types)
|
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):
|
def _gcode_lines(content):
|
||||||
@@ -173,6 +246,36 @@ def _paint_tool_indexes(content):
|
|||||||
return found
|
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):
|
def prepare_rinkhals_gcode(content, tools=None):
|
||||||
"""Kopfzeile für Rinkhals plus `; referenced_tools`, wenn sie fehlt.
|
"""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":
|
if not content or content[:2] == b"PK":
|
||||||
return content
|
return content
|
||||||
|
content = strip_filament_colour_type(content, tools=tools)
|
||||||
first_end = content.find(b"\n")
|
first_end = content.find(b"\n")
|
||||||
first = content if first_end < 0 else content[:first_end]
|
first = content if first_end < 0 else content[:first_end]
|
||||||
if first.endswith(b"\r"):
|
if first.endswith(b"\r"):
|
||||||
|
|||||||
+308
-37
@@ -10,7 +10,8 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from grotti import eject, jobs, moonraker, notify, store
|
from grotti import acm, eject, jobs, logutil, moonraker, notify, store
|
||||||
|
from grotti.drivers import for_printer
|
||||||
from grotti.db import connect
|
from grotti.db import connect
|
||||||
from grotti.errors import GrottiError
|
from grotti.errors import GrottiError
|
||||||
from grotti import time_window
|
from grotti import time_window
|
||||||
@@ -18,6 +19,8 @@ from grotti import time_window
|
|||||||
WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…"
|
WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…"
|
||||||
from grotti.events_bus import publish as publish_live
|
from grotti.events_bus import publish as publish_live
|
||||||
|
|
||||||
|
log = logutil.get("grotti.queue")
|
||||||
|
|
||||||
|
|
||||||
def _notify_queue():
|
def _notify_queue():
|
||||||
publish_live("queue", {})
|
publish_live("queue", {})
|
||||||
@@ -28,8 +31,13 @@ BUSY = {"printing", "paused", "heating", "error"}
|
|||||||
KEEPALIVE_S = 120
|
KEEPALIVE_S = 120
|
||||||
# Nach Start ohne print_active: Upload/MQTT/LeviQ brauchen Zeit, aber nicht ewig
|
# Nach Start ohne print_active: Upload/MQTT/LeviQ brauchen Zeit, aber nicht ewig
|
||||||
PRINT_START_TIMEOUT_S = 600
|
PRINT_START_TIMEOUT_S = 600
|
||||||
# Einfacher Queue-Start ohne Worker (typisch Grotti-Neustart)
|
# Upload-Worker weg (typisch Grotti-Neustart mitten im Hochladen).
|
||||||
|
# Nach erfolgreichem Senden ist die Phase await_print — dort gilt das nicht,
|
||||||
|
# sonst wird ein noch anlaufender Druck als „Neustart“ neu eingereiht.
|
||||||
SIMPLE_START_STUCK_S = 45
|
SIMPLE_START_STUCK_S = 45
|
||||||
|
# Anycubic meldet kurz cancelled/standby bei LeviQ und Filament-Resume.
|
||||||
|
TERMINAL_CONFIRM_HITS = 3
|
||||||
|
AWAIT_CANCEL_HITS = 3
|
||||||
|
|
||||||
|
|
||||||
def _ablauf_needs_motors(item):
|
def _ablauf_needs_motors(item):
|
||||||
@@ -111,7 +119,7 @@ class PrintQueueDispatcher:
|
|||||||
try:
|
try:
|
||||||
self.tick()
|
self.tick()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Druck-Queue: %s" % exc, flush=True)
|
log.warning("Druck-Queue: %s", exc)
|
||||||
self._stop.wait(self.interval)
|
self._stop.wait(self.interval)
|
||||||
|
|
||||||
def _live_map(self, printers):
|
def _live_map(self, printers):
|
||||||
@@ -120,7 +128,8 @@ class PrintQueueDispatcher:
|
|||||||
age = self.watcher.age_s()
|
age = self.watcher.age_s()
|
||||||
if cached and age is not None and age <= 30:
|
if cached and age is not None and age <= 30:
|
||||||
return cached
|
return cached
|
||||||
return {row["host"]: row for row in moonraker.snapshot(printers)}
|
from grotti.drivers import snapshot
|
||||||
|
return {row["host"]: row for row in snapshot(printers)}
|
||||||
|
|
||||||
def _auto_enabled(self):
|
def _auto_enabled(self):
|
||||||
return bool((self.cfg or {}).get("auto_queue", True))
|
return bool((self.cfg or {}).get("auto_queue", True))
|
||||||
@@ -147,10 +156,20 @@ class PrintQueueDispatcher:
|
|||||||
def _auto_retry_enabled(self):
|
def _auto_retry_enabled(self):
|
||||||
return bool((self.cfg or {}).get("auto_retry", True))
|
return bool((self.cfg or {}).get("auto_retry", True))
|
||||||
|
|
||||||
|
def _retryable_message(self, message):
|
||||||
|
text = (message or "").lower()
|
||||||
|
if "abgebrochen" in text:
|
||||||
|
return False
|
||||||
|
if "druck abgebrochen" in text:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def _try_auto_retry(self, conn, item, message):
|
def _try_auto_retry(self, conn, item, message):
|
||||||
"""Ein automatischer Neuversuch; sonst False."""
|
"""Ein automatischer Neuversuch; sonst False. Nicht nach Abbruch."""
|
||||||
if not self._auto_retry_enabled():
|
if not self._auto_retry_enabled():
|
||||||
return False
|
return False
|
||||||
|
if not self._retryable_message(message):
|
||||||
|
return False
|
||||||
retries = int(item.get("retry_count") or 0)
|
retries = int(item.get("retry_count") or 0)
|
||||||
if retries >= 1:
|
if retries >= 1:
|
||||||
return False
|
return False
|
||||||
@@ -168,6 +187,23 @@ class PrintQueueDispatcher:
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _terminal_hits(self, item_id, kind="end"):
|
||||||
|
key = "%s:%s" % (item_id, kind)
|
||||||
|
if not hasattr(self, "_term_hits"):
|
||||||
|
self._term_hits = {}
|
||||||
|
self._term_hits[key] = self._term_hits.get(key, 0) + 1
|
||||||
|
return self._term_hits[key]
|
||||||
|
|
||||||
|
def _clear_terminal_hits(self, item_id, kind=None):
|
||||||
|
if not hasattr(self, "_term_hits"):
|
||||||
|
return
|
||||||
|
if kind:
|
||||||
|
self._term_hits.pop("%s:%s" % (item_id, kind), None)
|
||||||
|
return
|
||||||
|
for key in list(self._term_hits):
|
||||||
|
if key.startswith("%s:" % item_id):
|
||||||
|
self._term_hits.pop(key, None)
|
||||||
|
|
||||||
def _fail_stuck_simple_start(self, conn, item, printer):
|
def _fail_stuck_simple_start(self, conn, item, printer):
|
||||||
host = printer["host"]
|
host = printer["host"]
|
||||||
msg = "Start abgebrochen (Neustart)"
|
msg = "Start abgebrochen (Neustart)"
|
||||||
@@ -207,7 +243,7 @@ class PrintQueueDispatcher:
|
|||||||
try:
|
try:
|
||||||
target(*args)
|
target(*args)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Queue-Worker %s: %s" % (item_id, exc), flush=True)
|
log.warning("Queue-Worker %s: %s", item_id, exc)
|
||||||
finally:
|
finally:
|
||||||
_notify_queue()
|
_notify_queue()
|
||||||
with self._worker_lock:
|
with self._worker_lock:
|
||||||
@@ -273,18 +309,27 @@ class PrintQueueDispatcher:
|
|||||||
lives[host] = live
|
lives[host] = live
|
||||||
state = (live.get("state") or "").lower()
|
state = (live.get("state") or "").lower()
|
||||||
if state in BUSY:
|
if state in BUSY:
|
||||||
|
# Laufenden Einfach-Druck mitziehen. Sonst bleibt await_print
|
||||||
|
# stehen, bis der Drucker frei ist — und der Start gilt als Timeout,
|
||||||
|
# obwohl er längst druckt.
|
||||||
|
running = store.running_print_on_host(conn, host)
|
||||||
|
if running and not _has_ablauf(running):
|
||||||
|
self._advance_simple(conn, running, printer, live)
|
||||||
|
conn.commit()
|
||||||
continue
|
continue
|
||||||
if state not in FREE:
|
if state not in FREE:
|
||||||
continue
|
continue
|
||||||
running = store.running_print_on_host(conn, host)
|
running = store.running_print_on_host(conn, host)
|
||||||
if running:
|
if running:
|
||||||
if (
|
if not _has_ablauf(running):
|
||||||
not _has_ablauf(running)
|
if (
|
||||||
and (running.get("phase") or "").lower() == "starting"
|
(running.get("phase") or "").lower() == "starting"
|
||||||
and not self._worker_busy(running["id"])
|
and not self._worker_busy(running["id"])
|
||||||
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
|
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
|
||||||
):
|
):
|
||||||
self._fail_stuck_simple_start(conn, running, printer)
|
self._fail_stuck_simple_start(conn, running, printer)
|
||||||
|
else:
|
||||||
|
self._advance_simple(conn, running, printer, live)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
continue
|
continue
|
||||||
if store.bed_needs_clear(conn, host):
|
if store.bed_needs_clear(conn, host):
|
||||||
@@ -306,6 +351,11 @@ class PrintQueueDispatcher:
|
|||||||
if (waiting.get("message") or "") != WINDOW_WAIT_MSG:
|
if (waiting.get("message") or "") != WINDOW_WAIT_MSG:
|
||||||
store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG})
|
store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG})
|
||||||
continue
|
continue
|
||||||
|
# Nächster passender / leere Map: ACE aus Spulen bauen oder warten
|
||||||
|
if not _has_ablauf(waiting):
|
||||||
|
waiting = self._ensure_ace_map(conn, waiting, host, live)
|
||||||
|
if not waiting:
|
||||||
|
continue
|
||||||
claimed = store.claim_print_queue(conn, waiting["id"], host)
|
claimed = store.claim_print_queue(conn, waiting["id"], host)
|
||||||
if not claimed:
|
if not claimed:
|
||||||
continue
|
continue
|
||||||
@@ -317,6 +367,49 @@ class PrintQueueDispatcher:
|
|||||||
self._start_simple(conn, claimed, printer)
|
self._start_simple(conn, claimed, printer)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
def _ensure_ace_map(self, conn, waiting, host, live):
|
||||||
|
"""Bei ACE-Drucker und leerer Map: aus Spulen zuordnen oder Wartegrund setzen."""
|
||||||
|
slots = (live or {}).get("slots") or []
|
||||||
|
if not slots:
|
||||||
|
return waiting
|
||||||
|
ace = waiting.get("ace_map") or []
|
||||||
|
if isinstance(ace, str):
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
ace = json.loads(ace)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
ace = []
|
||||||
|
if acm.normalize_mapping(ace if isinstance(ace, list) else []):
|
||||||
|
return waiting
|
||||||
|
file_id = waiting.get("file_id")
|
||||||
|
if not file_id:
|
||||||
|
return waiting
|
||||||
|
mapping, reason = store.auto_ace_map(conn, host, file_id)
|
||||||
|
if reason:
|
||||||
|
if (waiting.get("wait_reason") or "") != reason or (waiting.get("message") or "") != reason:
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
waiting["id"],
|
||||||
|
{"wait_reason": reason, "message": reason},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if not mapping:
|
||||||
|
# ACE am Drucker, aber keine ACE-Spulen in Grotti → nicht raten
|
||||||
|
reason = "ACE-Zuordnung fehlt (keine passende Spule)"
|
||||||
|
if (waiting.get("wait_reason") or "") != reason:
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
waiting["id"],
|
||||||
|
{"wait_reason": reason, "message": reason},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
store.update_print_queue(conn, waiting["id"], {"ace_map": mapping, "wait_reason": "", "message": ""})
|
||||||
|
waiting = dict(waiting)
|
||||||
|
waiting["ace_map"] = mapping
|
||||||
|
waiting["wait_reason"] = ""
|
||||||
|
return waiting
|
||||||
|
|
||||||
def _start_simple(self, conn, item, printer):
|
def _start_simple(self, conn, item, printer):
|
||||||
"""Upload/Start im Worker — mehrere freie Drucker parallel."""
|
"""Upload/Start im Worker — mehrere freie Drucker parallel."""
|
||||||
host = printer["host"]
|
host = printer["host"]
|
||||||
@@ -364,22 +457,31 @@ class PrintQueueDispatcher:
|
|||||||
bed_leveling=bed_leveling,
|
bed_leveling=bed_leveling,
|
||||||
)
|
)
|
||||||
name = (result or {}).get("filename") or ""
|
name = (result or {}).get("filename") or ""
|
||||||
|
# Abbruch während Upload: Status nicht überschreiben, Druck stoppen.
|
||||||
|
current = store.get_print_queue_row(wconn, item_id)
|
||||||
|
if current and (current.get("status") or "") in ("cancelled", "dismissed"):
|
||||||
|
try:
|
||||||
|
for_printer(printer_copy).command(host, "cancel")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
oid = item.get("order_item_id")
|
oid = item.get("order_item_id")
|
||||||
if oid:
|
if oid:
|
||||||
from grotti import orders
|
from grotti import orders
|
||||||
|
|
||||||
orders.attach_order_item_to_live_run(wconn, host, name, oid)
|
orders.attach_order_item_to_live_run(wconn, host, name, oid)
|
||||||
store.update_print_queue(
|
live_run = store.find_active_run(wconn, host, name)
|
||||||
wconn,
|
fields = {
|
||||||
item_id,
|
"status": "running",
|
||||||
{
|
"message": "Datei ist auf %s. Druckstart kann etwa eine Minute dauern."
|
||||||
"status": "done",
|
% printer_copy["name"],
|
||||||
"message": "Gestartet auf %s: %s" % (printer_copy["name"], name),
|
"host": host,
|
||||||
"host": host,
|
"phase": "await_print",
|
||||||
"phase": "",
|
"phase_started_at": "now",
|
||||||
"phase_started_at": None,
|
}
|
||||||
},
|
if live_run:
|
||||||
)
|
fields["run_id"] = live_run["id"]
|
||||||
|
store.update_print_queue(wconn, item_id, fields)
|
||||||
self._event(
|
self._event(
|
||||||
wconn,
|
wconn,
|
||||||
host,
|
host,
|
||||||
@@ -429,6 +531,147 @@ class PrintQueueDispatcher:
|
|||||||
|
|
||||||
self._spawn_worker(item_id, work)
|
self._spawn_worker(item_id, work)
|
||||||
|
|
||||||
|
def _advance_simple(self, conn, item, printer, live):
|
||||||
|
"""Einfach-Druck: running bleiben bis Moonraker fertig/abgebrochen."""
|
||||||
|
phase = (item.get("phase") or "").lower()
|
||||||
|
state = (live.get("state") or "").lower()
|
||||||
|
if phase in ("starting", "await_print"):
|
||||||
|
if state in ("printing", "paused", "heating") or live.get("print_active"):
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"phase": "print_active",
|
||||||
|
"phase_started_at": "now",
|
||||||
|
"message": "Druckt…",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if phase == "await_print" and state == "cancelled":
|
||||||
|
hits = self._terminal_hits(item["id"], "await_cancel")
|
||||||
|
if hits >= AWAIT_CANCEL_HITS:
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Druck abgebrochen.",
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if phase == "await_print" and self._print_start_timed_out(item):
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Druckstart-Timeout: Drucker hat nach %d min nicht angefangen."
|
||||||
|
% (PRINT_START_TIMEOUT_S // 60),
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# complete/standby vom vorherigen Auftrag: noch warten, nicht „Fertig.“
|
||||||
|
return
|
||||||
|
if phase != "print_active":
|
||||||
|
return
|
||||||
|
if state in ("printing", "paused", "heating"):
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
return
|
||||||
|
if state == "error":
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": (live.get("message") or "Druckerfehler").strip()[:200],
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# Nur complete = fertig. cancelled/standby erst nach Entprellen.
|
||||||
|
if state == "complete":
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "done",
|
||||||
|
"message": "Fertig.",
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
conn,
|
||||||
|
printer["host"],
|
||||||
|
printer["name"],
|
||||||
|
"fertig",
|
||||||
|
"%s fertig: %s" % (printer["name"], item.get("filename") or "Druck"),
|
||||||
|
item.get("filename") or "",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if state in ("cancelled", "standby"):
|
||||||
|
hits = self._terminal_hits(item["id"], "end")
|
||||||
|
if hits < TERMINAL_CONFIRM_HITS:
|
||||||
|
return
|
||||||
|
live2 = live
|
||||||
|
try:
|
||||||
|
live2 = moonraker.query(printer["host"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
state2 = (live2.get("state") or "").lower()
|
||||||
|
if state2 in ("printing", "paused", "heating") or live2.get("print_active"):
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
return
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
if state2 == "complete":
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "done",
|
||||||
|
"message": "Fertig.",
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
conn,
|
||||||
|
printer["host"],
|
||||||
|
printer["name"],
|
||||||
|
"fertig",
|
||||||
|
"%s fertig: %s" % (printer["name"], item.get("filename") or "Druck"),
|
||||||
|
item.get("filename") or "",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# Echter Abbruch — nicht „Fertig.“ und kein Auto-Retry.
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "cancelled",
|
||||||
|
"message": "Druck abgebrochen.",
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
conn,
|
||||||
|
printer["host"],
|
||||||
|
printer["name"],
|
||||||
|
"abbruch",
|
||||||
|
"%s: Druck abgebrochen (%s)"
|
||||||
|
% (printer["name"], item.get("filename") or "ohne Datei"),
|
||||||
|
item.get("filename") or "",
|
||||||
|
)
|
||||||
|
|
||||||
def _begin_ablauf(self, conn, item, printer, live):
|
def _begin_ablauf(self, conn, item, printer, live):
|
||||||
try:
|
try:
|
||||||
eject.require_kobra_3(printer)
|
eject.require_kobra_3(printer)
|
||||||
@@ -665,9 +908,31 @@ class PrintQueueDispatcher:
|
|||||||
live.get("message") or "Druckerfehler während des Drucks.",
|
live.get("message") or "Druckerfehler während des Drucks.",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
# Erst weiter, wenn der Job wirklich gelaufen ist — sonst Race
|
# Nur complete = Druckende. standby/cancelled entprellen (Filament-Resume).
|
||||||
# (noch standby/complete vom vorherigen Auftrag).
|
if phase == "print_active" and state == "complete":
|
||||||
if phase == "print_active" and state in ("complete", "standby"):
|
self._clear_terminal_hits(item["id"])
|
||||||
|
self._next_step(conn, item, printer, live)
|
||||||
|
return
|
||||||
|
if phase == "print_active" and state in ("cancelled", "standby"):
|
||||||
|
hits = self._terminal_hits(item["id"], "ablauf_end")
|
||||||
|
if hits < TERMINAL_CONFIRM_HITS:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
live = moonraker.query(printer["host"])
|
||||||
|
state = (live.get("state") or "").lower()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if state in ("printing", "paused", "heating") or live.get("print_active"):
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
return
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
if state == "complete":
|
||||||
|
self._next_step(conn, item, printer, live)
|
||||||
|
return
|
||||||
|
if state == "cancelled":
|
||||||
|
self._fail_ablauf(conn, item, printer, "Druck abgebrochen.")
|
||||||
|
return
|
||||||
|
# standby nach Entprellen: Job wirklich zu Ende
|
||||||
self._next_step(conn, item, printer, live)
|
self._next_step(conn, item, printer, live)
|
||||||
return
|
return
|
||||||
# Idle schon während des Drucks verlängern — sonst greift M84/Default zu früh.
|
# Idle schon während des Drucks verlängern — sonst greift M84/Default zu früh.
|
||||||
@@ -787,16 +1052,22 @@ class PrintQueueDispatcher:
|
|||||||
)
|
)
|
||||||
name = (result or {}).get("filename") or ""
|
name = (result or {}).get("filename") or ""
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
store.update_print_queue(
|
if (row.get("status") or "") in ("cancelled", "dismissed"):
|
||||||
wconn,
|
try:
|
||||||
item_id,
|
for_printer(printer_copy).command(printer_copy["host"], "cancel")
|
||||||
{
|
except Exception:
|
||||||
"phase": "printing",
|
pass
|
||||||
"phase_started_at": "now",
|
return
|
||||||
"file_id": step_copy["file_id"],
|
fields = {
|
||||||
"message": self._ablauf_message(row, "druckt %s" % name),
|
"phase": "printing",
|
||||||
},
|
"phase_started_at": "now",
|
||||||
)
|
"file_id": step_copy["file_id"],
|
||||||
|
"message": self._ablauf_message(row, "druckt %s" % name),
|
||||||
|
}
|
||||||
|
live_run = store.find_active_run(wconn, printer_copy["host"], name)
|
||||||
|
if live_run:
|
||||||
|
fields["run_id"] = live_run["id"]
|
||||||
|
store.update_print_queue(wconn, item_id, fields)
|
||||||
self._step_event(wconn, row, printer_copy, "druckt %s" % name)
|
self._step_event(wconn, row, printer_copy, "druckt %s" % name)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except GrottiError as exc:
|
except GrottiError as exc:
|
||||||
|
|||||||
+33
-2
@@ -1,21 +1,52 @@
|
|||||||
"""Die Drucker: Familie aus dem Namen, die Liste selbst liegt in PostgreSQL."""
|
"""Die Drucker: Familie aus dem Namen, die Liste selbst liegt in PostgreSQL."""
|
||||||
|
|
||||||
FAMILY_LABELS = {"kobra_s1": "Kobra S1", "kobra_3": "Kobra 3"}
|
FAMILY_LABELS = {
|
||||||
|
"kobra_s1": "Kobra S1",
|
||||||
|
"kobra_3": "Kobra 3",
|
||||||
|
"kobra_4": "Kobra 4",
|
||||||
|
"anycubic_i3": "Anycubic i3",
|
||||||
|
"tenlog": "Tenlog",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vorhandene Farm bleibt Moonraker/Rinkhals. Stock und Marlin sind eigene Treiber.
|
||||||
|
FAMILY_DRIVER = {
|
||||||
|
"kobra_s1": "moonraker",
|
||||||
|
"kobra_3": "moonraker",
|
||||||
|
"kobra_4": "anycubic_lan",
|
||||||
|
"anycubic_i3": "marlin",
|
||||||
|
"tenlog": "marlin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def family_from_name(name):
|
def family_from_name(name):
|
||||||
low = name.lower()
|
low = (name or "").lower()
|
||||||
|
if "kobra 4" in low or "kobra4" in low:
|
||||||
|
return "kobra_4"
|
||||||
if "s1" in low:
|
if "s1" in low:
|
||||||
return "kobra_s1"
|
return "kobra_s1"
|
||||||
if "kobra 3" in low or "kobra3" in low:
|
if "kobra 3" in low or "kobra3" in low:
|
||||||
return "kobra_3"
|
return "kobra_3"
|
||||||
|
if "tenlog" in low:
|
||||||
|
return "tenlog"
|
||||||
|
if "i3" in low:
|
||||||
|
return "anycubic_i3"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def family_from_text(text):
|
def family_from_text(text):
|
||||||
low = (text or "").lower()
|
low = (text or "").lower()
|
||||||
|
if "kobra 4" in low or "kobra4" in low:
|
||||||
|
return "kobra_4"
|
||||||
if "kobra s1" in low:
|
if "kobra s1" in low:
|
||||||
return "kobra_s1"
|
return "kobra_s1"
|
||||||
if "kobra 3" in low or "kobra3" in low:
|
if "kobra 3" in low or "kobra3" in low:
|
||||||
return "kobra_3"
|
return "kobra_3"
|
||||||
|
if "tenlog" in low:
|
||||||
|
return "tenlog"
|
||||||
|
if "anycubic i3" in low or re_i3(low):
|
||||||
|
return "anycubic_i3"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def re_i3(low):
|
||||||
|
return " i3" in low or low.startswith("i3")
|
||||||
@@ -7,9 +7,12 @@ CREATE TABLE IF NOT EXISTS projects (
|
|||||||
notes text NOT NULL DEFAULT '',
|
notes text NOT NULL DEFAULT '',
|
||||||
status text NOT NULL DEFAULT 'idee',
|
status text NOT NULL DEFAULT 'idee',
|
||||||
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
|
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
folder text NOT NULL DEFAULT '',
|
||||||
created_at timestamptz NOT NULL DEFAULT now()
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS folder text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS spools (
|
CREATE TABLE IF NOT EXISTS spools (
|
||||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
@@ -123,6 +126,7 @@ ALTER TABLE spools ADD COLUMN IF NOT EXISTS material_id bigint REFERENCES materi
|
|||||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS chargen_nr text;
|
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 hersteller_charge text NOT NULL DEFAULT '';
|
||||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text;
|
ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text;
|
||||||
|
ALTER TABLE spools ADD COLUMN IF NOT EXISTS archived_at timestamptz;
|
||||||
|
|
||||||
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_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 <> '';
|
CREATE UNIQUE INDEX IF NOT EXISTS spools_code ON spools (code) WHERE code IS NOT NULL AND code <> '';
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class SendQueue:
|
|||||||
spool_id,
|
spool_id,
|
||||||
ace_map=None,
|
ace_map=None,
|
||||||
bed_leveling=False,
|
bed_leveling=False,
|
||||||
|
marlin_mode="stream",
|
||||||
):
|
):
|
||||||
jobs_out = self.enqueue_many(
|
jobs_out = self.enqueue_many(
|
||||||
conn,
|
conn,
|
||||||
@@ -67,6 +68,7 @@ class SendQueue:
|
|||||||
spool_id,
|
spool_id,
|
||||||
{host: ace_map or []} if ace_map else {},
|
{host: ace_map or []} if ace_map else {},
|
||||||
bed_leveling=bed_leveling,
|
bed_leveling=bed_leveling,
|
||||||
|
marlin_mode=marlin_mode,
|
||||||
)
|
)
|
||||||
return jobs_out[0]
|
return jobs_out[0]
|
||||||
|
|
||||||
@@ -80,6 +82,7 @@ class SendQueue:
|
|||||||
spool_id,
|
spool_id,
|
||||||
ace_maps=None,
|
ace_maps=None,
|
||||||
bed_leveling=False,
|
bed_leveling=False,
|
||||||
|
marlin_mode="stream",
|
||||||
):
|
):
|
||||||
if not confirm:
|
if not confirm:
|
||||||
raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung")
|
raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung")
|
||||||
@@ -96,6 +99,7 @@ class SendQueue:
|
|||||||
ace_maps = ace_maps if isinstance(ace_maps, dict) else {}
|
ace_maps = ace_maps if isinstance(ace_maps, dict) else {}
|
||||||
# Eine Batch-Option für alle Drucker im Multi-Send (nicht pro Host).
|
# Eine Batch-Option für alle Drucker im Multi-Send (nicht pro Host).
|
||||||
want_level = bool(bed_leveling)
|
want_level = bool(bed_leveling)
|
||||||
|
mode = marlin_mode if marlin_mode in ("stream", "sd") else "stream"
|
||||||
created = []
|
created = []
|
||||||
for host in unique:
|
for host in unique:
|
||||||
# Mapping zuerst, damit Materialprüfung die ACE-Spulen sieht.
|
# Mapping zuerst, damit Materialprüfung die ACE-Spulen sieht.
|
||||||
@@ -120,6 +124,7 @@ class SendQueue:
|
|||||||
"override_machine": bool(override),
|
"override_machine": bool(override),
|
||||||
"ace_map": mapping,
|
"ace_map": mapping,
|
||||||
"bed_leveling": want_level,
|
"bed_leveling": want_level,
|
||||||
|
"marlin_mode": mode,
|
||||||
"result": None,
|
"result": None,
|
||||||
"error": None,
|
"error": None,
|
||||||
"created_at": time.time(),
|
"created_at": time.time(),
|
||||||
@@ -137,6 +142,7 @@ class SendQueue:
|
|||||||
"spool_id": chosen,
|
"spool_id": chosen,
|
||||||
"ace_map": mapping,
|
"ace_map": mapping,
|
||||||
"bed_leveling": want_level,
|
"bed_leveling": want_level,
|
||||||
|
"marlin_mode": mode,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
created.append(self.snapshot(job_id))
|
created.append(self.snapshot(job_id))
|
||||||
@@ -207,6 +213,7 @@ class SendQueue:
|
|||||||
prepared=True,
|
prepared=True,
|
||||||
ace_map=item.get("ace_map"),
|
ace_map=item.get("ace_map"),
|
||||||
bed_leveling=bool(item.get("bed_leveling")),
|
bed_leveling=bool(item.get("bed_leveling")),
|
||||||
|
marlin_mode=item.get("marlin_mode") or "stream",
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
done_msg = "Gestartet auf %s: %s" % (result["printer"], result["filename"])
|
done_msg = "Gestartet auf %s: %s" % (result["printer"], result["filename"])
|
||||||
|
|||||||
+1314
-159
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
|||||||
"""G-code zu einem dünnen 3D-Pfad für den Viewer."""
|
"""G-code zu einem dünnen 3D-Pfad für den Viewer."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
|
|||||||
|
|
||||||
# Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben)
|
# Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben)
|
||||||
TOOLPATH_VERSION = 8
|
TOOLPATH_VERSION = 8
|
||||||
|
# Die volle Bahn liegt kurz im Speicher. Parallel auf einem Pi mit 2 GB füllt das den Swap.
|
||||||
|
_build_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _gcode_text(path: Path) -> str:
|
def _gcode_text(path: Path) -> str:
|
||||||
@@ -111,6 +114,11 @@ def _thin_points(points, layer_breaks, max_points):
|
|||||||
|
|
||||||
|
|
||||||
def build_toolpath(path: Path, max_points=220000):
|
def build_toolpath(path: Path, max_points=220000):
|
||||||
|
with _build_lock:
|
||||||
|
return _build_toolpath(path, max_points)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_toolpath(path: Path, max_points=220000):
|
||||||
text = _gcode_text(path)
|
text = _gcode_text(path)
|
||||||
if not text:
|
if not text:
|
||||||
return {
|
return {
|
||||||
|
|||||||
+14
-6
@@ -53,15 +53,23 @@ def planned_shares(metadata, ace_map, target_count):
|
|||||||
weights = weight_list(metadata)
|
weights = weight_list(metadata)
|
||||||
mapping = ace_map if isinstance(ace_map, list) else []
|
mapping = ace_map if isinstance(ace_map, list) else []
|
||||||
if weights and mapping and len(weights) >= len(mapping) and target_count == len(mapping):
|
if weights and mapping and len(weights) >= len(mapping) and target_count == len(mapping):
|
||||||
shares = []
|
paints = []
|
||||||
for item in mapping:
|
for item in mapping:
|
||||||
paint = item.get("paint_index")
|
|
||||||
try:
|
try:
|
||||||
index = int(paint)
|
paints.append(int(item.get("paint_index")))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
index = len(shares)
|
paints.append(-1)
|
||||||
if 0 <= index < len(weights):
|
# Nur bei dichter 0…n−1-Map paint_index = Gewichtsindex; sonst Map-Reihenfolge
|
||||||
shares.append(weights[index])
|
# (Bambu/Anycubic oft T1+T10 bei zwei kompakten Gewichten).
|
||||||
|
dense = sorted(paints) == list(range(len(paints))) and all(
|
||||||
|
0 <= paint < len(weights) for paint in paints
|
||||||
|
)
|
||||||
|
shares = []
|
||||||
|
for order, paint in enumerate(paints):
|
||||||
|
if dense:
|
||||||
|
shares.append(weights[paint])
|
||||||
|
elif order < len(weights):
|
||||||
|
shares.append(weights[order])
|
||||||
else:
|
else:
|
||||||
shares.append(0.0)
|
shares.append(0.0)
|
||||||
if sum(shares) > 0:
|
if sum(shares) > 0:
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||||
|
|
||||||
__version__ = "3.2.0"
|
__version__ = "3.7.0"
|
||||||
+203
-46
@@ -6,14 +6,31 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from grotti import moonraker, notify, store, usage
|
from grotti import drivers, jobs, logutil, moonraker, notify, store, usage
|
||||||
from grotti.events_bus import publish as publish_live
|
from grotti.events_bus import publish as publish_live
|
||||||
from grotti.db import connect
|
from grotti.db import connect
|
||||||
|
|
||||||
|
log = logutil.get("grotti.watcher")
|
||||||
|
|
||||||
SAMPLE_EVERY_S = 30
|
SAMPLE_EVERY_S = 30
|
||||||
SAMPLE_HEAT_S = 10
|
SAMPLE_HEAT_S = 10
|
||||||
|
|
||||||
|
|
||||||
|
def contact_gap_keeps_run(prev_filename, next_filename, next_state):
|
||||||
|
"""Nach einem Kontaktverlust bleibt der Lauf, solange kein anderer Auftrag läuft.
|
||||||
|
|
||||||
|
Ein Timeout ist kein Druckende. Ohne Dateiname gibt es keinen Beweis
|
||||||
|
für einen Wechsel, der offene Lauf bleibt.
|
||||||
|
"""
|
||||||
|
if next_state not in ("printing", "paused"):
|
||||||
|
return False
|
||||||
|
prev_filename = prev_filename or ""
|
||||||
|
next_filename = next_filename or ""
|
||||||
|
if not prev_filename or not next_filename:
|
||||||
|
return True
|
||||||
|
return prev_filename == next_filename
|
||||||
|
|
||||||
|
|
||||||
class StateWatcher:
|
class StateWatcher:
|
||||||
def __init__(self, database_url, cfg, interval=10):
|
def __init__(self, database_url, cfg, interval=10):
|
||||||
self.database_url = database_url
|
self.database_url = database_url
|
||||||
@@ -21,6 +38,7 @@ class StateWatcher:
|
|||||||
self.interval = max(5, int(interval))
|
self.interval = max(5, int(interval))
|
||||||
self._prev = {}
|
self._prev = {}
|
||||||
self._offline_hits = {}
|
self._offline_hits = {}
|
||||||
|
self._cancel_hits = {}
|
||||||
self.latest = []
|
self.latest = []
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._updated_at = 0.0
|
self._updated_at = 0.0
|
||||||
@@ -59,7 +77,7 @@ class StateWatcher:
|
|||||||
try:
|
try:
|
||||||
self.tick()
|
self.tick()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher: %s" % exc, flush=True)
|
log.warning("Watcher: %s", exc)
|
||||||
self._stop.wait(self.interval)
|
self._stop.wait(self.interval)
|
||||||
|
|
||||||
def tick(self):
|
def tick(self):
|
||||||
@@ -69,7 +87,7 @@ class StateWatcher:
|
|||||||
if not printers:
|
if not printers:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return
|
return
|
||||||
lives = moonraker.snapshot(printers)
|
lives = drivers.snapshot(printers)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self.latest = lives
|
self.latest = lives
|
||||||
self._updated_at = time.monotonic()
|
self._updated_at = time.monotonic()
|
||||||
@@ -93,16 +111,43 @@ class StateWatcher:
|
|||||||
if offline:
|
if offline:
|
||||||
hits = self._offline_hits.get(host, 0) + 1
|
hits = self._offline_hits.get(host, 0) + 1
|
||||||
self._offline_hits[host] = hits
|
self._offline_hits[host] = hits
|
||||||
if hits >= 2 and not prev_offline:
|
# Ein Aussetzer ist noch kein Offline. Erst der zweite Poll.
|
||||||
|
if hits < 2:
|
||||||
|
return
|
||||||
|
if not prev_offline:
|
||||||
self._emit(conn, host, name, "offline", "%s ist offline." % name, filename)
|
self._emit(conn, host, name, "offline", "%s ist offline." % name, filename)
|
||||||
self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
|
# Kontakt weg ist kein Druckende. Der letzte echte Zustand bleibt
|
||||||
|
# gemerkt, damit fertig/Abbruch/Fehler beim nächsten Poll greifen.
|
||||||
|
remembered = prev_state if prev_state and prev_state != "offline" else ""
|
||||||
|
self._prev[host] = {
|
||||||
|
"state": remembered,
|
||||||
|
"offline": True,
|
||||||
|
"heating": bool(prev.get("heating")),
|
||||||
|
"filename": prev.get("filename") or filename or "",
|
||||||
|
"spool_warn": bool(prev.get("spool_warn")) if remembered == "printing" else False,
|
||||||
|
"run_id": prev.get("run_id"),
|
||||||
|
}
|
||||||
return
|
return
|
||||||
|
|
||||||
self._offline_hits[host] = 0
|
self._offline_hits[host] = 0
|
||||||
|
file_changed = False
|
||||||
|
if prev_offline and state in ("printing", "paused"):
|
||||||
|
file_changed = self._release_replaced_run(conn, host, name, filename)
|
||||||
|
elif state == "printing" and filename and prev_state in (
|
||||||
|
"printing", "paused", "cancelled", "standby",
|
||||||
|
):
|
||||||
|
# Abbruch verpasst oder sofort neu gestartet: alte Datei nicht umbenennen.
|
||||||
|
existing = store.find_live_run(conn, host)
|
||||||
|
old_name = (existing or {}).get("filename") or ""
|
||||||
|
if old_name and old_name != filename:
|
||||||
|
file_changed = self._release_replaced_run(conn, host, name, filename)
|
||||||
starting = (
|
starting = (
|
||||||
bool(prev_state)
|
(
|
||||||
and prev_state not in ("printing", "paused")
|
bool(prev_state)
|
||||||
and state == "printing"
|
and prev_state not in ("printing", "paused")
|
||||||
|
and state == "printing"
|
||||||
|
)
|
||||||
|
or (file_changed and state == "printing")
|
||||||
)
|
)
|
||||||
if starting:
|
if starting:
|
||||||
# Aufheiz-Proben behalten: Live-Lauf gleicher Datei nicht verwerfen
|
# Aufheiz-Proben behalten: Live-Lauf gleicher Datei nicht verwerfen
|
||||||
@@ -128,9 +173,21 @@ class StateWatcher:
|
|||||||
force = starting or (heating and not prev.get("heating"))
|
force = starting or (heating and not prev.get("heating"))
|
||||||
run = self._track_print(conn, host, name, filename, live, state, force_sample=force)
|
run = self._track_print(conn, host, name, filename, live, state, force_sample=force)
|
||||||
run_id = run["id"] if run else None
|
run_id = run["id"] if run else None
|
||||||
|
if run_id:
|
||||||
|
store.set_printer_active_run(conn, host, run_id)
|
||||||
|
|
||||||
|
if state not in ("cancelled", "standby"):
|
||||||
|
self._cancel_hits[host] = 0
|
||||||
|
|
||||||
if prev_offline:
|
if prev_offline:
|
||||||
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename, run_id)
|
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename, run_id)
|
||||||
|
if file_changed and state == "printing":
|
||||||
|
self._emit(
|
||||||
|
conn, host, name, "start",
|
||||||
|
"%s druckt %s" % (name, filename or "einen Auftrag"),
|
||||||
|
filename, run_id,
|
||||||
|
)
|
||||||
|
self._ensure_start_segment(conn, host, filename, live, run_id)
|
||||||
elif starting:
|
elif starting:
|
||||||
self._emit(
|
self._emit(
|
||||||
conn, host, name, "start",
|
conn, host, name, "start",
|
||||||
@@ -151,9 +208,16 @@ class StateWatcher:
|
|||||||
self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
|
self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
|
||||||
elif prev_state == "paused" and state == "printing":
|
elif prev_state == "paused" and state == "printing":
|
||||||
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id)
|
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id)
|
||||||
|
try:
|
||||||
|
jobs.sync_history(conn, self.cfg, host=host, watcher_resume=True)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Watcher Abgleich Resume %s: %s", host, exc)
|
||||||
|
# Nach Fold: aktiven History-Lauf weiter tracken.
|
||||||
|
open_run = store.find_active_run(conn, host, filename)
|
||||||
|
if open_run:
|
||||||
|
run_id = open_run["id"]
|
||||||
|
store.set_printer_active_run(conn, host, run_id)
|
||||||
elif prev_state == "printing" and state == "complete":
|
elif prev_state == "printing" and state == "complete":
|
||||||
# Live-Übergang: immer belegen (ensure_bed_after_print ohne Endzeit
|
|
||||||
# würde nach Quittierung eines älteren Drucks stehen bleiben).
|
|
||||||
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
|
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
|
||||||
self._emit(
|
self._emit(
|
||||||
conn,
|
conn,
|
||||||
@@ -165,42 +229,77 @@ class StateWatcher:
|
|||||||
run_id or (prev.get("run_id")),
|
run_id or (prev.get("run_id")),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
from grotti import jobs
|
jobs.sync_history(conn, self.cfg, host=host)
|
||||||
|
|
||||||
jobs.sync_history(conn, self.cfg)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
log.warning("Watcher Abgleich %s: %s", host, exc)
|
||||||
|
# Live schließen ohne Abzug — History bucht; Fallback nach Frist.
|
||||||
|
dangling = store.find_live_run(conn, host)
|
||||||
|
if dangling:
|
||||||
|
store.complete_open_run(conn, dangling["id"], state="completed")
|
||||||
|
store.set_printer_active_run(conn, host, None)
|
||||||
elif state in ("cancelled", "standby"):
|
elif state in ("cancelled", "standby"):
|
||||||
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
|
|
||||||
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
|
|
||||||
dangling = store.find_live_run(conn, host)
|
dangling = store.find_live_run(conn, host)
|
||||||
from_active = prev_state in ("printing", "paused")
|
from_active = prev_state in ("printing", "paused")
|
||||||
recover = bool(dangling) and (prev_offline or not prev_state)
|
recover = bool(dangling) and (prev_offline or not prev_state)
|
||||||
if from_active or recover:
|
hits = self._cancel_hits.get(host, 0) + 1
|
||||||
|
self._cancel_hits[host] = hits
|
||||||
|
# Anycubic flackert kurz cancelled/standby während Pause/Resume —
|
||||||
|
# erst nach 3 Polls aufgeben. Nach Offline-Lücke sofort.
|
||||||
|
need_hits = 1 if recover else 3
|
||||||
|
if (from_active or recover) and hits >= need_hits:
|
||||||
file_label = filename or (dangling or {}).get("filename") or state
|
file_label = filename or (dangling or {}).get("filename") or state
|
||||||
store.set_bed_needs_clear(conn, host, True, file_label)
|
|
||||||
store.abandon_live_runs(conn, host)
|
|
||||||
self._emit(
|
|
||||||
conn,
|
|
||||||
host,
|
|
||||||
name,
|
|
||||||
"abbruch",
|
|
||||||
"%s: Druck abgebrochen (%s)" % (
|
|
||||||
name, file_label if file_label != state else "ohne Datei",
|
|
||||||
),
|
|
||||||
file_label if file_label != state else "",
|
|
||||||
run_id or prev.get("run_id") or (dangling or {}).get("id"),
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
from grotti import jobs
|
jobs.sync_history(conn, self.cfg, host=host)
|
||||||
|
|
||||||
jobs.sync_history(conn, self.cfg)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
log.warning("Watcher Abgleich %s: %s", host, exc)
|
||||||
|
# Nach Sync evtl. schon Fortsetzung — dann nicht aufgeben.
|
||||||
|
still = store.find_live_run(conn, host) or store.find_open_history_run(
|
||||||
|
conn, host, filename or (dangling or {}).get("filename") or ""
|
||||||
|
)
|
||||||
|
if still and (still.get("state") or "") in ("in_progress", "printing", "paused"):
|
||||||
|
self._cancel_hits[host] = 0
|
||||||
|
run_id = still["id"]
|
||||||
|
store.set_printer_active_run(conn, host, run_id)
|
||||||
|
else:
|
||||||
|
store.set_bed_needs_clear(conn, host, True, file_label)
|
||||||
|
store.abandon_live_runs(conn, host)
|
||||||
|
store.set_printer_active_run(conn, host, None)
|
||||||
|
emit_id = run_id or prev.get("run_id") or (dangling or {}).get("id")
|
||||||
|
label = file_label if file_label != state else ""
|
||||||
|
if emit_id and not store.get_run_row(conn, emit_id):
|
||||||
|
# History-Abgleich hat den Live-Lauf übernommen und gelöscht.
|
||||||
|
emit_id = store.latest_run_id(conn, host, label)
|
||||||
|
self._emit(
|
||||||
|
conn,
|
||||||
|
host,
|
||||||
|
name,
|
||||||
|
"abbruch",
|
||||||
|
"%s: Druck abgebrochen (%s)" % (
|
||||||
|
name, label or "ohne Datei",
|
||||||
|
),
|
||||||
|
label,
|
||||||
|
emit_id,
|
||||||
|
)
|
||||||
|
# prev-Zustand behalten, bis die Hits reichen.
|
||||||
|
if hits < 3 and from_active:
|
||||||
|
self._prev[host] = {
|
||||||
|
"state": prev_state,
|
||||||
|
"offline": False,
|
||||||
|
"heating": heating,
|
||||||
|
"filename": prev.get("filename") or filename,
|
||||||
|
"spool_warn": bool(prev.get("spool_warn")),
|
||||||
|
"run_id": run_id or prev.get("run_id"),
|
||||||
|
}
|
||||||
|
return
|
||||||
elif state == "error" and prev_state != "error":
|
elif state == "error" and prev_state != "error":
|
||||||
if prev_state == "printing" or prev_state == "paused":
|
if prev_state == "printing" or prev_state == "paused":
|
||||||
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
|
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
|
||||||
|
try:
|
||||||
|
jobs.sync_history(conn, self.cfg, host=host)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Watcher Abgleich %s: %s", host, exc)
|
||||||
store.abandon_live_runs(conn, host)
|
store.abandon_live_runs(conn, host)
|
||||||
|
store.set_printer_active_run(conn, host, None)
|
||||||
msg = (live.get("message") or "").strip()
|
msg = (live.get("message") or "").strip()
|
||||||
if not msg or msg.lower() in ("error", "fehler"):
|
if not msg or msg.lower() in ("error", "fehler"):
|
||||||
msg = "Ursache unbekannt — in Mainsail prüfen"
|
msg = "Ursache unbekannt — in Mainsail prüfen"
|
||||||
@@ -214,18 +313,10 @@ class StateWatcher:
|
|||||||
filename,
|
filename,
|
||||||
run_id or prev.get("run_id"),
|
run_id or prev.get("run_id"),
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
from grotti import jobs
|
|
||||||
|
|
||||||
jobs.sync_history(conn, self.cfg)
|
|
||||||
except Exception as exc:
|
|
||||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
|
||||||
elif state == "error":
|
elif state == "error":
|
||||||
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
|
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
|
||||||
self._cool_heaters(host)
|
self._cool_heaters(host)
|
||||||
elif state == "complete" and (not prev_state or prev_offline):
|
elif state == "complete" and (not prev_state or prev_offline):
|
||||||
# Erster Blick nach Neustart: nur mit Endzeit des letzten Laufs,
|
|
||||||
# sonst wird bereits Quittiertes wieder „Platte belegt“.
|
|
||||||
ended = conn.execute(
|
ended = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT ended_at FROM runs
|
SELECT ended_at FROM runs
|
||||||
@@ -243,6 +334,10 @@ class StateWatcher:
|
|||||||
ended_at=ended["ended_at"] if ended else None,
|
ended_at=ended["ended_at"] if ended else None,
|
||||||
message=filename or "fertig",
|
message=filename or "fertig",
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
jobs.sync_history(conn, self.cfg, host=host)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Watcher Abgleich %s: %s", host, exc)
|
||||||
|
|
||||||
spool_warn = bool(prev.get("spool_warn"))
|
spool_warn = bool(prev.get("spool_warn"))
|
||||||
if state == "printing" and not spool_warn:
|
if state == "printing" and not spool_warn:
|
||||||
@@ -257,6 +352,49 @@ class StateWatcher:
|
|||||||
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") or heating else None),
|
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") or heating else None),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _release_replaced_run(self, conn, host, name, filename):
|
||||||
|
"""Anderer Auftrag nach der Lücke: den offenen Lauf loslassen.
|
||||||
|
|
||||||
|
History zuerst, damit Proben am echten Job hängen. Bleibt der alte
|
||||||
|
Live-Lauf, wird er beendet ohne Abzug — der History-Abgleich bucht
|
||||||
|
das Filament, sobald der Job da ist.
|
||||||
|
"""
|
||||||
|
existing = store.find_live_run(conn, host)
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"):
|
||||||
|
return False
|
||||||
|
label = existing.get("filename") or "ohne Datei"
|
||||||
|
old_id = existing["id"]
|
||||||
|
try:
|
||||||
|
jobs.sync_history(conn, self.cfg, host=host)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Watcher Abgleich nach Offline %s: %s", host, exc)
|
||||||
|
existing = store.find_live_run(conn, host)
|
||||||
|
if not existing:
|
||||||
|
# History hat den Live-Lauf übernommen. Ereignis an den überlebenden Lauf.
|
||||||
|
emit_id = old_id if store.get_run_row(conn, old_id) else store.latest_run_id(conn, host, label)
|
||||||
|
self._emit(
|
||||||
|
conn, host, name, "abbruch",
|
||||||
|
"%s: Druck unterbrochen (%s)" % (name, label),
|
||||||
|
label, emit_id,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"):
|
||||||
|
return False
|
||||||
|
label = existing.get("filename") or "ohne Datei"
|
||||||
|
store.abandon_live_runs(conn, host)
|
||||||
|
self._emit(
|
||||||
|
conn,
|
||||||
|
host,
|
||||||
|
name,
|
||||||
|
"abbruch",
|
||||||
|
"%s: Druck unterbrochen (%s)" % (name, label),
|
||||||
|
label,
|
||||||
|
existing["id"],
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
def _should_sample(self, conn, host, live, state):
|
def _should_sample(self, conn, host, live, state):
|
||||||
"""Proben bei Druck/Pause und beim Aufheizen mit Bezug zu einem Auftrag."""
|
"""Proben bei Druck/Pause und beim Aufheizen mit Bezug zu einem Auftrag."""
|
||||||
if state in ("printing", "paused"):
|
if state in ("printing", "paused"):
|
||||||
@@ -286,6 +424,8 @@ class StateWatcher:
|
|||||||
return None
|
return None
|
||||||
sent = store.latest_send(conn, host, filename) if filename else None
|
sent = store.latest_send(conn, host, filename) if filename else None
|
||||||
spool_id = (sent or {}).get("spool_id")
|
spool_id = (sent or {}).get("spool_id")
|
||||||
|
# Send kann nach Rollenwechsel veraltet sein — aktuelle Rolle am Host bevorzugt
|
||||||
|
spool_id = jobs.resolve_tracking_spool(conn, host, spool_id, ace_map=(sent or {}).get("ace_map"))
|
||||||
run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id)
|
run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id)
|
||||||
objects = self._merge_objects(run.get("objects"), live.get("exclude"))
|
objects = self._merge_objects(run.get("objects"), live.get("exclude"))
|
||||||
planned = usage.as_float(live.get("weight"))
|
planned = usage.as_float(live.get("weight"))
|
||||||
@@ -370,7 +510,11 @@ class StateWatcher:
|
|||||||
})
|
})
|
||||||
|
|
||||||
def _ensure_start_segment(self, conn, host, filename, live, run_id):
|
def _ensure_start_segment(self, conn, host, filename, live, run_id):
|
||||||
"""Nur externe Einzelspule: ein Abschnitt. ACE-Farben ohne Wechsel → Anteil-Split."""
|
"""Startabschnitte: externe Spule oder ACE-Plätze mit eingelegter Spule.
|
||||||
|
|
||||||
|
Ohne Rollenwechsel bleibt ACE-Abzug Anteil-Split (ein Segment je Platz).
|
||||||
|
Mit Wechsel greift die mm-Aufteilung innerhalb der Farbe (0→jetzt).
|
||||||
|
"""
|
||||||
if not run_id:
|
if not run_id:
|
||||||
return
|
return
|
||||||
existing = store.list_run_segments(conn, run_id)
|
existing = store.list_run_segments(conn, run_id)
|
||||||
@@ -381,9 +525,18 @@ class StateWatcher:
|
|||||||
from grotti import acm
|
from grotti import acm
|
||||||
|
|
||||||
mapping = acm.normalize_mapping(ace) if ace else []
|
mapping = acm.normalize_mapping(ace) if ace else []
|
||||||
if mapping:
|
|
||||||
return
|
|
||||||
used_mm = usage.as_float(live.get("filament_used"))
|
used_mm = usage.as_float(live.get("filament_used"))
|
||||||
|
if mapping:
|
||||||
|
seen = set()
|
||||||
|
for item in mapping:
|
||||||
|
slot = int(item["ams_index"]) + 1
|
||||||
|
if slot in seen:
|
||||||
|
continue
|
||||||
|
seen.add(slot)
|
||||||
|
row = store.spool_at_slot(conn, host, slot)
|
||||||
|
if row:
|
||||||
|
store.open_segment(conn, run_id, row["id"], slot, used_mm)
|
||||||
|
return
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id FROM spools
|
SELECT id FROM spools
|
||||||
@@ -399,7 +552,7 @@ class StateWatcher:
|
|||||||
try:
|
try:
|
||||||
moonraker.heaters_off(host)
|
moonraker.heaters_off(host)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Heizung aus %s: %s" % (host, exc), flush=True)
|
log.warning("Watcher Heizung aus %s: %s", host, exc)
|
||||||
|
|
||||||
def _check_spool(self, conn, host, name, filename, live):
|
def _check_spool(self, conn, host, name, filename, live):
|
||||||
need = float(live.get("weight") or 0)
|
need = float(live.get("weight") or 0)
|
||||||
@@ -434,6 +587,10 @@ class StateWatcher:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
|
def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
|
||||||
|
# Eine gelöschte Lauf-ID (Live wurde gerade übernommen) darf den Tick
|
||||||
|
# nicht am Fremdschlüssel zurückrollen — sonst ist der Abbruch weg.
|
||||||
|
if run_id is not None and not store.get_run_row(conn, run_id):
|
||||||
|
run_id = None
|
||||||
notify.record(
|
notify.record(
|
||||||
conn, self.cfg, host, printer, kind, message, filename,
|
conn, self.cfg, host, printer, kind, message, filename,
|
||||||
dedupe_s=60, run_id=run_id,
|
dedupe_s=60, run_id=run_id,
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut grotti_<version>_<arch>.deb (auf dem Zielsystem oder arm64-Container).
|
||||||
|
# Aufruf aus dem Repo-Root: bash packaging/build-deb.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
if ! command -v dpkg-deb >/dev/null 2>&1; then
|
||||||
|
echo "dpkg-deb fehlt — bitte auf Debian/Raspberry Pi OS bauen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="$(python3 -c "from grotti.version import __version__; print(__version__)")"
|
||||||
|
ARCH="$(dpkg --print-architecture)"
|
||||||
|
PKG="grotti_${VERSION}_${ARCH}"
|
||||||
|
DIST="$ROOT/packaging/dist"
|
||||||
|
STAGE="$DIST/$PKG"
|
||||||
|
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
mkdir -p "$STAGE/DEBIAN" \
|
||||||
|
"$STAGE/usr/lib/grotti" \
|
||||||
|
"$STAGE/usr/bin" \
|
||||||
|
"$STAGE/etc/grotti" \
|
||||||
|
"$STAGE/lib/systemd/system" \
|
||||||
|
"$STAGE/usr/share/grotti" \
|
||||||
|
"$STAGE/usr/share/doc/grotti" \
|
||||||
|
"$STAGE/var/lib/grotti/data/eingang" \
|
||||||
|
"$STAGE/var/lib/grotti/slice"
|
||||||
|
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude '__pycache__' --exclude '*.pyc' --exclude '.pytest_cache' \
|
||||||
|
"$ROOT/grotti/" "$STAGE/usr/lib/grotti/grotti/"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude '__pycache__' --exclude '*.pyc' \
|
||||||
|
"$ROOT/web/" "$STAGE/usr/lib/grotti/web/"
|
||||||
|
cp "$ROOT/server.py" "$STAGE/usr/lib/grotti/server.py"
|
||||||
|
cp "$ROOT/requirements.txt" "$STAGE/usr/lib/grotti/requirements.txt"
|
||||||
|
cp "$ROOT/config.example.json" "$STAGE/usr/lib/grotti/config.example.json"
|
||||||
|
mkdir -p "$STAGE/usr/lib/grotti/scripts"
|
||||||
|
cp "$ROOT/scripts/pfade_umziehen.py" "$STAGE/usr/lib/grotti/scripts/pfade_umziehen.py"
|
||||||
|
# Symlink-Pfad für die Anleitung
|
||||||
|
mkdir -p "$STAGE/opt/grotti"
|
||||||
|
ln -sfn /usr/lib/grotti/scripts "$STAGE/opt/grotti/scripts"
|
||||||
|
ln -sfn /usr/lib/grotti/venv "$STAGE/opt/grotti/venv"
|
||||||
|
echo '[]' > "$STAGE/usr/lib/grotti/drucker.json"
|
||||||
|
cp "$ROOT/packaging/config.pi.json" "$STAGE/usr/share/grotti/config.pi.json"
|
||||||
|
cp "$ROOT/packaging/config.pi.json" "$STAGE/etc/grotti/config.json"
|
||||||
|
cp "$ROOT/packaging/grotti.service" "$STAGE/lib/systemd/system/grotti.service"
|
||||||
|
cp "$ROOT/packaging/grotti-marlin.service" "$STAGE/lib/systemd/system/grotti-marlin.service"
|
||||||
|
mkdir -p "$STAGE/etc/sudoers.d"
|
||||||
|
cp "$ROOT/packaging/sudoers.d/grotti-marlin" "$STAGE/etc/sudoers.d/grotti-marlin"
|
||||||
|
chmod 440 "$STAGE/etc/sudoers.d/grotti-marlin"
|
||||||
|
cp "$ROOT/docs/Anleitung-Installation-Pi.md" "$STAGE/usr/share/doc/grotti/"
|
||||||
|
cp "$ROOT/CHANGELOG.md" "$STAGE/usr/share/doc/grotti/changelog"
|
||||||
|
gzip -9 -f "$STAGE/usr/share/doc/grotti/changelog"
|
||||||
|
|
||||||
|
cat > "$STAGE/usr/bin/grotti" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
export GROTTI_CONFIG="${GROTTI_CONFIG:-/etc/grotti/config.json}"
|
||||||
|
exec /usr/lib/grotti/venv/bin/python /usr/lib/grotti/server.py "$@"
|
||||||
|
EOF
|
||||||
|
chmod 755 "$STAGE/usr/bin/grotti"
|
||||||
|
|
||||||
|
python3 -m venv "$STAGE/usr/lib/grotti/venv"
|
||||||
|
"$STAGE/usr/lib/grotti/venv/bin/pip" install --upgrade pip wheel
|
||||||
|
"$STAGE/usr/lib/grotti/venv/bin/pip" install -r "$ROOT/requirements.txt"
|
||||||
|
|
||||||
|
SIZE_KB="$(du -sk "$STAGE" | awk '{print $1}')"
|
||||||
|
cat > "$STAGE/DEBIAN/control" <<EOF
|
||||||
|
Package: grotti
|
||||||
|
Version: ${VERSION}
|
||||||
|
Section: web
|
||||||
|
Priority: optional
|
||||||
|
Architecture: ${ARCH}
|
||||||
|
Maintainer: Dominic Gundel <[email protected]>
|
||||||
|
Depends: python3 (>= 3.11), python3-venv, postgresql, adduser
|
||||||
|
Installed-Size: ${SIZE_KB}
|
||||||
|
Homepage: https://git.gundel.dev/dg/grotti
|
||||||
|
Description: Farm-Manager für Moonraker-Drucker (Kobra 3 / S1)
|
||||||
|
Grotti steuert eine 3D-Drucker-Farm über Moonraker: Wand, Warteschlange,
|
||||||
|
Material, Abläufe und Protokoll. PostgreSQL wird lokal mit eingerichtet.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cp "$ROOT/packaging/debian/conffiles" "$STAGE/DEBIAN/conffiles"
|
||||||
|
for script in postinst prerm postrm; do
|
||||||
|
cp "$ROOT/packaging/debian/$script" "$STAGE/DEBIAN/$script"
|
||||||
|
chmod 755 "$STAGE/DEBIAN/$script"
|
||||||
|
done
|
||||||
|
|
||||||
|
dpkg-deb --root-owner-group --build "$STAGE" "$DIST/${PKG}.deb"
|
||||||
|
echo "Gebaut: $DIST/${PKG}.deb"
|
||||||
|
echo "Install: sudo apt install ./packaging/dist/${PKG}.deb"
|
||||||
|
echo "Logs: journalctl -u grotti -f"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8770,
|
||||||
|
"data_dir": "/var/lib/grotti/data",
|
||||||
|
"watch_dir": "/var/lib/grotti/slice",
|
||||||
|
"database_url": "postgresql:///grotti?host=/var/run/postgresql",
|
||||||
|
"token": "",
|
||||||
|
"secret_key": "",
|
||||||
|
"fqdn": "",
|
||||||
|
"log_level": "INFO",
|
||||||
|
"http_threads": 8,
|
||||||
|
"auto_queue": true,
|
||||||
|
"auto_retry": true,
|
||||||
|
"oauth": {
|
||||||
|
"enabled": false,
|
||||||
|
"issuer": "",
|
||||||
|
"client_id": "",
|
||||||
|
"client_secret": "",
|
||||||
|
"redirect_uri": ""
|
||||||
|
},
|
||||||
|
"energy": {
|
||||||
|
"watts": 40,
|
||||||
|
"nozzle_watts": 60,
|
||||||
|
"bed_watts": 220,
|
||||||
|
"euro_per_kwh": 0.35,
|
||||||
|
"dryer_watts": 0,
|
||||||
|
"dry_hours": 0,
|
||||||
|
"waste_percent": 0,
|
||||||
|
"utilization": 50,
|
||||||
|
"tariff": {
|
||||||
|
"ht_euro": 0.35,
|
||||||
|
"nt_euro": 0.22,
|
||||||
|
"nt_from": "22:00",
|
||||||
|
"nt_to": "06:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"enabled": false,
|
||||||
|
"bot_token": "",
|
||||||
|
"chat_id": ""
|
||||||
|
},
|
||||||
|
"ntfy": {
|
||||||
|
"enabled": false,
|
||||||
|
"server": "https://ntfy.sh",
|
||||||
|
"topic": "",
|
||||||
|
"token": ""
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"fertig": true,
|
||||||
|
"fehler": true,
|
||||||
|
"offline": true,
|
||||||
|
"queue": true,
|
||||||
|
"spule": true,
|
||||||
|
"ablauf": false,
|
||||||
|
"start": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/etc/grotti/config.json
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
_pg() {
|
||||||
|
if command -v runuser >/dev/null 2>&1; then
|
||||||
|
runuser -u postgres -- "$@"
|
||||||
|
else
|
||||||
|
su -s /bin/sh postgres -c "$*"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
configure)
|
||||||
|
if ! getent passwd grotti >/dev/null; then
|
||||||
|
adduser --system --group --home /var/lib/grotti --no-create-home \
|
||||||
|
--gecos "Grotti farm manager" grotti
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p /var/lib/grotti/data/eingang /var/lib/grotti/slice /etc/grotti
|
||||||
|
chown -R grotti:grotti /var/lib/grotti
|
||||||
|
chmod 750 /var/lib/grotti
|
||||||
|
|
||||||
|
if [ ! -f /etc/grotti/config.json ]; then
|
||||||
|
cp /usr/share/grotti/config.pi.json /etc/grotti/config.json
|
||||||
|
fi
|
||||||
|
chmod 640 /etc/grotti/config.json
|
||||||
|
chown root:grotti /etc/grotti/config.json
|
||||||
|
|
||||||
|
if getent passwd postgres >/dev/null 2>&1; then
|
||||||
|
_pg psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='grotti'" | grep -q 1 \
|
||||||
|
|| _pg createuser -D -R -S grotti || true
|
||||||
|
_pg psql -tAc "SELECT 1 FROM pg_database WHERE datname='grotti'" | grep -q 1 \
|
||||||
|
|| _pg createdb -O grotti grotti || true
|
||||||
|
_pg psql -c "GRANT ALL PRIVILEGES ON DATABASE grotti TO grotti;" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if getent group dialout >/dev/null 2>&1; then
|
||||||
|
usermod -aG dialout grotti || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
systemctl enable grotti.service grotti-marlin.service || true
|
||||||
|
# start, nicht restart: ein laufender USB-Druck bleibt.
|
||||||
|
systemctl start grotti-marlin.service || true
|
||||||
|
systemctl restart grotti.service || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
purge)
|
||||||
|
rm -f /etc/grotti/config.json
|
||||||
|
rmdir /etc/grotti 2>/dev/null || true
|
||||||
|
# Daten und DB bleiben absichtlich (Backup). Manuell:
|
||||||
|
# sudo rm -rf /var/lib/grotti
|
||||||
|
# sudo -u postgres dropdb grotti; sudo -u postgres dropuser grotti
|
||||||
|
;;
|
||||||
|
remove)
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
remove|upgrade|deconfigure)
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl stop grotti.service 2>/dev/null || true
|
||||||
|
systemctl disable grotti.service 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
Vendored
Whitespace-only changes.
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Grotti Marlin über USB
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=grotti
|
||||||
|
Group=grotti
|
||||||
|
SupplementaryGroups=dialout
|
||||||
|
WorkingDirectory=/usr/lib/grotti
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=PYTHONPATH=/usr/lib/grotti
|
||||||
|
ExecStart=/usr/lib/grotti/venv/bin/python -m grotti.marlin_daemon
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
KillMode=process
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Grotti Farm-Manager
|
||||||
|
Documentation=file:///usr/share/doc/grotti/Anleitung-Installation-Pi.md
|
||||||
|
After=network-online.target postgresql.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=grotti
|
||||||
|
Group=grotti
|
||||||
|
WorkingDirectory=/usr/lib/grotti
|
||||||
|
Environment=GROTTI_CONFIG=/etc/grotti/config.json
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/usr/lib/grotti/venv/bin/python /usr/lib/grotti/server.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
# USB-Marlin liegt in grotti-marlin.service und bleibt bei einem Neustart hier stehen.
|
||||||
|
# Stopp nicht 90 s hängen: nach TimeoutStopSec folgt SIGKILL
|
||||||
|
TimeoutStopSec=20
|
||||||
|
KillMode=mixed
|
||||||
|
# Logs → journald (journalctl -u grotti -f)
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=grotti
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
grotti ALL=(root) NOPASSWD: /usr/bin/systemctl start grotti-marlin.service, /bin/systemctl start grotti-marlin.service
|
||||||
+2
-1
@@ -4,5 +4,6 @@ psycopg-pool==3.3.3
|
|||||||
paho-mqtt==2.1.0
|
paho-mqtt==2.1.0
|
||||||
cryptography==50.0.2
|
cryptography==50.0.2
|
||||||
markdown==3.11.1
|
markdown==3.11.1
|
||||||
mcp>=1.2
|
mcp==2.0.0
|
||||||
waitress==3.0.2
|
waitress==3.0.2
|
||||||
|
pyserial==3.5
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Alt-Paare und verwaiste Live-Läufe finden. Standard: Probelauf.
|
||||||
|
|
||||||
|
python scripts/laeufe_reparieren.py
|
||||||
|
python scripts/laeufe_reparieren.py --apply
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from grotti import jobs, store # noqa: E402
|
||||||
|
from grotti.config import load # noqa: E402
|
||||||
|
from grotti.db import connect, prepare # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def find_pairs(conn):
|
||||||
|
return conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT a.id AS pred_id, b.id AS succ_id,
|
||||||
|
a.printer_host, a.filename,
|
||||||
|
a.state AS pred_state, b.state AS succ_state,
|
||||||
|
a.duration_s AS pred_dur, b.duration_s AS succ_dur,
|
||||||
|
a.ended_at, b.started_at,
|
||||||
|
a.deducted AS pred_deducted, b.deducted AS succ_deducted,
|
||||||
|
a.chargen_nr AS pred_chargen, b.chargen_nr AS succ_chargen
|
||||||
|
FROM runs a
|
||||||
|
JOIN runs b
|
||||||
|
ON a.printer_host = b.printer_host
|
||||||
|
AND a.filename = b.filename
|
||||||
|
AND a.id <> b.id
|
||||||
|
AND b.started_at BETWEEN a.ended_at - interval '30 seconds'
|
||||||
|
AND a.ended_at + interval '5 seconds'
|
||||||
|
WHERE a.state IN ('cancelled', 'interrupted', 'error')
|
||||||
|
AND a.ended_at IS NOT NULL
|
||||||
|
AND a.moonraker_job_id NOT LIKE 'live:%%'
|
||||||
|
AND b.moonraker_job_id NOT LIKE 'live:%%'
|
||||||
|
AND COALESCE(b.duration_s, 0) > 60
|
||||||
|
AND COALESCE(b.duration_s, 0) >= COALESCE(a.duration_s, 0) - 30
|
||||||
|
ORDER BY a.ended_at DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def find_orphan_live(conn):
|
||||||
|
return conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, printer_host, filename, state, started_at, ended_at, duration_s, chargen_nr
|
||||||
|
FROM runs
|
||||||
|
WHERE moonraker_job_id LIKE 'live:%%'
|
||||||
|
AND state IN ('interrupted', 'completed', 'cancelled')
|
||||||
|
ORDER BY COALESCE(ended_at, started_at) DESC
|
||||||
|
LIMIT 100
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Läufe nach Filament-Resume reparieren")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Wirklich zusammenlegen")
|
||||||
|
args = parser.parse_args()
|
||||||
|
cfg = load()
|
||||||
|
prepare(cfg["database_url"])
|
||||||
|
with connect(cfg["database_url"]) as conn:
|
||||||
|
pairs = find_pairs(conn)
|
||||||
|
orphans = find_orphan_live(conn)
|
||||||
|
print("=== Verdachtspaare (Beweis: Dauer fortgesetzt, Lücke ≤ 5 s) ===")
|
||||||
|
for row in pairs:
|
||||||
|
print(json.dumps({k: str(v) for k, v in dict(row).items()}, ensure_ascii=False))
|
||||||
|
print("%d Paare" % len(pairs))
|
||||||
|
print("=== Verwaiste live:-Läufe ===")
|
||||||
|
for row in orphans:
|
||||||
|
print(json.dumps({k: str(v) for k, v in dict(row).items()}, ensure_ascii=False))
|
||||||
|
print("%d orphan" % len(orphans))
|
||||||
|
if not args.apply:
|
||||||
|
print("Probelauf. Mit --apply werden Paare verbunden.")
|
||||||
|
conn.rollback()
|
||||||
|
return
|
||||||
|
merged = 0
|
||||||
|
for row in pairs:
|
||||||
|
try:
|
||||||
|
jobs.manual_merge_runs(
|
||||||
|
conn, row["pred_id"], row["succ_id"], confirm=True
|
||||||
|
)
|
||||||
|
merged += 1
|
||||||
|
print("verbunden", row["pred_id"], "+", row["succ_id"])
|
||||||
|
except Exception as exc:
|
||||||
|
print("Fehler", row["pred_id"], row["succ_id"], exc)
|
||||||
|
conn.rollback()
|
||||||
|
# Orphans nur an History derselben Datei hängen (kein Host-Fallback).
|
||||||
|
# adopt_live_run nimmt geschlossene live: nur ≤ 2 h — ältere bleiben liegen.
|
||||||
|
cleaned = 0
|
||||||
|
skipped = 0
|
||||||
|
for row in orphans:
|
||||||
|
fname = (row.get("filename") or "").strip()
|
||||||
|
if not fname:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
hist = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM runs
|
||||||
|
WHERE printer_host = %s
|
||||||
|
AND filename = %s
|
||||||
|
AND moonraker_job_id NOT LIKE 'live:%%'
|
||||||
|
AND state IN ('in_progress', 'printing', 'paused')
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(row["printer_host"], fname),
|
||||||
|
).fetchone()
|
||||||
|
if not hist:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
store.adopt_live_run(conn, row["printer_host"], fname, hist["id"])
|
||||||
|
still = conn.execute(
|
||||||
|
"SELECT 1 FROM runs WHERE id = %s", (row["id"],)
|
||||||
|
).fetchone()
|
||||||
|
if still:
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
cleaned += 1
|
||||||
|
conn.commit()
|
||||||
|
print(
|
||||||
|
"verbunden=%d orphan_adopted=%d orphan_skipped=%d"
|
||||||
|
% (merged, cleaned, skipped)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Empfänger für Grotti-Meldungen → Issue auf Forgejo/Gitea.
|
||||||
|
|
||||||
|
Läuft getrennt von den Installationen. Der Token bleibt hier.
|
||||||
|
|
||||||
|
export FORGEJO_URL=https://git.gundel.dev
|
||||||
|
export FORGEJO_TOKEN=...
|
||||||
|
export FORGEJO_REPO=dg/grotti
|
||||||
|
python scripts/meldung_relay.py
|
||||||
|
|
||||||
|
Port über RELAY_PORT (Standard 8780). Vor Forgejo mit Reverse-Proxy unter
|
||||||
|
/hooks/grotti-meldung legen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
|
||||||
|
def env(name, default=""):
|
||||||
|
return (os.environ.get(name) or default).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def create_issue(title, body):
|
||||||
|
base = env("FORGEJO_URL", "https://git.gundel.dev").rstrip("/")
|
||||||
|
repo = env("FORGEJO_REPO", "dg/grotti")
|
||||||
|
token = env("FORGEJO_TOKEN")
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("FORGEJO_TOKEN fehlt.")
|
||||||
|
url = "%s/api/v1/repos/%s/issues" % (base, repo)
|
||||||
|
payload = json.dumps({"title": title[:120], "body": body}).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "token %s" % token,
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=20) as response:
|
||||||
|
data = json.loads(response.read().decode("utf-8"))
|
||||||
|
return data.get("html_url") or data.get("url") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def issue_body(data):
|
||||||
|
lines = [
|
||||||
|
data.get("text") or "",
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"Version: %s" % (data.get("version") or "—"),
|
||||||
|
"Seite: %s" % (data.get("page") or "—"),
|
||||||
|
"Drucker: %s" % (data.get("printer_host") or "—"),
|
||||||
|
]
|
||||||
|
err = (data.get("error_text") or "").strip()
|
||||||
|
if err:
|
||||||
|
lines.extend(["", "Fehlertext:", "```", err[:2000], "```"])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
return
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.split("?", 1)[0] in ("/", "/gesundheit", "/hooks/grotti-meldung"):
|
||||||
|
self._json(200, {"ok": True})
|
||||||
|
return
|
||||||
|
self._json(404, {"message": "Unbekannt."})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
path = self.path.split("?", 1)[0]
|
||||||
|
if path not in ("/", "/hooks/grotti-meldung"):
|
||||||
|
self._json(404, {"message": "Unbekannt."})
|
||||||
|
return
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
raw = self.rfile.read(length) if length else b"{}"
|
||||||
|
try:
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeError, json.JSONDecodeError):
|
||||||
|
self._json(400, {"message": "Kein JSON."})
|
||||||
|
return
|
||||||
|
if not isinstance(data, dict) or not (data.get("text") or "").strip():
|
||||||
|
self._json(400, {"message": "text fehlt."})
|
||||||
|
return
|
||||||
|
title = "Grotti: %s" % ((data.get("text") or "").strip().splitlines()[0][:80])
|
||||||
|
try:
|
||||||
|
url = create_issue(title, issue_body(data))
|
||||||
|
except Exception as exc:
|
||||||
|
self._json(502, {"message": str(exc)})
|
||||||
|
return
|
||||||
|
self._json(201, {"ok": True, "url": url})
|
||||||
|
|
||||||
|
def _json(self, code, payload):
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
port = int(env("RELAY_PORT", "8780") or 8780)
|
||||||
|
httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+208
-9
@@ -13,8 +13,11 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -25,7 +28,19 @@ from urllib.request import Request, urlopen
|
|||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
CONFIG_PATH = REPO_ROOT / "data" / "orca-senden.json"
|
CONFIG_PATH = REPO_ROOT / "data" / "orca-senden.json"
|
||||||
DEBUG_LOG = REPO_ROOT / "data" / "orca-senden-last.log"
|
DEBUG_LOG = REPO_ROOT / "data" / "orca-senden-last.log"
|
||||||
|
BROWSER_LOCK = REPO_ROOT / "data" / "orca-senden-browser.lock"
|
||||||
|
FOCUS_PATH = REPO_ROOT / "data" / "orca-senden-focus.json"
|
||||||
|
APP_PROFILE = REPO_ROOT / "data" / "grotti-app-profile"
|
||||||
ALLOWED_SUFFIXES = (".gcode", ".gcode.3mf", ".3mf", ".gcode.pp", ".pp")
|
ALLOWED_SUFFIXES = (".gcode", ".gcode.3mf", ".3mf", ".gcode.pp", ".pp")
|
||||||
|
BROWSER_DEBOUNCE_S = 25.0
|
||||||
|
# Anycubic: MMDD-HHMM-Name.gcode — Zeitstempel raus, sonst jedes Export ein neues Fenster
|
||||||
|
_TIME_PREFIX_RE = re.compile(r"^\d{4}-\d{4}-")
|
||||||
|
_CHROMIUM_CANDIDATES = (
|
||||||
|
Path(r"C:\Program Files\Google\Chrome\Application\chrome.exe"),
|
||||||
|
Path(r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"),
|
||||||
|
Path(os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe")),
|
||||||
|
Path(os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def show_error(message: str) -> None:
|
def show_error(message: str) -> None:
|
||||||
@@ -76,7 +91,17 @@ def load_config(path: Path = CONFIG_PATH) -> dict[str, str]:
|
|||||||
raise ValueError("Config: url fehlt (z. B. http://127.0.0.1:8770).")
|
raise ValueError("Config: url fehlt (z. B. http://127.0.0.1:8770).")
|
||||||
if not token:
|
if not token:
|
||||||
raise ValueError("Config: token fehlt (grt_… aus Einstellungen).")
|
raise ValueError("Config: token fehlt (grt_… aus Einstellungen).")
|
||||||
return {"url": url, "token": token}
|
browser = str(data.get("browser") or "app").strip().lower() or "app"
|
||||||
|
if browser not in ("app", "tab"):
|
||||||
|
browser = "app"
|
||||||
|
# Windows-Pfad des Slice-Ordners, wenn Grotti auf dem Pi läuft (dort anderer Pfad)
|
||||||
|
watch_local = str(data.get("watch_dir_local") or data.get("watch_dir") or "").strip()
|
||||||
|
return {
|
||||||
|
"url": url,
|
||||||
|
"token": token,
|
||||||
|
"browser": browser,
|
||||||
|
"watch_dir_local": watch_local,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_gcode_path(path: Path) -> bool:
|
def is_gcode_path(path: Path) -> bool:
|
||||||
@@ -140,6 +165,83 @@ def send_page_url(base_url: str, file_id: int) -> str:
|
|||||||
return "%s/dateien?file=%s" % (base_url.rstrip("/"), int(file_id))
|
return "%s/dateien?file=%s" % (base_url.rstrip("/"), int(file_id))
|
||||||
|
|
||||||
|
|
||||||
|
def app_page_url(base_url: str) -> str:
|
||||||
|
"""Stabile App-URL — Datei kommt über orca-fokus, kein neues Fenster pro Slice."""
|
||||||
|
return "%s/dateien?orca=1" % base_url.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def find_chromium() -> Path | None:
|
||||||
|
for path in _CHROMIUM_CANDIDATES:
|
||||||
|
try:
|
||||||
|
if path.is_file():
|
||||||
|
return path
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def app_already_running() -> bool:
|
||||||
|
"""True wenn Chrome/Edge schon mit grotti-app-profile läuft (Profil-Sperre)."""
|
||||||
|
needle = "grotti-app-profile"
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try:
|
||||||
|
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
out = subprocess.check_output(
|
||||||
|
[
|
||||||
|
"powershell",
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
(
|
||||||
|
"Get-CimInstance Win32_Process | "
|
||||||
|
"Where-Object { "
|
||||||
|
"($_.Name -match '^(chrome|msedge)\\.exe$') -and "
|
||||||
|
"($_.CommandLine -like '*%s*') "
|
||||||
|
"} | Select-Object -First 1 -ExpandProperty ProcessId"
|
||||||
|
)
|
||||||
|
% needle,
|
||||||
|
],
|
||||||
|
timeout=8,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
creationflags=creationflags,
|
||||||
|
)
|
||||||
|
return bool((out or b"").strip())
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
for name in ("SingletonLock", "SingletonSocket", "lockfile"):
|
||||||
|
path = APP_PROFILE / name
|
||||||
|
try:
|
||||||
|
if path.exists() or path.is_symlink():
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def open_as_app(url: str) -> bool:
|
||||||
|
"""Chrome/Edge --app=… → eigenes Fenster auf dem Desktop, kein Browser-Tab."""
|
||||||
|
binary = find_chromium()
|
||||||
|
if binary is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
APP_PROFILE.mkdir(parents=True, exist_ok=True)
|
||||||
|
subprocess.Popen(
|
||||||
|
[
|
||||||
|
str(binary),
|
||||||
|
"--app=%s" % url,
|
||||||
|
"--user-data-dir=%s" % str(APP_PROFILE.resolve()),
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
close_fds=True,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except OSError as exc:
|
||||||
|
write_debug(sys.argv, "app_open_fail=%s" % exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _api_json(
|
def _api_json(
|
||||||
base_url: str,
|
base_url: str,
|
||||||
token: str,
|
token: str,
|
||||||
@@ -192,6 +294,16 @@ def fetch_watch_dir(base_url: str, token: str) -> Path | None:
|
|||||||
return path if path.is_dir() else None
|
return path if path.is_dir() else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_watch_dir(config: dict) -> Path | None:
|
||||||
|
"""Lokaler Slice-Ordner: Config zuerst, sonst der Pfad von Grotti (gleiche Maschine)."""
|
||||||
|
local = str(config.get("watch_dir_local") or "").strip()
|
||||||
|
if local:
|
||||||
|
path = Path(local)
|
||||||
|
if path.is_dir():
|
||||||
|
return path
|
||||||
|
return fetch_watch_dir(config["url"], config["token"])
|
||||||
|
|
||||||
|
|
||||||
def under_watch(watch_dir: Path, target: Path) -> tuple[str, str] | None:
|
def under_watch(watch_dir: Path, target: Path) -> tuple[str, str] | None:
|
||||||
"""(folder_relativ, dateiname) wenn target unter watch_dir liegt."""
|
"""(folder_relativ, dateiname) wenn target unter watch_dir liegt."""
|
||||||
try:
|
try:
|
||||||
@@ -279,13 +391,87 @@ def upload_file(
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def open_browser(url: str) -> None:
|
def browser_key(name: str) -> str:
|
||||||
|
"""Gleicher Slice trotz Zeitstempel / -2 / Leerzeichen → ein Browserfenster."""
|
||||||
|
stem = Path(name or "").stem.lower()
|
||||||
|
stem = _TIME_PREFIX_RE.sub("", stem)
|
||||||
|
stem = re.sub(r"-\d+$", "", stem)
|
||||||
|
return re.sub(r"[^a-z0-9]+", "", stem)
|
||||||
|
|
||||||
|
|
||||||
|
def write_focus(file_id: int, url: str, key: str = "") -> None:
|
||||||
|
"""Offene Dateien-Seite kann auf die neueste Orca-Datei umschalten."""
|
||||||
|
try:
|
||||||
|
FOCUS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
FOCUS_PATH.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"file_id": int(file_id),
|
||||||
|
"url": url,
|
||||||
|
"key": key,
|
||||||
|
"ts": time.time(),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except (OSError, TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def open_browser(
|
||||||
|
url: str,
|
||||||
|
key: str = "",
|
||||||
|
file_id: int | None = None,
|
||||||
|
mode: str = "app",
|
||||||
|
) -> None:
|
||||||
|
"""Anycubic/Orca starten das Skript oft 2× — nur ein Fenster pro Modell.
|
||||||
|
|
||||||
|
mode=app (Standard): Chrome/Edge als App-Fenster auf dem Desktop.
|
||||||
|
mode=tab: klassischer Browser-Tab.
|
||||||
|
"""
|
||||||
|
token = (key or url).strip() or url
|
||||||
|
now = time.time()
|
||||||
|
if file_id is not None:
|
||||||
|
write_focus(int(file_id), url, key=token)
|
||||||
|
try:
|
||||||
|
BROWSER_LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if BROWSER_LOCK.is_file():
|
||||||
|
prev = json.loads(BROWSER_LOCK.read_text(encoding="utf-8"))
|
||||||
|
if (
|
||||||
|
isinstance(prev, dict)
|
||||||
|
and prev.get("key") == token
|
||||||
|
and now - float(prev.get("ts") or 0) < BROWSER_DEBOUNCE_S
|
||||||
|
):
|
||||||
|
write_debug(
|
||||||
|
sys.argv,
|
||||||
|
"browser_skip key=%s age=%.1fs focus=%s"
|
||||||
|
% (token, now - float(prev.get("ts") or 0), file_id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
BROWSER_LOCK.write_text(
|
||||||
|
json.dumps({"key": token, "ts": now, "url": url, "mode": mode}, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except (OSError, TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
if (mode or "app").strip().lower() != "tab":
|
||||||
|
# Zweites Chrome mit gleichem Profil = leeres Profil / erneutes Login.
|
||||||
|
# Offenes Fenster pollt orca-fokus und wechselt die Datei selbst.
|
||||||
|
if app_already_running():
|
||||||
|
write_debug(sys.argv, "browser_reuse focus=%s" % file_id)
|
||||||
|
return
|
||||||
|
if open_as_app(url):
|
||||||
|
write_debug(sys.argv, "browser_app url=%s" % url)
|
||||||
|
return
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
|
||||||
def deliver(base_url: str, token: str, src: Path) -> int:
|
def deliver(config: dict, src: Path) -> int:
|
||||||
"""Datei in Slice-Unterordner legen oder hochladen; gibt file_id zurück."""
|
"""Datei in Slice-Unterordner legen oder hochladen; gibt file_id zurück."""
|
||||||
watch = fetch_watch_dir(base_url, token)
|
base_url = config["url"]
|
||||||
|
token = config["token"]
|
||||||
|
watch = resolve_watch_dir(config)
|
||||||
target = output_target()
|
target = output_target()
|
||||||
if watch is not None and target is not None:
|
if watch is not None and target is not None:
|
||||||
placed = under_watch(watch, target)
|
placed = under_watch(watch, target)
|
||||||
@@ -315,12 +501,25 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
path = find_gcode_arg(argv)
|
path = find_gcode_arg(argv)
|
||||||
write_debug(
|
write_debug(
|
||||||
argv,
|
argv,
|
||||||
"chosen=%s\nupload_as=%s\noutput=%s"
|
"chosen=%s\nupload_as=%s\noutput=%s\nwatch_local=%s"
|
||||||
% (path, upload_name_for(path), output_target() or ""),
|
% (path, upload_name_for(path), output_target() or "", config.get("watch_dir_local") or ""),
|
||||||
|
)
|
||||||
|
file_id = deliver(config, path)
|
||||||
|
name = upload_name_for(path)
|
||||||
|
write_debug(argv, "file_id=%s\nupload_as=%s" % (file_id, name))
|
||||||
|
mode = str(config.get("browser") or "app").strip().lower()
|
||||||
|
# App: stabile URL; Tab: Deep-Link mit file=
|
||||||
|
open_url = (
|
||||||
|
send_page_url(config["url"], file_id)
|
||||||
|
if mode == "tab"
|
||||||
|
else app_page_url(config["url"])
|
||||||
|
)
|
||||||
|
open_browser(
|
||||||
|
open_url,
|
||||||
|
key=browser_key(name),
|
||||||
|
file_id=file_id,
|
||||||
|
mode=mode,
|
||||||
)
|
)
|
||||||
file_id = deliver(config["url"], config["token"], path)
|
|
||||||
write_debug(argv, "file_id=%s" % file_id)
|
|
||||||
open_browser(send_page_url(config["url"], file_id))
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
write_debug(argv, "error=%s" % exc)
|
write_debug(argv, "error=%s" % exc)
|
||||||
show_error(str(exc))
|
show_error(str(exc))
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Absolute Windows-Pfade in files.stored_path und projects.folder auf Linux umschreiben.
|
||||||
|
|
||||||
|
Beispiel (Dry-Run):
|
||||||
|
python scripts/pfade_umziehen.py \\
|
||||||
|
--database-url postgresql:///grotti?host=/var/run/postgresql \\
|
||||||
|
--from "D:/Projekte/3DP/Slice" --to /var/lib/grotti/slice
|
||||||
|
|
||||||
|
Ohne --apply nur Anzeige. Mit --apply werden die Zeilen geschrieben.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_slash(path: str) -> str:
|
||||||
|
return (path or "").replace("\\", "/").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite(value: str, old: str, new: str) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
cur = value.replace("\\", "/")
|
||||||
|
old_n = normalize_slash(old)
|
||||||
|
new_n = normalize_slash(new)
|
||||||
|
# case-insensitive für Windows-Laufwerke
|
||||||
|
if cur.lower().startswith(old_n.lower()):
|
||||||
|
rest = cur[len(old_n) :]
|
||||||
|
if rest and not rest.startswith("/"):
|
||||||
|
rest = "/" + rest
|
||||||
|
return new_n + rest
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Slice-Pfade Windows → Linux umschreiben")
|
||||||
|
parser.add_argument("--database-url", required=True)
|
||||||
|
parser.add_argument("--from", dest="old", required=True, help="Alter Prefix (Windows)")
|
||||||
|
parser.add_argument("--to", dest="new", required=True, help="Neuer Prefix (Linux)")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Wirklich schreiben")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
old = args.old
|
||||||
|
new = args.new
|
||||||
|
changed_files = 0
|
||||||
|
changed_projects = 0
|
||||||
|
|
||||||
|
with psycopg.connect(args.database_url, row_factory=dict_row) as conn:
|
||||||
|
files = conn.execute("SELECT id, stored_path FROM files ORDER BY id").fetchall()
|
||||||
|
for row in files:
|
||||||
|
nxt = rewrite(row["stored_path"] or "", old, new)
|
||||||
|
if nxt is None or nxt == row["stored_path"]:
|
||||||
|
continue
|
||||||
|
changed_files += 1
|
||||||
|
print("file %s: %s → %s" % (row["id"], row["stored_path"], nxt))
|
||||||
|
if args.apply:
|
||||||
|
conn.execute("UPDATE files SET stored_path = %s WHERE id = %s", (nxt, row["id"]))
|
||||||
|
|
||||||
|
projects = conn.execute("SELECT id, folder FROM projects ORDER BY id").fetchall()
|
||||||
|
for row in projects:
|
||||||
|
folder = row["folder"] or ""
|
||||||
|
# folder ist relativ — nur absolute Reste bereinigen
|
||||||
|
nxt = rewrite(folder, old, new)
|
||||||
|
if nxt is None:
|
||||||
|
continue
|
||||||
|
# relativ zum neuen Slice-Root speichern, falls möglich
|
||||||
|
new_n = normalize_slash(new)
|
||||||
|
if nxt.lower().startswith(new_n.lower()):
|
||||||
|
rel = nxt[len(new_n) :].lstrip("/")
|
||||||
|
nxt = rel
|
||||||
|
changed_projects += 1
|
||||||
|
print("project %s: %s → %s" % (row["id"], folder, nxt))
|
||||||
|
if args.apply:
|
||||||
|
conn.execute("UPDATE projects SET folder = %s WHERE id = %s", (nxt, row["id"]))
|
||||||
|
|
||||||
|
if args.apply:
|
||||||
|
conn.commit()
|
||||||
|
print("Geschrieben: %s Dateien, %s Projekte." % (changed_files, changed_projects))
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"Dry-Run: %s Dateien, %s Projekte würden sich ändern. Mit --apply ausführen."
|
||||||
|
% (changed_files, changed_projects)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -7,10 +7,13 @@ import time
|
|||||||
|
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
|
|
||||||
from grotti import db, jobs, library, store
|
from grotti import db, jobs, library, logutil, store
|
||||||
|
from grotti.marlin_supervisor import enable as enable_marlin
|
||||||
from grotti.config import load
|
from grotti.config import load
|
||||||
from web.app import create_app
|
from web.app import create_app
|
||||||
|
|
||||||
|
log = logutil.get("grotti.server")
|
||||||
|
|
||||||
|
|
||||||
def background(cfg):
|
def background(cfg):
|
||||||
def work():
|
def work():
|
||||||
@@ -20,10 +23,10 @@ def background(cfg):
|
|||||||
jobs.sync_history(conn, cfg)
|
jobs.sync_history(conn, cfg)
|
||||||
deleted = db.prune_run_samples(conn, keep_days=90)
|
deleted = db.prune_run_samples(conn, keep_days=90)
|
||||||
if deleted:
|
if deleted:
|
||||||
print("Proben gelöscht: %s" % deleted, flush=True)
|
log.info("Proben gelöscht: %s", deleted)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Abgleich: %s" % exc, flush=True)
|
log.warning("Abgleich: %s", exc)
|
||||||
|
|
||||||
threading.Thread(target=work, name="grotti-abgleich", daemon=True).start()
|
threading.Thread(target=work, name="grotti-abgleich", daemon=True).start()
|
||||||
|
|
||||||
@@ -39,14 +42,15 @@ def _retention_loop(cfg, stop_event):
|
|||||||
n = db.prune_run_samples(conn, keep_days=90)
|
n = db.prune_run_samples(conn, keep_days=90)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
if n:
|
if n:
|
||||||
print("Retention: %s Proben gelöscht." % n, flush=True)
|
log.info("Retention: %s Proben gelöscht.", n)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Retention: %s" % exc, flush=True)
|
log.warning("Retention: %s", exc)
|
||||||
stop_event.wait(3600)
|
stop_event.wait(3600)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
cfg = load()
|
cfg = load()
|
||||||
|
logutil.setup(cfg.get("log_level") or "INFO")
|
||||||
cfg["data_dir"].mkdir(parents=True, exist_ok=True)
|
cfg["data_dir"].mkdir(parents=True, exist_ok=True)
|
||||||
(cfg["data_dir"] / "eingang").mkdir(exist_ok=True)
|
(cfg["data_dir"] / "eingang").mkdir(exist_ok=True)
|
||||||
db.prepare(cfg["database_url"])
|
db.prepare(cfg["database_url"])
|
||||||
@@ -60,6 +64,7 @@ def main():
|
|||||||
watcher = app.extensions["watcher"]
|
watcher = app.extensions["watcher"]
|
||||||
dispatcher = app.extensions["print_queue"]
|
dispatcher = app.extensions["print_queue"]
|
||||||
send_queue = app.extensions["send_queue"]
|
send_queue = app.extensions["send_queue"]
|
||||||
|
enable_marlin(cfg["data_dir"])
|
||||||
watcher.start()
|
watcher.start()
|
||||||
dispatcher.start()
|
dispatcher.start()
|
||||||
if hasattr(send_queue, "start"):
|
if hasattr(send_queue, "start"):
|
||||||
@@ -99,6 +104,7 @@ def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
db.close_pool()
|
db.close_pool()
|
||||||
|
# Der Marlin-Dienst bleibt. Ein Stopp hier würde den Druck abbrechen.
|
||||||
|
|
||||||
atexit.register(shutdown)
|
atexit.register(shutdown)
|
||||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
@@ -107,8 +113,14 @@ def main():
|
|||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
print("Grotti: http://%s:%s/" % (cfg["host"], cfg["port"]), flush=True)
|
log.info("Grotti: http://%s:%s/", cfg["host"], cfg["port"])
|
||||||
serve(app, host=cfg["host"], port=cfg["port"], threads=16, channel_timeout=120)
|
serve(
|
||||||
|
app,
|
||||||
|
host=cfg["host"],
|
||||||
|
port=cfg["port"],
|
||||||
|
threads=int(cfg.get("http_threads") or 16),
|
||||||
|
channel_timeout=120,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -63,6 +63,30 @@ class BedarfTests(unittest.TestCase):
|
|||||||
out = compute(conn)
|
out = compute(conn)
|
||||||
self.assertEqual(out["shortfall"], [])
|
self.assertEqual(out["shortfall"], [])
|
||||||
|
|
||||||
|
def test_slicer_hex_matches_named_stock(self):
|
||||||
|
"""Slice #26A69A vs. Spule „Grau“ — 1 kg reicht, kein Fehlalarm."""
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.execute.side_effect = self._rows(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"spool_id": None,
|
||||||
|
"repeat_total": 1,
|
||||||
|
"repeat_done": 0,
|
||||||
|
"weight_g": 49.23,
|
||||||
|
"filaments": [{"type": "PLA", "color": "#26A69A"}],
|
||||||
|
"spool_material": None,
|
||||||
|
"spool_color": None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{"material_type": "PLA", "color": "Grau", "remaining_g": 1000.0},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
out = compute(conn)
|
||||||
|
self.assertEqual(out["shortfall"], [])
|
||||||
|
self.assertAlmostEqual(out["needed"][0]["weight_g"], 49.23)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Einfarbig: filament_colour_type strippen; Multicolor unangetastet."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from grotti.parse import gcode_is_multicolor, prepare_rinkhals_gcode, strip_filament_colour_type
|
||||||
|
|
||||||
|
|
||||||
|
class ColourTypeStripTest(unittest.TestCase):
|
||||||
|
def test_strip_single_color(self):
|
||||||
|
raw = (
|
||||||
|
b"; generated by OrcaSlicer 2.3.0\n"
|
||||||
|
b"; filament_colour = #26A69A\n"
|
||||||
|
b"; filament_colour_type = 1\n"
|
||||||
|
b"G28\n"
|
||||||
|
b"T0\n"
|
||||||
|
b"G1 X10\n"
|
||||||
|
)
|
||||||
|
out = strip_filament_colour_type(raw)
|
||||||
|
self.assertNotIn(b"filament_colour_type", out)
|
||||||
|
self.assertIn(b"filament_colour = #26A69A", out)
|
||||||
|
self.assertFalse(gcode_is_multicolor(raw))
|
||||||
|
|
||||||
|
def test_keep_multicolor(self):
|
||||||
|
raw = (
|
||||||
|
b"; generated by OrcaSlicer 2.3.0\n"
|
||||||
|
b"; filament_colour = #111111;#222222\n"
|
||||||
|
b"; filament_colour_type = 0;1\n"
|
||||||
|
b"T0\n"
|
||||||
|
b"G1 X1\n"
|
||||||
|
b"T1\n"
|
||||||
|
b"G1 X2\n"
|
||||||
|
)
|
||||||
|
self.assertTrue(gcode_is_multicolor(raw))
|
||||||
|
out = strip_filament_colour_type(raw)
|
||||||
|
self.assertIn(b"filament_colour_type = 0;1", out)
|
||||||
|
|
||||||
|
def test_prepare_strips_single(self):
|
||||||
|
raw = b"; filament_colour_type = 1\nG28\nT0\n"
|
||||||
|
out = prepare_rinkhals_gcode(raw)
|
||||||
|
self.assertNotIn(b"filament_colour_type", out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Treiberwahl und die LAN-Karte. Kein echter Drucker."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from grotti.drivers import for_printer
|
||||||
|
from grotti.drivers.anycubic_lan import card_from_info, slots_from_report
|
||||||
|
from grotti.moonraker import PrinterError
|
||||||
|
from grotti.printers import family_from_name
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyTests(unittest.TestCase):
|
||||||
|
def test_existing_names_stay_moonraker(self):
|
||||||
|
for name, family in (
|
||||||
|
("Kobra 3 A1", "kobra_3"),
|
||||||
|
("Kobra S1", "kobra_s1"),
|
||||||
|
):
|
||||||
|
self.assertEqual(family_from_name(name), family)
|
||||||
|
drv = for_printer({"name": name, "family": family, "host": "10.0.0.1"})
|
||||||
|
self.assertEqual(drv.transport, "moonraker")
|
||||||
|
self.assertTrue(drv.rinkhals)
|
||||||
|
self.assertFalse(drv.firmware_restart)
|
||||||
|
|
||||||
|
def test_kobra_4_is_lan(self):
|
||||||
|
self.assertEqual(family_from_name("Kobra 4"), "kobra_4")
|
||||||
|
drv = for_printer({"family": "kobra_4", "host": "10.0.0.4"})
|
||||||
|
self.assertEqual(drv.transport, "anycubic_lan")
|
||||||
|
self.assertFalse(drv.rinkhals)
|
||||||
|
|
||||||
|
def test_i3_is_marlin(self):
|
||||||
|
self.assertEqual(family_from_name("Anycubic i3 Mega"), "anycubic_i3")
|
||||||
|
drv = for_printer({"family": "anycubic_i3", "host": "usb"})
|
||||||
|
self.assertEqual(drv.transport, "marlin")
|
||||||
|
|
||||||
|
def test_tenlog_is_marlin_with_two_heads(self):
|
||||||
|
self.assertEqual(family_from_name("Tenlog"), "tenlog")
|
||||||
|
drv = for_printer({"family": "tenlog", "host": "COM5"})
|
||||||
|
self.assertEqual(drv.transport, "marlin")
|
||||||
|
self.assertFalse(drv.rinkhals)
|
||||||
|
|
||||||
|
|
||||||
|
class LanCardTests(unittest.TestCase):
|
||||||
|
def test_busy_with_file_is_printing(self):
|
||||||
|
card = card_from_info({
|
||||||
|
"state": "busy",
|
||||||
|
"temp": {"curr_nozzle_temp": 210, "curr_hotbed_temp": 60},
|
||||||
|
"project": {"filename": "teil.gcode", "progress": 40, "curr_layer": 3, "total_layers": 10},
|
||||||
|
})
|
||||||
|
self.assertEqual(card["state"], "printing")
|
||||||
|
self.assertEqual(card["filename"], "teil.gcode")
|
||||||
|
self.assertAlmostEqual(card["progress"], 0.4)
|
||||||
|
self.assertEqual(card["nozzle"], 210)
|
||||||
|
self.assertEqual(card["layer"], 3)
|
||||||
|
self.assertFalse(card["has_camera"])
|
||||||
|
self.assertIsNone(card["mesh"])
|
||||||
|
|
||||||
|
def test_free_is_standby(self):
|
||||||
|
card = card_from_info({"state": "free", "project": None})
|
||||||
|
self.assertEqual(card["state"], "standby")
|
||||||
|
self.assertEqual(card["filename"], "")
|
||||||
|
|
||||||
|
def test_slots_from_boxes(self):
|
||||||
|
slots = slots_from_report({
|
||||||
|
"multi_color_box": [{
|
||||||
|
"slots": [
|
||||||
|
{"type": "PETG"},
|
||||||
|
{"type": "PLA"},
|
||||||
|
{"type": ""},
|
||||||
|
{"type": "ABS"},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
self.assertEqual([item["type"] for item in slots], ["PETG", "PLA", "ABS"])
|
||||||
|
self.assertEqual([item["index"] for item in slots], [0, 1, 3])
|
||||||
|
|
||||||
|
|
||||||
|
class LanUploadTests(unittest.TestCase):
|
||||||
|
def test_posts_filename_and_gcode(self):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return b"{}"
|
||||||
|
|
||||||
|
def fake_urlopen(request, timeout=0):
|
||||||
|
captured["url"] = request.full_url
|
||||||
|
captured["body"] = request.data
|
||||||
|
captured["length"] = request.headers["X-file-length"]
|
||||||
|
return Response()
|
||||||
|
|
||||||
|
info = {"urls": {"fileUploadurl": "http://10.0.0.4:18910/gcode_upload?s=token"}}
|
||||||
|
drv = for_printer({"family": "kobra_4", "host": "10.0.0.4"})
|
||||||
|
with mock.patch("grotti.anycubic_lan.print_info", return_value=info), \
|
||||||
|
mock.patch("urllib.request.urlopen", fake_urlopen):
|
||||||
|
name = drv.upload("10.0.0.4", "teil.gcode", b"G1 X1")
|
||||||
|
self.assertEqual(name, "teil.gcode")
|
||||||
|
self.assertIn("/gcode_upload", captured["url"])
|
||||||
|
self.assertIn(b'name="filename"', captured["body"])
|
||||||
|
self.assertIn(b'name="gcode"', captured["body"])
|
||||||
|
self.assertIn(b"G1 X1", captured["body"])
|
||||||
|
self.assertEqual(captured["length"], "5")
|
||||||
|
|
||||||
|
def test_missing_url(self):
|
||||||
|
drv = for_printer({"family": "kobra_4"})
|
||||||
|
with mock.patch("grotti.anycubic_lan.print_info", return_value={"urls": {}}):
|
||||||
|
with self.assertRaises(PrinterError):
|
||||||
|
drv.upload("10.0.0.4", "teil.gcode", b"G1")
|
||||||
|
|
||||||
|
|
||||||
|
class MarlinStubTests(unittest.TestCase):
|
||||||
|
def test_start_says_service_missing(self):
|
||||||
|
drv = for_printer({"family": "anycubic_i3", "name": "i3", "host": "COM3"})
|
||||||
|
cards = drv.snapshot([{"name": "i3", "host": "COM3", "family": "anycubic_i3"}])
|
||||||
|
self.assertEqual(cards[0]["state"], "offline")
|
||||||
|
self.assertIn("läuft nicht", cards[0]["message"])
|
||||||
|
with self.assertRaises(PrinterError):
|
||||||
|
drv.start_print("COM3", "teil.gcode")
|
||||||
@@ -11,11 +11,43 @@ class FilamentOutHintTests(unittest.TestCase):
|
|||||||
self.assertEqual(code, "message")
|
self.assertEqual(code, "message")
|
||||||
self.assertIn("10107", text)
|
self.assertIn("10107", text)
|
||||||
|
|
||||||
def test_active_filament_empty(self):
|
def test_reason_for_pause(self):
|
||||||
code, text = filament_out_hint({"active_filament": {"empty": True}})
|
code, text = filament_out_hint({"reason_for_pause": "Filament runout"}, "")
|
||||||
|
self.assertEqual(code, "reason")
|
||||||
|
self.assertIn("runout", text.lower())
|
||||||
|
|
||||||
|
def test_active_filament_empty_needs_active_tool(self):
|
||||||
|
code, text = filament_out_hint({
|
||||||
|
"tool": 1,
|
||||||
|
"gate_status": [1, 1, 1, 1],
|
||||||
|
"active_filament": {"empty": True},
|
||||||
|
})
|
||||||
self.assertEqual(code, "active_empty")
|
self.assertEqual(code, "active_empty")
|
||||||
self.assertIn("leer", text.lower())
|
self.assertIn("leer", text.lower())
|
||||||
|
|
||||||
|
def test_anycubic_empty_without_tool_is_not_runout(self):
|
||||||
|
# Live A2/Kobra 3: empty=true, tool=-1, alle gate_status 0 — auch beim Druck
|
||||||
|
code, text = filament_out_hint({
|
||||||
|
"tool": -1,
|
||||||
|
"gate": -1,
|
||||||
|
"is_paused": False,
|
||||||
|
"gate_status": [0, 0, 0, 0],
|
||||||
|
"active_filament": {"empty": True, "material": ""},
|
||||||
|
"reason_for_pause": "",
|
||||||
|
})
|
||||||
|
self.assertIsNone(code)
|
||||||
|
self.assertIsNone(text)
|
||||||
|
|
||||||
|
def test_manual_pause_anycubic_not_material(self):
|
||||||
|
code, text = filament_out_hint({
|
||||||
|
"tool": -1,
|
||||||
|
"is_paused": True,
|
||||||
|
"gate_status": [0, 0, 0, 0],
|
||||||
|
"active_filament": {"empty": True},
|
||||||
|
}, "paused")
|
||||||
|
self.assertIsNone(code)
|
||||||
|
self.assertIsNone(text)
|
||||||
|
|
||||||
def test_gate_status_zero_for_tool(self):
|
def test_gate_status_zero_for_tool(self):
|
||||||
code, text = filament_out_hint({
|
code, text = filament_out_hint({
|
||||||
"tool": 2,
|
"tool": 2,
|
||||||
@@ -25,13 +57,15 @@ class FilamentOutHintTests(unittest.TestCase):
|
|||||||
self.assertEqual(code, "gate_empty")
|
self.assertEqual(code, "gate_empty")
|
||||||
self.assertIn("3", text)
|
self.assertIn("3", text)
|
||||||
|
|
||||||
def test_mmu_paused_without_active_type(self):
|
def test_all_gates_zero_not_empty_signal(self):
|
||||||
|
# Anycubic: gate_status oft durchgängig 0 — kein Runout-Beweis
|
||||||
code, text = filament_out_hint({
|
code, text = filament_out_hint({
|
||||||
"is_paused": True,
|
"tool": 0,
|
||||||
"active_filament": {},
|
"gate_status": [0, 0, 0, 0],
|
||||||
|
"active_filament": {"type": "PLA", "empty": False},
|
||||||
})
|
})
|
||||||
self.assertEqual(code, "mmu_paused")
|
self.assertIsNone(code)
|
||||||
self.assertTrue(text)
|
self.assertIsNone(text)
|
||||||
|
|
||||||
def test_no_hint_for_plain_pause(self):
|
def test_no_hint_for_plain_pause(self):
|
||||||
code, text = filament_out_hint({"tool": 0, "gate_status": [1, 1]}, "paused")
|
code, text = filament_out_hint({"tool": 0, "gate_status": [1, 1]}, "paused")
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Leere History-Hülle, Resume-Beweis und Chargen-Tag."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from grotti.jobs import (
|
||||||
|
history_in_order,
|
||||||
|
history_shell_is_empty,
|
||||||
|
is_filament_resume,
|
||||||
|
)
|
||||||
|
from grotti.store import chargen_day, history_job_retired
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryShellTests(unittest.TestCase):
|
||||||
|
def test_pause_shell_is_empty(self):
|
||||||
|
self.assertTrue(history_shell_is_empty({"duration_s": 0, "ended_at": None}))
|
||||||
|
|
||||||
|
def test_recorded_cancel_is_not_a_shell(self):
|
||||||
|
self.assertFalse(history_shell_is_empty({
|
||||||
|
"duration_s": 653,
|
||||||
|
"ended_at": datetime(2026, 10, 18, 13, 18, tzinfo=timezone.utc),
|
||||||
|
}))
|
||||||
|
|
||||||
|
def test_missing_row_is_not_a_shell(self):
|
||||||
|
self.assertFalse(history_shell_is_empty(None))
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentResumeProofTests(unittest.TestCase):
|
||||||
|
def test_duration_continued_with_tiny_gap(self):
|
||||||
|
# 2026-10-25 Kobra 3 A1: 000040 → 000041, 0,04 s Lücke, gleiche print_duration
|
||||||
|
end = datetime(2026, 10, 25, 5, 48, 27, 569483, tzinfo=timezone.utc)
|
||||||
|
start = end + timedelta(milliseconds=40)
|
||||||
|
self.assertTrue(is_filament_resume(
|
||||||
|
end, start, prev_duration_s=11711, new_duration_s=11711
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_fresh_restart_after_cancel_is_not_resume(self):
|
||||||
|
# Lauf 27941: 8 s abgebrochen, 45 s später Neustart mit Dauer ~0
|
||||||
|
end = datetime(2026, 10, 24, 11, 46, 8, tzinfo=timezone.utc)
|
||||||
|
start = end + timedelta(seconds=45)
|
||||||
|
self.assertFalse(is_filament_resume(
|
||||||
|
end, start, prev_duration_s=8, new_duration_s=5
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_two_arg_call_without_proof_is_false(self):
|
||||||
|
end = datetime(2026, 10, 21, 19, 32, 20, tzinfo=timezone.utc)
|
||||||
|
start = end + timedelta(milliseconds=40)
|
||||||
|
self.assertFalse(is_filament_resume(end, start))
|
||||||
|
|
||||||
|
def test_watcher_signal_allows_longer_gap(self):
|
||||||
|
end = datetime(2026, 10, 25, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
|
start = end + timedelta(hours=4)
|
||||||
|
self.assertTrue(is_filament_resume(
|
||||||
|
end, start, prev_duration_s=1000, new_duration_s=1000, watcher_resume=True
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_duration_reset_is_not_resume(self):
|
||||||
|
end = datetime(2026, 10, 25, 5, 48, 27, tzinfo=timezone.utc)
|
||||||
|
start = end + timedelta(milliseconds=40)
|
||||||
|
self.assertFalse(is_filament_resume(
|
||||||
|
end, start, prev_duration_s=11711, new_duration_s=12
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryOrderTests(unittest.TestCase):
|
||||||
|
def test_cancelled_part_before_resume(self):
|
||||||
|
items = [
|
||||||
|
{"job_id": "000041", "start_time": 1792907307.607},
|
||||||
|
{"job_id": "000040", "start_time": 1792879062.392},
|
||||||
|
]
|
||||||
|
self.assertEqual([j["job_id"] for j in history_in_order(items)], ["000040", "000041"])
|
||||||
|
|
||||||
|
def test_missing_start_first(self):
|
||||||
|
items = [{"job_id": "b", "start_time": 5}, {"job_id": "a"}]
|
||||||
|
self.assertEqual([j["job_id"] for j in history_in_order(items)], ["a", "b"])
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryJobRetiredTests(unittest.TestCase):
|
||||||
|
def _conn(self, row):
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.execute.return_value.fetchone.return_value = row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def test_folded_predecessor_is_retired(self):
|
||||||
|
conn = self._conn({"moonraker_job_id": "000041"})
|
||||||
|
self.assertTrue(history_job_retired(conn, "h", "000040"))
|
||||||
|
|
||||||
|
def test_own_job_is_not_retired(self):
|
||||||
|
conn = self._conn({"moonraker_job_id": "000041"})
|
||||||
|
self.assertFalse(history_job_retired(conn, "h", "000041"))
|
||||||
|
|
||||||
|
def test_unknown_job_is_not_retired(self):
|
||||||
|
self.assertFalse(history_job_retired(self._conn(None), "h", "000042"))
|
||||||
|
|
||||||
|
|
||||||
|
class ChargenDayTests(unittest.TestCase):
|
||||||
|
def test_old_print_keeps_its_day(self):
|
||||||
|
started = datetime(2026, 10, 18, 13, 4, 16, tzinfo=timezone.utc)
|
||||||
|
self.assertEqual(chargen_day(started, today=date(2026, 10, 24)), date(2026, 10, 18))
|
||||||
|
|
||||||
|
def test_without_start_uses_today(self):
|
||||||
|
self.assertEqual(chargen_day(None, today=date(2026, 10, 24)), date(2026, 10, 24))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -28,13 +28,77 @@ class ChooseSpoolTests(unittest.TestCase):
|
|||||||
with patch("grotti.jobs.store.spools_for_printer", return_value=[old]):
|
with patch("grotti.jobs.store.spools_for_printer", return_value=[old]):
|
||||||
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", None, ended_at=end)["id"], 1)
|
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", None, ended_at=end)["id"], 1)
|
||||||
|
|
||||||
|
def test_prefers_host_spool_over_stale_hint(self):
|
||||||
|
"""Rollenwechsel: Hint im Regal, neue Rolle am Drucker → Abzug auf die neue."""
|
||||||
|
end = datetime(2026, 10, 18, 14, 58, tzinfo=timezone.utc)
|
||||||
|
hinted = {
|
||||||
|
"id": 4,
|
||||||
|
"material_type": "PLA",
|
||||||
|
"printer_host": "",
|
||||||
|
"created_at": end - timedelta(days=5),
|
||||||
|
}
|
||||||
|
current = {
|
||||||
|
"id": 14,
|
||||||
|
"material_type": "PLA",
|
||||||
|
"printer_host": "h",
|
||||||
|
"created_at": end - timedelta(minutes=10),
|
||||||
|
}
|
||||||
|
conn = MagicMock()
|
||||||
|
with patch("grotti.jobs.store.spools_for_printer", return_value=[current]):
|
||||||
|
with patch("grotti.jobs.store.get_spool_row", return_value=hinted):
|
||||||
|
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", 4, ended_at=end)["id"], 14)
|
||||||
|
|
||||||
|
def test_keeps_hint_when_no_host_spool(self):
|
||||||
|
end = datetime(2026, 10, 18, 14, 58, tzinfo=timezone.utc)
|
||||||
|
hinted = {
|
||||||
|
"id": 4,
|
||||||
|
"material_type": "PLA",
|
||||||
|
"printer_host": "",
|
||||||
|
"created_at": end - timedelta(days=5),
|
||||||
|
}
|
||||||
|
conn = MagicMock()
|
||||||
|
with patch("grotti.jobs.store.spools_for_printer", return_value=[]):
|
||||||
|
with patch("grotti.jobs.store.get_spool_row", return_value=hinted):
|
||||||
|
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", 4, ended_at=end)["id"], 4)
|
||||||
|
|
||||||
|
def test_resolve_tracking_spool_prefers_host(self):
|
||||||
|
conn = MagicMock()
|
||||||
|
with patch(
|
||||||
|
"grotti.jobs.resolve_spools_at_host",
|
||||||
|
return_value=[{"id": 14, "printer_host": "h"}],
|
||||||
|
):
|
||||||
|
self.assertEqual(jobs.resolve_tracking_spool(conn, "h", 4), 14)
|
||||||
|
|
||||||
|
def test_ace_target_skips_spool_created_after_end(self):
|
||||||
|
end = datetime(2026, 10, 15, 22, 0, tzinfo=timezone.utc)
|
||||||
|
future = {"id": 18, "created_at": end + timedelta(days=6)}
|
||||||
|
conn = MagicMock()
|
||||||
|
with patch("grotti.jobs.store.spool_at_slot", return_value=future):
|
||||||
|
self.assertEqual(
|
||||||
|
jobs._targets_from_ace(conn, "h", [{"ams_index": 3}], ended_at=end),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
def test_spool_valid_for_end_naive(self):
|
def test_spool_valid_for_end_naive(self):
|
||||||
end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
|
end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
|
||||||
spool = {"created_at": datetime(2026, 9, 1, 12, 0)}
|
spool = {"created_at": datetime(2026, 9, 1, 12, 0)}
|
||||||
self.assertTrue(jobs._spool_valid_for_end(spool, end))
|
self.assertTrue(jobs._spool_valid_for_end(spool, end))
|
||||||
|
|
||||||
|
|
||||||
|
class UndoDeductionTests(unittest.TestCase):
|
||||||
|
def test_reopens_last_segment(self):
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.execute.return_value.fetchall.return_value = [
|
||||||
|
{"spool_id": 7, "weight_g": 10},
|
||||||
|
]
|
||||||
|
run = {"id": 42, "deducted": True}
|
||||||
|
self.assertTrue(jobs.undo_deduction(conn, run))
|
||||||
|
sqls = [str(call.args[0]) for call in conn.execute.call_args_list]
|
||||||
|
self.assertTrue(any("UPDATE run_segments" in sql and "mm_end = NULL" in sql for sql in sqls))
|
||||||
|
|
||||||
|
|
||||||
class SteuernAliasTests(unittest.TestCase):
|
class SteuernAliasTests(unittest.TestCase):
|
||||||
|
|
||||||
@patch("grotti.moonraker.gcode_script", return_value={"ok": True})
|
@patch("grotti.moonraker.gcode_script", return_value={"ok": True})
|
||||||
def test_value_alias_for_temp(self, gcode):
|
def test_value_alias_for_temp(self, gcode):
|
||||||
moonraker.steuern("10.0.0.1", "nozzle", {"value": 210})
|
moonraker.steuern("10.0.0.1", "nozzle", {"value": 210})
|
||||||
@@ -55,6 +119,8 @@ class HostValidationTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(validate_printer_host("10.10.1.60"), "10.10.1.60")
|
self.assertEqual(validate_printer_host("10.10.1.60"), "10.10.1.60")
|
||||||
self.assertEqual(validate_printer_host("kobra.local:80"), "kobra.local:80")
|
self.assertEqual(validate_printer_host("kobra.local:80"), "kobra.local:80")
|
||||||
|
self.assertEqual(validate_printer_host("COM5@250000"), "COM5@250000")
|
||||||
|
self.assertEqual(validate_printer_host("/dev/ttyUSB0"), "/dev/ttyUSB0")
|
||||||
with self.assertRaises(GrottiError):
|
with self.assertRaises(GrottiError):
|
||||||
validate_printer_host("http://evil")
|
validate_printer_host("http://evil")
|
||||||
with self.assertRaises(GrottiError):
|
with self.assertRaises(GrottiError):
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Logging-Hilfen."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from grotti import logutil
|
||||||
|
|
||||||
|
|
||||||
|
class LogutilTests(unittest.TestCase):
|
||||||
|
def test_parse_level(self):
|
||||||
|
self.assertEqual(logutil.parse_level("DEBUG"), logging.DEBUG)
|
||||||
|
self.assertEqual(logutil.parse_level("warn"), logging.WARNING)
|
||||||
|
self.assertEqual(logutil.parse_level("nope"), logging.INFO)
|
||||||
|
|
||||||
|
def test_setup_idempotent(self):
|
||||||
|
logutil.setup("INFO")
|
||||||
|
logutil.setup("DEBUG")
|
||||||
|
self.assertEqual(logging.getLogger().level, logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Marlin-Dienst mit einer Leitung aus dem Test. Kein USB-Gerät."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from grotti.drivers.marlin import MarlinDriver
|
||||||
|
from grotti.marlin_daemon import serve
|
||||||
|
from grotti.marlin_link import MarlinService, gcode_lines, sd_filename, split_port
|
||||||
|
from grotti.moonraker import PrinterError
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptedPort:
|
||||||
|
def __init__(self):
|
||||||
|
self.written = []
|
||||||
|
self._queue = []
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
text = data.decode().strip()
|
||||||
|
self.written.append(text)
|
||||||
|
if text.startswith("M27"):
|
||||||
|
self._queue.append(b"Done printing file\n")
|
||||||
|
self._queue.append(b"ok\n")
|
||||||
|
elif text.startswith("M105"):
|
||||||
|
self._queue.append(
|
||||||
|
b"ok T:200.0 /210.0 T0:200.0 /210.0 T1:190.0 /180.0 B:60.0 /60.0\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._queue.append(b"ok\n")
|
||||||
|
|
||||||
|
def readline(self):
|
||||||
|
if self._queue:
|
||||||
|
return self._queue.pop(0)
|
||||||
|
return b""
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
GCODE = b"; Kommentar\nT0\nM109 S210\nT1\nM109 S180\nG1 X1\n"
|
||||||
|
|
||||||
|
|
||||||
|
class MarlinTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.port = ScriptedPort()
|
||||||
|
self.service = MarlinService(open_port=lambda device, baud: self.port)
|
||||||
|
self.httpd = serve(0, self.service)
|
||||||
|
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
base = "http://127.0.0.1:%s" % self.httpd.server_address[1]
|
||||||
|
self.drv = MarlinDriver(base=base)
|
||||||
|
self.printer = {
|
||||||
|
"name": "Tenlog",
|
||||||
|
"host": "COM5@250000",
|
||||||
|
"family": "tenlog",
|
||||||
|
"family_label": "Tenlog",
|
||||||
|
}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.httpd.shutdown()
|
||||||
|
self.httpd.server_close()
|
||||||
|
|
||||||
|
def test_split_and_short_name(self):
|
||||||
|
self.assertEqual(split_port("COM5@250000"), ("COM5", 250000))
|
||||||
|
self.assertEqual(sd_filename("teil drucken.gcode"), "TEILDRUC.GCO")
|
||||||
|
self.assertEqual(gcode_lines(GCODE), ["T0", "M109 S210", "T1", "M109 S180", "G1 X1"])
|
||||||
|
|
||||||
|
def test_two_heads_and_stream(self):
|
||||||
|
card = self.drv.snapshot([self.printer])[0]
|
||||||
|
self.assertTrue(card["ok"])
|
||||||
|
self.assertEqual(card["state"], "standby")
|
||||||
|
self.assertEqual(card["slots"], [])
|
||||||
|
self.assertEqual(len(card["tools"]), 2)
|
||||||
|
self.assertEqual(card["tools"][1]["temp"], 190.0)
|
||||||
|
self.assertEqual(card["host"], "COM5@250000")
|
||||||
|
stored = self.drv.upload("COM5@250000", "teil.gcode", GCODE)
|
||||||
|
self.drv.start_print("COM5@250000", stored, mode="stream")
|
||||||
|
self.service.session("COM5")._thread.join(3)
|
||||||
|
sent = self.port.written
|
||||||
|
self.assertIn("T0", sent)
|
||||||
|
self.assertIn("T1", sent)
|
||||||
|
self.assertNotIn("M28 teil.gcode", sent)
|
||||||
|
status = self.service.session("COM5").status()
|
||||||
|
self.assertEqual(status["state"], "complete")
|
||||||
|
self.assertEqual(status["progress"], 1)
|
||||||
|
|
||||||
|
def test_sd_writes_then_starts(self):
|
||||||
|
self.drv.attach(self.printer)
|
||||||
|
stored = self.drv.upload("COM5", "teil.gcode", GCODE)
|
||||||
|
shown = self.drv.start_print("COM5@250000", stored, mode="sd")
|
||||||
|
self.assertEqual(shown, "TEIL.GCO")
|
||||||
|
self.service.session("COM5")._thread.join(5)
|
||||||
|
sent = self.port.written
|
||||||
|
self.assertIn("M28 TEIL.GCO", sent)
|
||||||
|
self.assertIn("M29", sent)
|
||||||
|
self.assertIn("M23 TEIL.GCO", sent)
|
||||||
|
self.assertIn("M24", sent)
|
||||||
|
self.assertLess(sent.index("M28 TEIL.GCO"), sent.index("T0"))
|
||||||
|
self.assertLess(sent.index("M29"), sent.index("M24"))
|
||||||
|
|
||||||
|
def test_pause_and_cancel_two_heaters(self):
|
||||||
|
self.drv.attach(self.printer)
|
||||||
|
session = self.service.session("COM5")
|
||||||
|
session.state = "printing"
|
||||||
|
session.mode = "sd"
|
||||||
|
self.drv.command("COM5", "pause")
|
||||||
|
self.assertIn("M25", self.port.written)
|
||||||
|
self.assertEqual(session.state, "paused")
|
||||||
|
session.cancel()
|
||||||
|
self.assertIn("M104 T0 S0", self.port.written)
|
||||||
|
self.assertIn("M104 T1 S0", self.port.written)
|
||||||
|
self.assertIn("M140 S0", self.port.written)
|
||||||
|
self.assertEqual(session.state, "standby")
|
||||||
|
|
||||||
|
def test_without_service_stays_offline(self):
|
||||||
|
drv = MarlinDriver(base="http://127.0.0.1:9")
|
||||||
|
card = drv.snapshot([{"name": "i3", "host": "COM9", "family": "anycubic_i3"}])[0]
|
||||||
|
self.assertFalse(card["ok"])
|
||||||
|
self.assertEqual(card["state"], "offline")
|
||||||
|
with self.assertRaises(PrinterError):
|
||||||
|
drv.start_print("COM9", "a.gcode")
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Marlin-Dienst starten, ohne ihn zu beenden. Kein echtes USB-Gerät."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from grotti import marlin_supervisor as sup
|
||||||
|
|
||||||
|
|
||||||
|
class SupervisorTests(unittest.TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
sup.disable()
|
||||||
|
|
||||||
|
def test_running_service_is_not_started_again(self):
|
||||||
|
starts = []
|
||||||
|
service = sup.MarlinSupervisor(
|
||||||
|
Path("."),
|
||||||
|
healthy=lambda: True,
|
||||||
|
start=lambda folder: starts.append(folder),
|
||||||
|
wait_s=0.1,
|
||||||
|
)
|
||||||
|
result = service.ensure()
|
||||||
|
self.assertTrue(result["running"])
|
||||||
|
self.assertFalse(result["started"])
|
||||||
|
self.assertEqual(starts, [])
|
||||||
|
|
||||||
|
def test_starts_once_when_down(self):
|
||||||
|
state = {"up": False}
|
||||||
|
starts = []
|
||||||
|
|
||||||
|
def healthy():
|
||||||
|
return state["up"]
|
||||||
|
|
||||||
|
def start(folder):
|
||||||
|
starts.append(folder)
|
||||||
|
state["up"] = True
|
||||||
|
|
||||||
|
service = sup.MarlinSupervisor(Path("."), healthy=healthy, start=start, wait_s=0.2)
|
||||||
|
first = service.ensure()
|
||||||
|
second = service.ensure()
|
||||||
|
self.assertTrue(first["started"])
|
||||||
|
self.assertEqual(len(starts), 1)
|
||||||
|
self.assertFalse(second["started"])
|
||||||
|
self.assertTrue(second["running"])
|
||||||
|
|
||||||
|
def test_failed_start_does_not_repeat_immediately(self):
|
||||||
|
starts = []
|
||||||
|
service = sup.MarlinSupervisor(
|
||||||
|
Path("."),
|
||||||
|
healthy=lambda: False,
|
||||||
|
start=lambda folder: starts.append(1),
|
||||||
|
wait_s=0.05,
|
||||||
|
)
|
||||||
|
service.ensure()
|
||||||
|
service.ensure()
|
||||||
|
self.assertEqual(len(starts), 1)
|
||||||
|
|
||||||
|
def test_parallel_ensure_starts_once(self):
|
||||||
|
gate = threading.Event()
|
||||||
|
starts = []
|
||||||
|
|
||||||
|
def start(folder):
|
||||||
|
starts.append(1)
|
||||||
|
gate.wait(1)
|
||||||
|
|
||||||
|
service = sup.MarlinSupervisor(
|
||||||
|
Path("."),
|
||||||
|
healthy=lambda: False,
|
||||||
|
start=start,
|
||||||
|
wait_s=0.05,
|
||||||
|
)
|
||||||
|
threads = [threading.Thread(target=service.ensure) for _ in range(4)]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
gate.set()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join(2)
|
||||||
|
self.assertEqual(len(starts), 1)
|
||||||
|
|
||||||
|
def test_spawn_is_detached_and_runs_the_module(self):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
class Proc:
|
||||||
|
pid = 42
|
||||||
|
|
||||||
|
def popen(**kwargs):
|
||||||
|
seen.update(kwargs)
|
||||||
|
if kwargs.get("creationflags", 0) & sup._CREATE_BREAKAWAY:
|
||||||
|
raise OSError("breakaway")
|
||||||
|
return Proc()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
pid = sup.spawn_detached(folder, popen=popen)
|
||||||
|
self.assertEqual(pid, 42)
|
||||||
|
self.assertEqual(seen["args"][1:], ["-m", "grotti.marlin_daemon"])
|
||||||
|
self.assertIs(seen["stdin"], sup.subprocess.DEVNULL)
|
||||||
|
if sup.os.name == "nt":
|
||||||
|
flags = seen["creationflags"]
|
||||||
|
self.assertTrue(flags & sup._DETACHED)
|
||||||
|
self.assertFalse(flags & sup._CREATE_BREAKAWAY)
|
||||||
|
else:
|
||||||
|
self.assertTrue(seen["start_new_session"])
|
||||||
|
|
||||||
|
def test_enable_reuses_a_live_daemon(self):
|
||||||
|
sup.enable(Path("."))
|
||||||
|
sup._supervisor.healthy = lambda: True
|
||||||
|
sup._supervisor.start = lambda folder: (_ for _ in ()).throw(AssertionError("gestartet"))
|
||||||
|
result = sup.ensure_if_enabled()
|
||||||
|
self.assertTrue(result["running"])
|
||||||
|
self.assertFalse(result["started"])
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Sorten-Routing: Profil (Meta/Matte) und Auto-ACE-Map."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from grotti import store
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileMatchTests(unittest.TestCase):
|
||||||
|
def test_meta_not_matte(self):
|
||||||
|
self.assertTrue(store._profile_matches("Sunlu PLA Meta", "Sunlu PLA Meta"))
|
||||||
|
self.assertTrue(store._profile_matches("Sunlu PLA Meta", "Sunlu PLA Meta schwarz"))
|
||||||
|
self.assertFalse(store._profile_matches("Sunlu PLA Meta", "Sunlu PLA Matte"))
|
||||||
|
self.assertFalse(store._profile_matches("Sunlu PLA Matte", "Sunlu PLA"))
|
||||||
|
self.assertFalse(store._profile_matches("Sunlu PLA", "Sunlu PLA Matte"))
|
||||||
|
self.assertFalse(store._profile_matches("Sunlu PLA", "Sunlu PLA Meta"))
|
||||||
|
|
||||||
|
def test_empty_profile_always_ok(self):
|
||||||
|
self.assertTrue(store._profile_matches("", "Sunlu PLA Meta"))
|
||||||
|
|
||||||
|
def test_base_profile_helpers(self):
|
||||||
|
self.assertFalse(store._profile_has_variant("Sunlu PLA"))
|
||||||
|
self.assertTrue(store._profile_has_variant("Sunlu PLA Meta"))
|
||||||
|
self.assertEqual(store._profile_base_type("Sunlu PLA"), "pla")
|
||||||
|
self.assertEqual(store._profile_base_type("Generic PETG"), "petg")
|
||||||
|
|
||||||
|
|
||||||
|
class MatchSpoolsTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.have = [
|
||||||
|
{
|
||||||
|
"spool_id": 1,
|
||||||
|
"material_type": "pla",
|
||||||
|
"color": "#ff0000",
|
||||||
|
"remaining_g": 800,
|
||||||
|
"material_name": "sunlu pla meta",
|
||||||
|
"ace_slot": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"spool_id": 2,
|
||||||
|
"material_type": "pla",
|
||||||
|
"color": "#000000",
|
||||||
|
"remaining_g": 500,
|
||||||
|
"material_name": "sunlu pla matte",
|
||||||
|
"ace_slot": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"spool_id": 3,
|
||||||
|
"material_type": "pla",
|
||||||
|
"color": "",
|
||||||
|
"remaining_g": 900,
|
||||||
|
"material_name": "eryone pla",
|
||||||
|
"ace_slot": None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_base_sunlu_pla_matches_any_pla(self):
|
||||||
|
need = {"profile": "Sunlu PLA", "material_type": "pla", "color": "", "paint_index": 0}
|
||||||
|
matched = store._match_spools_for_need(need, self.have)
|
||||||
|
ids = {m["spool_id"] for m in matched}
|
||||||
|
self.assertEqual(ids, {1, 2, 3})
|
||||||
|
|
||||||
|
def test_base_pla_matches_matte_alone(self):
|
||||||
|
need = {"profile": "Sunlu PLA", "material_type": "pla", "color": "#f2a900", "paint_index": 0}
|
||||||
|
have = [self.have[1]]
|
||||||
|
matched = store._match_spools_for_need(need, have)
|
||||||
|
self.assertEqual([m["spool_id"] for m in matched], [2])
|
||||||
|
|
||||||
|
def test_meta_falls_back_to_any_pla(self):
|
||||||
|
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
|
||||||
|
have = [self.have[1]]
|
||||||
|
matched = store._match_spools_for_need(need, have)
|
||||||
|
self.assertEqual([m["spool_id"] for m in matched], [2])
|
||||||
|
|
||||||
|
def test_petg_does_not_match_pla(self):
|
||||||
|
need = {"profile": "Sunlu PETG", "material_type": "petg", "color": "", "paint_index": 0}
|
||||||
|
self.assertEqual(store._match_spools_for_need(need, self.have), [])
|
||||||
|
|
||||||
|
def test_pla_lite_hex_matches_any_pla(self):
|
||||||
|
# Orca: Profil „Eryone PLA Lite“, Farbe #26A69A — Lager: Eryone/Sunlu PLA „Grau“
|
||||||
|
need = {
|
||||||
|
"profile": "Eryone PLA Lite",
|
||||||
|
"material_type": "pla",
|
||||||
|
"color": "#26A69A",
|
||||||
|
"paint_index": 0,
|
||||||
|
}
|
||||||
|
have = [
|
||||||
|
{
|
||||||
|
"spool_id": 15,
|
||||||
|
"material_type": "pla",
|
||||||
|
"color": "grau",
|
||||||
|
"remaining_g": 873,
|
||||||
|
"material_name": "sunlu pla",
|
||||||
|
"ace_slot": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"spool_id": 1,
|
||||||
|
"material_type": "pla",
|
||||||
|
"color": "grau",
|
||||||
|
"remaining_g": 800,
|
||||||
|
"material_name": "sunlu pla meta",
|
||||||
|
"ace_slot": 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
matched = store._match_spools_for_need(need, have)
|
||||||
|
self.assertEqual({m["spool_id"] for m in matched}, {15, 1})
|
||||||
|
|
||||||
|
def test_picks_meta_not_matte(self):
|
||||||
|
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
|
||||||
|
matched = store._match_spools_for_need(need, self.have)
|
||||||
|
self.assertEqual(len(matched), 1)
|
||||||
|
self.assertEqual(matched[0]["spool_id"], 1)
|
||||||
|
|
||||||
|
def test_color_soft_among_profile(self):
|
||||||
|
need = {
|
||||||
|
"profile": "Sunlu PLA Meta",
|
||||||
|
"material_type": "pla",
|
||||||
|
"color": "#00ff00",
|
||||||
|
"paint_index": 0,
|
||||||
|
}
|
||||||
|
# Nur Meta vorhanden, Farbe anders → trotzdem Meta (Farbe soft)
|
||||||
|
matched = store._match_spools_for_need(need, self.have)
|
||||||
|
self.assertEqual(matched[0]["spool_id"], 1)
|
||||||
|
|
||||||
|
def test_auto_ace_map_assigns_slots(self):
|
||||||
|
# Ohne DB: Logik über _match + manuell wie auto_ace_map
|
||||||
|
need = [
|
||||||
|
{"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "#ff0000", "paint_index": 1},
|
||||||
|
{"profile": "Sunlu PLA Matte", "material_type": "pla", "color": "#000000", "paint_index": 10},
|
||||||
|
]
|
||||||
|
used = set()
|
||||||
|
mapping = []
|
||||||
|
for n in need:
|
||||||
|
matched = store._match_spools_for_need(n, self.have, used)
|
||||||
|
self.assertTrue(matched)
|
||||||
|
chosen = matched[0]
|
||||||
|
used.add(chosen["spool_id"])
|
||||||
|
mapping.append({"paint_index": n["paint_index"], "ams_index": int(chosen["ace_slot"]) - 1})
|
||||||
|
self.assertEqual(mapping, [
|
||||||
|
{"paint_index": 1, "ams_index": 0},
|
||||||
|
{"paint_index": 10, "ams_index": 1},
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Problem-Meldungen: Relay und Antwort, ohne echten Forgejo."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from grotti import meldung
|
||||||
|
|
||||||
|
|
||||||
|
class RelayTests(unittest.TestCase):
|
||||||
|
def test_relay_ok(self):
|
||||||
|
cfg = {"_raw": {"meldung_relay": "http://127.0.0.1/hooks/grotti-meldung"}}
|
||||||
|
|
||||||
|
class Resp:
|
||||||
|
def read(self):
|
||||||
|
return b'{"url":"https://git.example/issues/1"}'
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
with mock.patch("urllib.request.urlopen", return_value=Resp()) as opener:
|
||||||
|
ok, message = meldung.send_relay(cfg, {"text": "kaputt", "version": "1"})
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertIn("issues/1", message)
|
||||||
|
request = opener.call_args[0][0]
|
||||||
|
body = json.loads(request.data.decode("utf-8"))
|
||||||
|
self.assertEqual(body["text"], "kaputt")
|
||||||
|
self.assertNotIn("username", body)
|
||||||
|
|
||||||
|
def test_relay_down(self):
|
||||||
|
cfg = {"_raw": {"meldung_relay": "http://127.0.0.1:9/hooks"}}
|
||||||
|
with mock.patch("urllib.request.urlopen", side_effect=URLError("down")):
|
||||||
|
ok, message = meldung.send_relay(cfg, {"text": "x"})
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertIn("nicht erreichbar", message)
|
||||||
|
|
||||||
|
def test_relay_http_error(self):
|
||||||
|
cfg = {"_raw": {"meldung_relay": "http://example/hooks"}}
|
||||||
|
|
||||||
|
class Fake(HTTPError):
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return b"nope"
|
||||||
|
|
||||||
|
code = 502
|
||||||
|
|
||||||
|
with mock.patch("urllib.request.urlopen", side_effect=Fake()):
|
||||||
|
ok, message = meldung.send_relay(cfg, {"text": "x"})
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertIn("502", message)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+104
-1
@@ -23,6 +23,90 @@ class OrcaSendenHelpersTest(unittest.TestCase):
|
|||||||
"http://127.0.0.1:8770/dateien?file=42",
|
"http://127.0.0.1:8770/dateien?file=42",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_app_page_url(self):
|
||||||
|
self.assertEqual(
|
||||||
|
orca.app_page_url("http://127.0.0.1:8770/"),
|
||||||
|
"http://127.0.0.1:8770/dateien?orca=1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_browser_key_collapses_duplicates(self):
|
||||||
|
self.assertEqual(
|
||||||
|
orca.browser_key("1018-1648-Round Clip PLA + PETG(01)_PLA_0.2_30m34s.gcode"),
|
||||||
|
orca.browser_key("1018-1648-Round_Clip_PLA_+_PETG(01)_PLA_0.2_30m34s-2.gcode"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_browser_key_strips_time_prefix(self):
|
||||||
|
self.assertEqual(
|
||||||
|
orca.browser_key("1018-1706-Round Clip PLA + PETG(01)_PLA_0.2_30m34s.gcode"),
|
||||||
|
orca.browser_key("1018-1709-Round_Clip_PLA_+_PETG(01)_PLA_0.2_30m34s.gcode"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
orca.browser_key("1018-1709-Round Clip PLA + PETG(01)_PLA_0.2_30m34s.gcode"),
|
||||||
|
"roundclipplapetg01pla0230m34s",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_open_browser_debounces(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
lock = Path(tmp) / "browser.lock"
|
||||||
|
focus = Path(tmp) / "focus.json"
|
||||||
|
with patch.object(orca, "BROWSER_LOCK", lock):
|
||||||
|
with patch.object(orca, "FOCUS_PATH", focus):
|
||||||
|
with patch.object(orca, "open_as_app", return_value=False):
|
||||||
|
with patch.object(orca, "webbrowser") as browser:
|
||||||
|
orca.open_browser(
|
||||||
|
"http://127.0.0.1:8770/dateien?file=1",
|
||||||
|
key="roundclip",
|
||||||
|
file_id=1,
|
||||||
|
mode="tab",
|
||||||
|
)
|
||||||
|
orca.open_browser(
|
||||||
|
"http://127.0.0.1:8770/dateien?file=2",
|
||||||
|
key="roundclip",
|
||||||
|
file_id=2,
|
||||||
|
mode="tab",
|
||||||
|
)
|
||||||
|
self.assertEqual(browser.open.call_count, 1)
|
||||||
|
data = json.loads(focus.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(data["file_id"], 2)
|
||||||
|
|
||||||
|
def test_open_browser_app_mode(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
lock = Path(tmp) / "browser.lock"
|
||||||
|
focus = Path(tmp) / "focus.json"
|
||||||
|
with patch.object(orca, "BROWSER_LOCK", lock):
|
||||||
|
with patch.object(orca, "FOCUS_PATH", focus):
|
||||||
|
with patch.object(orca, "app_already_running", return_value=False):
|
||||||
|
with patch.object(orca, "open_as_app", return_value=True) as app:
|
||||||
|
with patch.object(orca, "webbrowser") as browser:
|
||||||
|
orca.open_browser(
|
||||||
|
"http://127.0.0.1:8770/dateien?orca=1",
|
||||||
|
key="roundclip",
|
||||||
|
file_id=9,
|
||||||
|
mode="app",
|
||||||
|
)
|
||||||
|
app.assert_called_once()
|
||||||
|
browser.open.assert_not_called()
|
||||||
|
|
||||||
|
def test_open_browser_reuses_running_app(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
lock = Path(tmp) / "browser.lock"
|
||||||
|
focus = Path(tmp) / "focus.json"
|
||||||
|
with patch.object(orca, "BROWSER_LOCK", lock):
|
||||||
|
with patch.object(orca, "FOCUS_PATH", focus):
|
||||||
|
with patch.object(orca, "app_already_running", return_value=True):
|
||||||
|
with patch.object(orca, "open_as_app", return_value=True) as app:
|
||||||
|
with patch.object(orca, "webbrowser") as browser:
|
||||||
|
orca.open_browser(
|
||||||
|
"http://127.0.0.1:8770/dateien?orca=1",
|
||||||
|
key="andere-datei",
|
||||||
|
file_id=11,
|
||||||
|
mode="app",
|
||||||
|
)
|
||||||
|
app.assert_not_called()
|
||||||
|
browser.open.assert_not_called()
|
||||||
|
data = json.loads(focus.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(data["file_id"], 11)
|
||||||
|
|
||||||
def test_find_gcode_arg_last_matching(self):
|
def test_find_gcode_arg_last_matching(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
folder = Path(tmp)
|
folder = Path(tmp)
|
||||||
@@ -71,12 +155,31 @@ class OrcaSendenHelpersTest(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = Path(tmp) / "orca-senden.json"
|
path = Path(tmp) / "orca-senden.json"
|
||||||
path.write_text(
|
path.write_text(
|
||||||
json.dumps({"url": "http://127.0.0.1:8770/", "token": "grt_abc"}),
|
json.dumps({
|
||||||
|
"url": "http://127.0.0.1:8770/",
|
||||||
|
"token": "grt_abc",
|
||||||
|
"watch_dir_local": "D:/Slice",
|
||||||
|
}),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
cfg = orca.load_config(path)
|
cfg = orca.load_config(path)
|
||||||
self.assertEqual(cfg["url"], "http://127.0.0.1:8770")
|
self.assertEqual(cfg["url"], "http://127.0.0.1:8770")
|
||||||
self.assertEqual(cfg["token"], "grt_abc")
|
self.assertEqual(cfg["token"], "grt_abc")
|
||||||
|
self.assertEqual(cfg["watch_dir_local"], "D:/Slice")
|
||||||
|
|
||||||
|
def test_resolve_watch_dir_prefers_local(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
local = Path(tmp) / "slice"
|
||||||
|
local.mkdir()
|
||||||
|
cfg = {
|
||||||
|
"url": "http://127.0.0.1:8770",
|
||||||
|
"token": "grt_x",
|
||||||
|
"watch_dir_local": str(local),
|
||||||
|
}
|
||||||
|
with patch.object(orca, "fetch_watch_dir") as fetch:
|
||||||
|
resolved = orca.resolve_watch_dir(cfg)
|
||||||
|
self.assertEqual(resolved, local)
|
||||||
|
fetch.assert_not_called()
|
||||||
|
|
||||||
def test_load_config_missing(self):
|
def test_load_config_missing(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Sparse paint_index / Bambu-T-Nummern für ACE-Map."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from grotti import acm, parse, usage
|
||||||
|
|
||||||
|
|
||||||
|
PAINT_SPARSE = json.dumps(
|
||||||
|
[
|
||||||
|
{"material_type": "PLA", "paint_color": [255, 128, 0], "paint_index": 1},
|
||||||
|
{"material_type": "PLA", "paint_color": [0, 0, 0], "paint_index": 10},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PaintIndexTests(unittest.TestCase):
|
||||||
|
def test_filaments_keep_slicer_paint_index(self):
|
||||||
|
rows = parse.filaments_from_paint_info(PAINT_SPARSE)
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertEqual(rows[0]["paint_index"], 1)
|
||||||
|
self.assertEqual(rows[0]["type"], "PLA")
|
||||||
|
self.assertEqual(rows[0]["color"], "#ff8000")
|
||||||
|
self.assertEqual(rows[1]["paint_index"], 10)
|
||||||
|
self.assertEqual(rows[1]["color"], "#000000")
|
||||||
|
|
||||||
|
def test_build_acm_uses_paint_index_not_array_slot(self):
|
||||||
|
filaments = parse.filaments_from_paint_info(PAINT_SPARSE)
|
||||||
|
mapping = [
|
||||||
|
{"paint_index": 1, "ams_index": 3},
|
||||||
|
{"paint_index": 10, "ams_index": 2},
|
||||||
|
]
|
||||||
|
slots = [
|
||||||
|
{"index": 2, "type": "PLA", "color": "#f4f4f4"},
|
||||||
|
{"index": 3, "type": "PLA", "color": "#c23b3b"},
|
||||||
|
]
|
||||||
|
payload = acm.build_acm(filaments, mapping, slots)
|
||||||
|
paints = [item["paint_index"] for item in payload["ams_box_mapping"]]
|
||||||
|
self.assertEqual(paints, [1, 10])
|
||||||
|
by_paint = {item["paint_index"]: item for item in payload["ams_box_mapping"]}
|
||||||
|
self.assertEqual(by_paint[1]["ams_index"], 3)
|
||||||
|
self.assertEqual(by_paint[10]["ams_index"], 2)
|
||||||
|
self.assertEqual(by_paint[1]["paint_color"], [255, 128, 0])
|
||||||
|
self.assertEqual(by_paint[10]["paint_color"], [0, 0, 0])
|
||||||
|
|
||||||
|
def test_densify_mapping_and_rewrite_avoids_invalid_filament_id(self):
|
||||||
|
mapping = [
|
||||||
|
{"paint_index": 1, "ams_index": 3},
|
||||||
|
{"paint_index": 10, "ams_index": 2},
|
||||||
|
]
|
||||||
|
remap, dense = acm.densify_mapping(mapping)
|
||||||
|
self.assertEqual(remap, {1: 0, 10: 1})
|
||||||
|
self.assertEqual(
|
||||||
|
dense,
|
||||||
|
[
|
||||||
|
{"paint_index": 0, "ams_index": 3},
|
||||||
|
{"paint_index": 1, "ams_index": 2},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
gcode = b"; hdr\nT10\nG1 X0\nT1 ; change\n; referenced_tools = 1,10\n"
|
||||||
|
out = acm.rewrite_gcode_tools(gcode, remap)
|
||||||
|
self.assertIn(b"T1\n", out)
|
||||||
|
self.assertIn(b"T0 ; change", out)
|
||||||
|
self.assertNotIn(b"T10", out)
|
||||||
|
self.assertIn(b"; referenced_tools = 0,1", out)
|
||||||
|
filaments = acm.remap_filaments(
|
||||||
|
parse.filaments_from_paint_info(PAINT_SPARSE), remap
|
||||||
|
)
|
||||||
|
slots = [
|
||||||
|
{"index": 2, "type": "PLA", "color": "#f4f4f4"},
|
||||||
|
{"index": 3, "type": "PLA", "color": "#c23b3b"},
|
||||||
|
]
|
||||||
|
payload = acm.build_acm(filaments, dense, slots)
|
||||||
|
paints = [item["paint_index"] for item in payload["ams_box_mapping"]]
|
||||||
|
self.assertEqual(paints, [0, 1])
|
||||||
|
self.assertEqual(payload["ams_box_mapping"][0]["paint_color"], [255, 128, 0])
|
||||||
|
|
||||||
|
def test_densify_identity_when_already_dense(self):
|
||||||
|
mapping = [{"paint_index": 0, "ams_index": 1}, {"paint_index": 1, "ams_index": 2}]
|
||||||
|
remap, dense = acm.densify_mapping(mapping)
|
||||||
|
self.assertEqual(remap, {})
|
||||||
|
self.assertEqual(dense, mapping)
|
||||||
|
|
||||||
|
def test_align_rewrites_when_ui_map_is_already_dense(self):
|
||||||
|
"""Regression 10011700: UI schickt 0/1, G-code hat noch T1/T10."""
|
||||||
|
gcode = b"; hdr\nT10\nG1 X0\nT1 ; change\n; referenced_tools = 1,10\n"
|
||||||
|
mapping = [
|
||||||
|
{"paint_index": 0, "ams_index": 3},
|
||||||
|
{"paint_index": 1, "ams_index": 2},
|
||||||
|
]
|
||||||
|
filaments = [
|
||||||
|
{"type": "PLA", "color": "#ff8000", "paint_index": 0},
|
||||||
|
{"type": "PLA", "color": "#000000", "paint_index": 1},
|
||||||
|
]
|
||||||
|
out, dense, fils, remap = acm.align_tools_for_ace(gcode, mapping, filaments)
|
||||||
|
self.assertEqual(remap, {1: 0, 10: 1})
|
||||||
|
self.assertEqual(
|
||||||
|
dense,
|
||||||
|
[
|
||||||
|
{"paint_index": 0, "ams_index": 3},
|
||||||
|
{"paint_index": 1, "ams_index": 2},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertIn(b"T1\n", out)
|
||||||
|
self.assertIn(b"T0 ; change", out)
|
||||||
|
self.assertNotIn(b"T10", out)
|
||||||
|
self.assertIn(b"; referenced_tools = 0,1", out)
|
||||||
|
self.assertEqual([row["paint_index"] for row in fils], [0, 1])
|
||||||
|
self.assertEqual(fils[0]["color"], "#ff8000")
|
||||||
|
self.assertEqual(fils[1]["color"], "#000000")
|
||||||
|
|
||||||
|
def test_align_with_sparse_ui_map(self):
|
||||||
|
gcode = b"T10\nT1\n"
|
||||||
|
mapping = [
|
||||||
|
{"paint_index": 1, "ams_index": 3},
|
||||||
|
{"paint_index": 10, "ams_index": 2},
|
||||||
|
]
|
||||||
|
out, dense, fils, remap = acm.align_tools_for_ace(
|
||||||
|
gcode, mapping, parse.filaments_from_paint_info(PAINT_SPARSE)
|
||||||
|
)
|
||||||
|
self.assertEqual(remap, {1: 0, 10: 1})
|
||||||
|
self.assertEqual(dense[0]["ams_index"], 3)
|
||||||
|
self.assertEqual(dense[1]["ams_index"], 2)
|
||||||
|
self.assertNotIn(b"T10", out)
|
||||||
|
self.assertEqual([row["paint_index"] for row in fils], [0, 1])
|
||||||
|
|
||||||
|
def test_tools_used_finds_t10(self):
|
||||||
|
gcode = b"; header\nT10\nG1 X0\nT1 ; change\n"
|
||||||
|
self.assertEqual(parse.tools_used_in_gcode(gcode), {1, 10})
|
||||||
|
|
||||||
|
def test_planned_shares_sparse_paint_falls_back_to_order(self):
|
||||||
|
meta = {"filament_weight_total": [12.0, 8.0]}
|
||||||
|
mapping = [
|
||||||
|
{"paint_index": 1, "ams_index": 0},
|
||||||
|
{"paint_index": 10, "ams_index": 1},
|
||||||
|
]
|
||||||
|
self.assertEqual(usage.planned_shares(meta, mapping, 2), [12.0, 8.0])
|
||||||
|
|
||||||
|
def test_profile_name_strips_machine(self):
|
||||||
|
self.assertEqual(parse.profile_name('Sunlu PLA Meta @Kobra 3'), "Sunlu PLA Meta")
|
||||||
|
self.assertEqual(parse.profile_name('"Sunlu PLA Matte @Kobra S1"'), "Sunlu PLA Matte")
|
||||||
|
|
||||||
|
def test_filaments_of_attaches_settings_id_profiles(self):
|
||||||
|
# Slot 1 = PLA, Slot 10 = Matte — wie sparse paint_info
|
||||||
|
profiles = ";".join(
|
||||||
|
['"Sunlu PLA @Kobra 3"' if i not in (1, 10) else (
|
||||||
|
'"Sunlu PLA @Kobra 3"' if i == 1 else '"Sunlu PLA Matte @Kobra 3"'
|
||||||
|
) for i in range(15)]
|
||||||
|
)
|
||||||
|
comments = {
|
||||||
|
"paint_info": PAINT_SPARSE,
|
||||||
|
"filament_settings_id": profiles,
|
||||||
|
}
|
||||||
|
rows = parse.filaments_of(comments)
|
||||||
|
self.assertEqual(rows[0]["profile"], "Sunlu PLA")
|
||||||
|
self.assertEqual(rows[1]["profile"], "Sunlu PLA Matte")
|
||||||
|
|
||||||
|
def test_filaments_of_single_without_paint(self):
|
||||||
|
comments = {
|
||||||
|
"filament_type": "PLA",
|
||||||
|
"filament_colour": "#00FF00",
|
||||||
|
"filament_settings_id": '"Sunlu PLA Meta @Kobra 3"',
|
||||||
|
}
|
||||||
|
rows = parse.filaments_of(comments)
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0]["profile"], "Sunlu PLA Meta")
|
||||||
|
self.assertEqual(rows[0]["type"], "PLA")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user