Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1f79c2ce2 | ||
|
|
4ffe1dcfc6 | ||
|
|
dd0e7ebc6b | ||
|
|
67a89d1794 | ||
|
|
4ddffbbeb1 | ||
|
|
6e59df22e5 | ||
|
|
8e88efc094 | ||
|
|
0236278929 | ||
|
|
9e22cebb81 | ||
|
|
efd0801cd1 | ||
|
|
b277ca48aa | ||
|
|
7da895bbcd | ||
|
|
5a3716b26a | ||
|
|
b20f8a7779 | ||
|
|
ead7e5718e | ||
|
|
28a1469f61 | ||
|
|
99ede12f01 |
No files matched your search
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
description: Grotti-Konventionen für Agenten (Changelog, Doku, Version, Sicherheit)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Grotti
|
||||||
|
|
||||||
|
Grotti ist ein eigenständiges Repo. Alles, was das Tool betrifft, bleibt hier.
|
||||||
|
|
||||||
|
- Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits.
|
||||||
|
- Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
|
||||||
|
- **Version/`version.py` nur erhöhen, wenn der Nutzer explizit Release sagt.** Keine Zwischen-Patches von allein. Beim Release: SemVer (Patch/Minor/Major), `[Unreleased]` → `[x.y.z] – Datum`, Git-Tag `vX.Y.Z`.
|
||||||
|
- Nutzer-Anleitung: `docs/Anleitung-*.md` unter `/doku`. Technik/Debug: `docs/internal/` — nicht in der App.
|
||||||
|
- CSS-Version in `web/templates/layout.html` (`grotti.css?v=`) erhöhen, wenn sich `grotti.css` ändert.
|
||||||
|
- Keine freie G-code-Konsole, kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Druckstarts brauchen `confirm: true`.
|
||||||
|
- Auth: Session oder API-Token (`grt_…`); Rollen admin/operator.
|
||||||
|
|
||||||
|
Druckbeobachtungen und Slicer-Profile gehören nicht hierher, sondern in den FDM-Wissen-Tresor (`D:\Projekte\3DP\FDM\Wissen\`).
|
||||||
@@ -2,7 +2,16 @@
|
|||||||
config.json
|
config.json
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Slice-/Watch-Ordner: nur der Platzhalter im Git, Inhalt lokal
|
||||||
|
slice/*
|
||||||
|
!slice/.gitkeep
|
||||||
|
|
||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
_tmp_*
|
_tmp_*
|
||||||
|
%s
|
||||||
|
|
||||||
|
# gebaute .deb (Skript legt sie hier ab)
|
||||||
|
packaging/dist/*
|
||||||
|
!packaging/dist/.gitkeep
|
||||||
@@ -4,7 +4,7 @@ Grotti ist ein eigenständiges Repo. Alles, was das Tool betrifft, bleibt hier.
|
|||||||
|
|
||||||
- Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits.
|
- Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits.
|
||||||
- Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
|
- Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
|
||||||
- Version in `grotti/version.py` nach SemVer: Patch für Fehlerbehebung, Minor für Funktion, Major für Bruch an API oder Datenbankschema. Beim Release `[Unreleased]` in `[x.y.z] – Datum` umbenennen. Git-Tag `vX.Y.Z`.
|
- **Version/`version.py` nur erhöhen, wenn der Nutzer explizit Release sagt.** Keine Zwischen-Patches von allein. Beim Release: SemVer (Patch/Minor/Major), `[Unreleased]` → `[x.y.z] – Datum`, Git-Tag `vX.Y.Z`.
|
||||||
- Technische Doku liegt in `docs/` und wird unter `/doku/<name>` gerendert. Neue Erkenntnisse zum Tool dort ergänzen.
|
- Nutzer-Anleitung: `docs/Anleitung-*.md`, gerendert unter `/doku`. Technik und Debug: `docs/internal/` — erscheint **nicht** in der App (inkl. MCP.md, Anycubic-Stack, …).
|
||||||
- CSS-Version in `web/templates/layout.html` (`grotti.css?v=`) erhöhen, wenn sich `grotti.css` ändert.
|
- CSS-Version in `web/templates/layout.html` (`grotti.css?v=`) erhöhen, wenn sich `grotti.css` ändert.
|
||||||
- Keine freie G-code-Konsole, kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Druckstarts brauchen `confirm: true`.
|
- Keine freie G-code-Konsole, kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Druckstarts brauchen `confirm: true`.
|
||||||
@@ -2,6 +2,276 @@
|
|||||||
|
|
||||||
Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionen nach [SemVer](https://semver.org/lang/de/).
|
Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionen nach [SemVer](https://semver.org/lang/de/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.5.0] – 2026-10-19
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Doku:** interne Makro-Referenz `docs/internal/Makros-Kobra.md` (Nicht-tun, Vanilla-Klipper-Alltag, ACE, LeviQ/Wipe für S1 und K3).
|
||||||
|
- **Pagination:** Dateien, Protokoll, Material, Aufträge, Warteschlange, Aktivität, Projekte — max. 40 Zeilen pro Seite (Zurück/Weiter).
|
||||||
|
- **Logging:** `log_level` in der Config; Kernpfade schreiben nach stdout (unter systemd → `journalctl -u grotti`).
|
||||||
|
- **Debian-Paket:** `packaging/build-deb.sh` baut `.deb` mit venv, systemd-Unit und PostgreSQL-`postinst` (Peer-Auth). Anleitung: `docs/Anleitung-Installation-Pi.md`.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **Config:** `GROTTI_CONFIG` bzw. `/etc/grotti/config.json` für Paket-Install; sonst Repo-`config.json`.
|
||||||
|
- **README:** Banner, Badges, Feature-Übersicht und Community-Screenshots oben; Setup/API darunter; Pi-`.deb`-Hinweis.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Queue „braucht Eryone PLA Lite“ trotz PLA-Spule:** Basis-Profil (ohne Meta/Matte/Silk) matcht nach Typ; Slice-Hex-Farbe (`#26A69A`) blockiert Lagerfarbe „Grau“ nicht mehr.
|
||||||
|
- **Dateien Info-Popup:** Details-Popup wird nicht mehr vom Bibliothek-Scroll abgeschnitten; Position fixed mit Flip nach oben, besserer Kontrast.
|
||||||
|
|
||||||
|
## [3.4.0] – 2026-10-18
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Dateien:** Umbenennen in der Bibliothek (Stift-Icon; Endung bleibt erhalten).
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Queue „druckt schon“ bei Folgejobs:** Einfach-Druck bleibt `running` bis Moonraker fertig — nicht mehr sofort `done` nach Start (sonst startete der nächste Job zu früh).
|
||||||
|
- **Bedarf „443 g fehlen“ trotz voller Spulen:** Slice-Farbe `#26A69A` matchte nicht Lagerfarbe „Grau“ → Rest 0. Bei Hex-Farbe zählt jetzt der Rest gleicher Sorte; archivierte Spulen nicht.
|
||||||
|
- **Falsches „Material nachlegen“ bei manueller Pause:** Anycubic meldet `active_filament.empty=true` auch ohne Runout (`tool`/`gate` = −1). Heuristik nur noch bei Meldung/`reason_for_pause`/10107, aktivem Tool + differenziertem `gate_status`, oder G-code-Store.
|
||||||
|
- **gklib 10111 nach Einfarbig-Upload:** Orca-Zeile `; filament_colour_type` wird beim Senden entfernt (nur Einfarbig; Multicolor unangetastet).
|
||||||
|
- **Protokoll „druckt“ nach Offline:** Live-Läufe werden bei anhaltendem Offline als `interrupted` geschlossen (z. B. gklib-Shutdown).
|
||||||
|
- **Sorte „Sunlu PLA“ vs. Eryone PLA:** Basis-Profil ohne Meta/Matte/Silk matcht nach Material-Typ (Marke egal); Varianten bleiben streng.
|
||||||
|
|
||||||
|
## [3.3.0] – 2026-10-18
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Projekte** `/projekte`: Arbeitsmappe mit Ordner unter dem Slice-Pfad (`projects.folder`); G-code-Zuordnung per Sync; Upload weiterer Dateien (STEP/STL/PDF/…); Nav unter Verwalten.
|
||||||
|
- **Dateien:** Uploads in Slice-Ordner verschieben (werden Watch); Löschen für Upload und Watch (nur unter Slice/`eingang`); kompaktere Bibliothek-Filter.
|
||||||
|
- **Material:** Filter Sorte/Farbe; leere/archivierte Spulen ausgeblendet (Filter **Archiv**); Buttons **Leer**, **Archivieren**, **Wiederherstellen** (`archived_at`).
|
||||||
|
- **Wand:** kleines Kamera-Snapshot-Thumb auf jeder Karte (nie Stream); Menüpunkt Kameras entfernt, `/kameras` → Wand.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **Technik-Cockpit:** kompakte Fact-Chips; Steuerung in Gruppen Heizung / Druck / ACE-Plätze.
|
||||||
|
- **Protokoll:** kurze Dateinamen, festere Spaltenbreiten.
|
||||||
|
- **Statistik:** kompakte Filter inkl. Farbe, Auto-Laden, KPI Primär/Sekundär.
|
||||||
|
- **Orca-Login:** Session 30 Tage (überlebt App-Fenster schließen); kein zweites Chrome mit demselben Profil (sonst erneutes Anmelden).
|
||||||
|
- **Druckbericht:** Kosten-Torte und Tabelle inkl. Filament (nicht nur Strom); Summe = Material + Energie.
|
||||||
|
- **Dateien:** Starten / Einreihen / Ablauf mit einem Klick (kein Zwei-Klick mehr). API weiter mit `confirm: true`.
|
||||||
|
- **Senden:** Zwei Start-Modi statt drei — **Dieser Drucker** (frei → sofort, sonst Warteschlange) und **Nächster passender** (Grotti wählt nach Sorte).
|
||||||
|
- **Material-Routing:** Match auf Katalog-Sorte aus Orca-`filament_settings_id` (PLA Meta ≠ Matte ≠ Silk); falsche Variante wartet mit Grund. Farbe bleibt soft. Bei Auto-Wahl baut Grotti die ACE-Map aus den Spulen am Claim-Drucker. Bestehende Dateien: **Dateien neu lesen**.
|
||||||
|
- **Wand:** Bei Filament-Pause Hinweis, die neue Rolle unter Material dem Drucker zuzuweisen — sonst zählt der Verbrauch weiter zur alten Spule.
|
||||||
|
- Anleitung Material/ACE: Rollenwechsel während Pause klargestellt.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Orca/Anycubic öffnet zwei Browserfenster:** Nachbearbeitung läuft oft 2× — Debounce (~25 s) öffnet nur noch eines. Deep-Link lädt fehlende `paint_index` nach (ACE zeigt sonst fälschlich T0/T1).
|
||||||
|
- **Dateien-Tab hängt nach Orca:** 3D-Pfad erst nach ACE/Senden (Idle); Debounce ignoriert Anycubic-Zeitstempel im Dateinamen; erneuter Export aktualisiert den offenen Tab statt ein zweites Fenster.
|
||||||
|
- **Orca/Anycubic → Desktop-Fenster:** `orca_senden` öffnet Grotti als Chrome/Edge-App (`browser: app`). Anycubic-Dialog nur vermeiden durch **G-code exportieren**, nicht Remote Print / Aufgabe starten.
|
||||||
|
- Anleitung Orca: Grotti unter **Gerät** per Device-UI-URL (`http://127.0.0.1:8770/`); Upload-zum-Host weiter meiden.
|
||||||
|
- **ACE-Map mit Lücken-T (Bambu/Anycubic):** UI behält Slicer-`Tn` (z. B. T1+T10). Beim Senden verdichtet Grotti auf T0…n und schreibt passende `.acm` — sonst gklib **10011700 invalid filament id** bzw. Hang bei Map-Mismatch. Senden prüft Map gegen T-Befehle. Bestehende Dateien: **Dateien neu lesen**.
|
||||||
|
- **10011700 trotz dichter UI-Map:** Verdichtung hängt am G-code (`T1`/`T10`), nicht daran, ob die Map schon 0/1 zeigt — sonst blieb T10 auf dem Drucker. Grotti neu starten, Clip erneut senden.
|
||||||
|
- **Abräumen ließ Lauf offen:** Urteil und Platte waren quittiert, der Live-Lauf blieb `in_progress` (Moonraker-History oft ohne Endzeit). Abräumen und Watcher (`printing`→`complete`) schließen den Lauf jetzt als `completed` inkl. Materialabzug.
|
||||||
|
- **Rollenwechsel während Pause:** Einlegen hängt Abschnitte/Ereignisse auch an offene History-Jobs (nicht nur `live:`). Send-Spule und Lauf-Spule werden mitgezogen; Abzug bevorzugt die Rolle am Drucker statt eines veralteten Send-Hints.
|
||||||
|
|
||||||
|
## [3.2.0] – 2026-10-18
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Jetzt-Liste** / Topbar-Chips / Mobile-Tab `/jetzt`: Platte räumen, Queue-Fehler, Bedarf, Wartung, Aufträge, Material-Wartegründe; Farm-ETA; Filament-Pause → „Material nachlegen“.
|
||||||
|
- **`GET /api/v1/jetzt`**, **`GET /api/v1/prognose`**, **`GET /api/v1/suche`** (Ctrl+K).
|
||||||
|
- **Filament-Pause:** Pause + ACE/`mmu`/Runout-Hinweis → Event `spule`, Jetzt-Eintrag, Alert.
|
||||||
|
- **Druckbericht:** Strom-Aufschlüsselung (Grundlast / Düse / Bett / Trockner) mit Tortendiagramm.
|
||||||
|
- **Material-Routing:** „Nächster freier“ bevorzugt passende Rolle/Farbe/Rest; sonst Wartegrund in der Queue.
|
||||||
|
- **Termine** (`due_at`) sortieren die Warteschlange vor der Priorität.
|
||||||
|
- **Auto-Retry** (einmal, Einstellung) bei Queue-/Ablauf-Fehler.
|
||||||
|
- **Gleich nochmal drucken** im Abräumen-Dialog.
|
||||||
|
- **Tagesbericht** 7 Uhr (Telegram/ntfy) und Push bei Wartung fällig, Bedarf, Auftrag überfällig.
|
||||||
|
- **Kosten pro Teil** und Stück-Yield in Bericht, Auftrag und Statistik.
|
||||||
|
- **Undo-Toast** beim Entfernen aus der Warteschlange; **Ctrl+K**-Suche.
|
||||||
|
- **Design-Polish:** Lucide-Icons (`icons.svg`), Schrift Inter, Tokens/Komponenten; Topbar und Sidebar-Gruppen.
|
||||||
|
- **Kamera-Stream:** Snapshot oder MJPEG in Einstellungen und auf der Kameras-Seite.
|
||||||
|
- **Community-Screenshots** unter `docs/community/`.
|
||||||
|
- Einstellungen: Abschnitts-Navigation, Autosave, Drucker als Karten mit Dialog.
|
||||||
|
- Cockpit: Alltag oben, **Technik**-Fold (Steuerung, Verlauf, 3D, Bettnetz) standardmäßig zu.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **Live-Status** in der Topbar (Chips + Popover); Mobile-Tab **Jetzt**.
|
||||||
|
- **PA12-CF** in Grotti als PA12-CF; am ACE als PA6-CF (ACE kennt den Typ nicht).
|
||||||
|
- Falsche Sorte warnt in Dateien, hält die Queue nicht an; zu wenig Rest blockiert weiter.
|
||||||
|
- Alert-Hygiene: `spule`/`start`/`auth` quiet; Sidebar-Label „Meldungen“.
|
||||||
|
- Status als Chip; Einstellungen-Nav mit Icons; Autosave; Light-Theme; `tabular-nums`.
|
||||||
|
- Begriffe: Queue-Eintrag = Druck; Empty-States mit Handlung; Primär-/Danger-Buttons.
|
||||||
|
- Cool-Park blockiert den Queue-Tick nicht mehr (Worker).
|
||||||
|
- Kunden und Positionen bearbeiten/löschen unter Aufträge.
|
||||||
|
- `projekte.html` / `ideen.html` entfernt (Redirect bleibt).
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Bedarf/Wartung/Auftrag-Spam:** Ops-Alerts Dedup-Art und Event-`kind` stimmten nicht überein (jede Minute derselbe Hinweis).
|
||||||
|
- **Platte belegt nach Neustart:** Watcher setzte bereits Quittiertes wieder auf `needs_clear`.
|
||||||
|
- **„Pausiert“ nach Abbruch/Offline:** Live-Lauf blieb offen bei Offline→standby.
|
||||||
|
- **Regal-Modus:** Sidebar-Menü unter Desktop-Breite ausgeblendet.
|
||||||
|
|
||||||
|
## [3.1.0] – 2026-10-18
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Bedarf in der UI:** Sidebar-Warnung, Karte unter Material, Chip in der Warteschlange.
|
||||||
|
- **Start-Zeitfenster** je Drucker in den Einstellungen (Queue nur im Fenster).
|
||||||
|
- **HT/NT-Tarif** in den Energie-Einstellungen.
|
||||||
|
- **ntfy-Test** wie bei Telegram.
|
||||||
|
- **Helles Theme** (Dunkel / Hell / System) pro Browser.
|
||||||
|
- Statistik: Balkendiagramme Druckzeit/Verbrauch, Empty-State ohne Daten.
|
||||||
|
- Wartung: KPIs, Empty-State, Reset mit Bestätigung (`armButton`).
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Cockpit und Warteschlange nutzen SSE/`createPoller` statt hartem 5‑s-Intervall.
|
||||||
|
- Shared-`config.token` wird nicht mehr als Auth akzeptiert — nur noch Session und `grt_…`-Tokens.
|
||||||
|
- Legacy-Alias `GET /api/drucker` entfernt (nur `/api/v1/drucker`).
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- SSE unter waitress: Hop-by-Hop-Header `Connection` entfernt (sonst Fehler auf `/api/v1/stream`).
|
||||||
|
- Bedarf: Material/Farbe aus `files.filaments` statt nicht existierender Spalten.
|
||||||
|
- Chrome: Service-Worker lieferte bei Fehlern die Startseite statt der angeklickten Route; „Mehr“-Dialog blockierte die Navigation; SSE und Poller liefen parallel und stauten Verbindungen.
|
||||||
|
|
||||||
|
## [3.0.0] – 2026-10-18
|
||||||
|
|
||||||
|
Major: neue Tabellen (Aufträge, Wartung), Projekte/Ideen → Aufträge, Navigation umgebaut, Dev-Server → waitress, Schema über Migrationen.
|
||||||
|
|
||||||
|
**Vor dem Update:** `pg_dump` der Datenbank. Danach Grotti starten (Migrationen 003–009 laufen automatisch).
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Aufträge:** Kunden, Positionen (Datei × Menge), Fortschritt, Einreihen, Lieferschein; Migration aus Projekten/Ideen; API `/api/v1/kunden`, `/api/v1/auftraege`; MCP-Lesetools.
|
||||||
|
- **Ausschuss-Pflicht:** Dialog beim „Platte geräumt“ (Urteil, Grund, Stückzahlen).
|
||||||
|
- **Wartung:** Laufzähler und Verschleißteile, Seite `/wartung`.
|
||||||
|
- **Bedarf:** Queue vs. Spulenrest (`GET /api/v1/bedarf`).
|
||||||
|
- **ntfy** neben Telegram; **Backup**-ZIP (pg_dump + data/); **Zeitfenster** je Drucker; optional HT/NT-Tarif.
|
||||||
|
- **SSE** Live-Stream `/api/v1/stream`, Verbindungsbanner, PWA-Manifest/Service-Worker.
|
||||||
|
- Migrationen unter `grotti/migrations/`, Connection-Pool, Indizes, Sample-Retention.
|
||||||
|
- Mobile-Nav mit „Mehr“-Sheet; Anleitungen Aufträge und Wartung.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Server: waitress; Threads starten in `server.main()`.
|
||||||
|
- Navigation: Aufträge statt Projekte/Ideen; Wartung; Dateien-/Statistik-Icons.
|
||||||
|
- Kontrast `--faint`, `prefers-reduced-motion`, Touch-Ziele, Print-CSS.
|
||||||
|
- Upload-Whitelist, Host-Validierung, ProxyFix, Settings-Lock, `?token=` nur Bilder.
|
||||||
|
- Spulenabzug History ohne blinde Rate; Chargen-Locks; MCP `temp`/`percent`.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Hängende Queue-Starts nach Neustart; Worker-Rollback; `snapshot()`-Exceptions.
|
||||||
|
- Toast-Dedupe; ACE-Map bei Send-Vorbereitung; Spulenwarnung mit ACE.
|
||||||
|
|
||||||
|
## [2.10.0] – 2026-10-18
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Einstellungen: Slice-Ordner (`watch_dir`) setzen; Repo-Ordner `slice/` als Standard (Inhalt gitignored).
|
||||||
|
- Orca-Nachbearbeitung: `scripts/orca_senden.py` lädt den Export nach Grotti und öffnet `/dateien?file=…` (Drucker, ACE, Einreihen in der UI); Anleitung unter Doku.
|
||||||
|
- Bibliothek: Ordner unter dem Slice-Pfad anlegen und Watch-Dateien verschieben (ID/Projekt bleiben); API `…/dateien/ordner`, `…/verschieben`.
|
||||||
|
- Bibliothek: Filter Material und Ordner, Sortierung (Neueste / Name / Material / Ordner / Druckzeit / Gewicht), Suche auch in Material und Projekt; Filter bleiben im Browser; Gruppenköpfe bei Sort Material/Ordner.
|
||||||
|
- Cockpit: Verlauf (Temp/ED) des aktuellen oder letzten Drucks, Link zum Bericht; gemeinsame Chart-Hilfe in `api.js`.
|
||||||
|
- Druckbericht-Verlauf: Zeitachse (links °C, rechts ED %), gestrichelte Soll-Linien, Einschaltdauer Düse/Bett; Aufheizen wird dichter erfasst.
|
||||||
|
- Stromschätzung aus ED: Einstellungen → Düse-/Bett-Watt; Bericht zeigt kWh (ED oder pauschal).
|
||||||
|
- Einstellungen → Dieser Browser: Zeitzone für Anzeigezeiten (Browser automatisch oder z. B. Europe/Berlin).
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Energie-Defaults: Grundlast 40 W (Elektronik), Düse 60 W, Bett 220 W; Hinweis in den Einstellungen, wenn Düse/Bett 0 (nur pauschal).
|
||||||
|
- Druckbericht: Start/Ende als lesbare Ortszeit (`17.10.2026, 15:09 – 15:46`) statt ISO.
|
||||||
|
- Sidebar **Läuft jetzt**: eine Zeile pro Drucker (Name · % · Fertig-Uhr), Fortschritt in der Zeile; Klick öffnet die Warteschlange, × bricht ab.
|
||||||
|
- Spulenakte: KPI-Kacheln statt Fließtext; Druckzeilen mit Datum, Charge `D-…`, Kurzname, Gramm und Status.
|
||||||
|
- Material-Spulenformular: Marke, Chargen und Technik unter „Weitere Angaben“ eingeklappt.
|
||||||
|
- Druck-Etikett: „Soll … g“ statt „Ziel“ (Slicer-Filamentmenge, wie im Bericht).
|
||||||
|
- Tasten-Grenze im Ablauf: dokumentierter Default 3 mm (kalt an der Düse sonst Fehlalarm); Anleitung und Technik-Notiz angepasst.
|
||||||
|
- `requirements.txt` mit festen Versionen; `requirements-dev.txt` mit pytest.
|
||||||
|
- Unbenutzte Helfer entfernt (`printers.load_printers`, Mesh-Vergleich 3×3, `fetch_bed_mesh`, `last_sample_at`, …).
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Druckbericht: Objekt-Etiketten kollidieren nicht mehr, wenn dieselbe Chargennummer schon an einem anderen Lauf hängt (Seite `/protokoll/<id>` lieferte 500).
|
||||||
|
- Orca-Nachbearbeitung: Export unter dem Slice-Ordner (inkl. Unterordner) wird als Watch-Datei übernommen statt flachem Upload; Temp-Dateien `.gcode.pp` erkannt.
|
||||||
|
- Material: Rest-Gramm im Spulenformular akzeptiert Dezimalwerte (Abzug lieferte z. B. 501,34 g — Browser blockierte bei `step=1`).
|
||||||
|
|
||||||
|
|
||||||
|
## [2.5.1] – 2026-10-17
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Cockpit-Steuerung: ACE-Trockner starten/stoppen (Temp, Dauer), nur wenn ein ACE hängt.
|
||||||
|
- Cockpit-Steuerung: bestückte ACE-Plätze mit Sorte und Farbe aus Grotti an den Drucker schreiben.
|
||||||
|
- Wand-Kacheln zeigen das Druck-Vorschaubild (Moonraker-Thumbnail) in der Job-Zeile.
|
||||||
|
- **Druckbericht** `/protokoll/<id>`: Soll/Ist-Gramm, Spulenanteile, Temperaturverlauf, Ereignisse, Charge.
|
||||||
|
- **Statistik** `/statistik`: Auslastung, Erfolg, Verbrauch, Ausschuss, Schätztreue; CSV-Export.
|
||||||
|
- Spulenakte unter Material: verdruckte Gramm, Ausschuss, Leer-in aus dem Wochenverbrauch.
|
||||||
|
- Etiketten pro Druck oder Objekt (`D-YYYYMMDD-nnnn`), QR auf den Bericht; Scan von `D-…` öffnet den Bericht.
|
||||||
|
- Spulenabzug nach Ist (`filament_used`); Abbruch nur anteilig; Mehrfarb-Anteile als Schätzwert; Rest darf ins Minus.
|
||||||
|
- Rollenwechsel während Druck/Pause (Einlegen) teilt den Verbrauch in Abschnitte.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Statistik: Zeitraum per Knopf (Tag, Woche, Monat, Gestern, Letzte Woche, Letzter Monat, Alles); Bis-Datum zählt inklusiv; CSV übernimmt denselben Filter.
|
||||||
|
- Druck-Charge `D-…` wieder während des Laufs (Protokoll, Wand-Etikett, Bericht); Adoption übernimmt die Nummer auf den History-Job. Ausschuss: gedruckte Bögen verwerfen, Charge bleibt.
|
||||||
|
- Dateien → ACE-Zuordnung: Platz-Dropdown zeigt bei bekannter Spule Material, Farbe, Name und Restgramm.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Spulenakte blieb leer: Abzug schrieb oft kein `run_spools` (hängende History-Läufe, Abzug vor Akte-Tabelle); Nachzug ohne erneuten Rest-Abzug, Abzug schreibt `run_spools` vor `deducted`.
|
||||||
|
- Protokoll: keine Doppel-„druckt“-Zeilen mehr (Live + History); Watcher hängt am History-Job, sobald Moonraker ihn kennt; Liste blendet überzählige Live-Zeilen aus.
|
||||||
|
- Platte geräumt: `needs_clear` nach Watcher-Ende und History-Sync (nur wenn kein Live-Druck); neuer Druckstart bricht alte Live-Läufe ab.
|
||||||
|
- Druck-Etikett: Material schon während des Laufs (Send/ACE/Spule); QR bleibt im 70‑mm-Bogen.
|
||||||
|
- Druckstart: Dateien mit altem `stored_path` (z. B. nach Umzug von `3DP/…/Uebersicht` nach Grotti) werden unter `data/eingang/<name>` wiedergefunden statt „liegt nicht mehr auf der Platte“.
|
||||||
|
- Wand/Sidebar: Dateiname springt nicht mehr alle paar Sekunden, wenn Moonraker kurz keinen Namen liefert; 3D-Pfad-Caption flackert nicht mehr mit „wird gelesen …“.
|
||||||
|
- Dateien: Hinweis zu fehlendem ACE-Trailer nur noch bei Mehrfarb (nicht als Warnung bei Monoteilen).
|
||||||
|
- 3D-Pfad wirkte gesprenkelt: Ausdünnen behält ganze Schichten dicht (statt Stichproben entlang der Bahn); keine Parallel-Offsets.
|
||||||
|
- Toolpath-API mit `Cache-Control: no-store`, damit der Browser keinen alten Pfad behält.
|
||||||
|
- Senden: Dateinamen mit Nicht-ASCII (z. B. CJK) werden für Moonraker/Rinkhals auf ASCII normalisiert — sonst Fehler 10115 „gcode … not found“.
|
||||||
|
|
||||||
|
## [2.2.2] – 2026-10-15
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- 3D-Pfad: bei großen G-codes zerbrach das Ausdünnen die Segment-Paare — Viewer zeigte nur Objekt-Umrisse, keinen Druckfortschritt (grün/grau).
|
||||||
|
|
||||||
|
## [2.2.1] – 2026-10-15
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Anleitungen mit relativen Bild- und Kapitelpfaden (`../web/static/doku/…`, `Anleitung-*.md`), damit Screenshots und Querverweise auch auf Git lesbar sind; die App schreibt sie beim Rendern auf `/static/…` bzw. `/doku/…` um.
|
||||||
|
|
||||||
|
## [2.2.0] – 2026-10-15
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Benutzerverwaltung:** lokaler Login (Admin/Operator), Bootstrap „Ersten Admin anlegen“, Session-Cookies, CSRF, Login-Rate-Limit.
|
||||||
|
- **API-Tokens** (`grt_…`) unter Einstellungen für App, Home-Automation und MCP; Shared-`config.token` bleibt als Migrationsbrücke (Admin).
|
||||||
|
- **MCP-Server** (`python -m grotti.mcp_server`): Lesen + bestätigte Steuer-Tools; `GROTTI_MCP_READ_ONLY`; Doku `docs/internal/MCP.md`.
|
||||||
|
- OIDC-Platzhalter in Config (`oauth`) für späteren Anschluss.
|
||||||
|
- Schema: `users`, `api_tokens`, `oauth_identities`.
|
||||||
|
- **Logo / Favicon:** Markenzeichen in der Sidebar und als Favicon (`web/static/brand/`).
|
||||||
|
- Login: statt Nutzhinweis wechselnde Ingenieurszitate (ein Zitat pro Tag).
|
||||||
|
- Umfangreiche Bedienungsanleitung unter `/doku` (`docs/Anleitung-*.md`) mit UI-Screenshots.
|
||||||
|
- Task-Hygiene wie Rinkhals (Kobra 3 und S1): `ensure_task_free` vor Start; bei **10101** cancel + Retry.
|
||||||
|
- Live-Snapshot `anycubic_task` auf der Wand.
|
||||||
|
- Cursor-Regel `.cursor/rules/grotti.mdc`.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- `/doku` zeigt nur noch `Anleitung-*.md`; Technik/Debug unter `docs/internal/` (nicht in der App). Changelog nicht mehr in der UI.
|
||||||
|
- Anleitungen ohne interne Stack-/Debug-Details.
|
||||||
|
- HTML und API hinter Auth (außer Setup/Login); kein Token mehr im Meta-Tag.
|
||||||
|
- Kamera im Cockpit: Layout-Klassen bleiben erhalten; ohne erreichbare Webcam kein Platzreservierung; `has_camera` im Drucker-Payload.
|
||||||
|
- `POST /api/v1/druck-queue` braucht `confirm: true` (wie Senden).
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Cockpit: periodisches Layout-Ruckeln durch `className`-Reset der Kamera-Folds.
|
||||||
|
- Kameras-Seite und leere Flotte: echte Empty-States statt Raw-API-URL / leerem Raster.
|
||||||
|
- Spulen-Etikett: QR-Code wieder sichtbar — `qrcode.min.js` lokal.
|
||||||
|
|
||||||
## [2.0.0] – 2026-10-15
|
## [2.0.0] – 2026-10-15
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|||||||
@@ -1,102 +1,155 @@
|
|||||||
# Grotti
|
<p align="center">
|
||||||
|
<img src="docs/brand/readme-banner.png" alt="Grotti" width="720">
|
||||||
|
</p>
|
||||||
|
|
||||||
Steuert die 3D-Drucker im Haus von einer Weboberfläche aus: Live-Zustand, Dateien senden, Warteschlange, Abläufe für den Kobra 3 (drucken, abkühlen, abschieben, prüfen), Material, Projekte und ein Protokoll, was wann passiert ist.
|
<p align="center">
|
||||||
|
<strong>Farm-Manager für Moonraker-Drucker</strong><br>
|
||||||
|
Eine Oberfläche für die ganze Farm: Wand, Warteschlange, Material, Abläufe und Protokoll.<br>
|
||||||
|
<em>Benannt nach der Mühle Grotti — sie mahlt ohne Pause, was man ihr aufträgt.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
Der Name kommt von der Mühle Grotti: sie mahlt ohne Pause, was man ihr aufträgt.
|
<p align="center">
|
||||||
|
<a href="https://git.gundel.dev/dg/grotti"><img src="https://img.shields.io/badge/Version-3.5.0-e85d04?style=flat-square" alt="Version 3.5.0"></a>
|
||||||
|
<img src="https://img.shields.io/badge/Python-3.11+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
|
||||||
|
<img src="https://img.shields.io/badge/PostgreSQL-14+-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
|
||||||
|
<img src="https://img.shields.io/badge/Moonraker-Klipper-2ecc71?style=flat-square" alt="Moonraker">
|
||||||
|
<img src="https://img.shields.io/badge/UI-Deutsch-111827?style=flat-square" alt="UI Deutsch">
|
||||||
|
<img src="https://img.shields.io/badge/Self--hosted-lokal-6b7280?style=flat-square" alt="Self-hosted">
|
||||||
|
</p>
|
||||||
|
|
||||||
Die Drucker laufen mit Rinkhals (Kobra 3, Kobra S1). Grotti spricht sie über Moonraker an.
|
<p align="center">
|
||||||
|
<a href="#features">Features</a> ·
|
||||||
|
<a href="#screenshots">Screenshots</a> ·
|
||||||
|
<a href="#schnellstart">Schnellstart</a> ·
|
||||||
|
<a href="#api">API</a> ·
|
||||||
|
<a href="CHANGELOG.md">Changelog</a> ·
|
||||||
|
<a href="docs/community/README.md">Community</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
Aktuelle Version: siehe `grotti/version.py`, Änderungen in [CHANGELOG.md](CHANGELOG.md).
|
---
|
||||||
|
|
||||||
## Voraussetzungen
|
<p align="center">
|
||||||
|
<img src="docs/community/01-druckerwand.png" alt="Druckerwand" width="900">
|
||||||
|
</p>
|
||||||
|
|
||||||
- Python 3.11
|
## 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, der beobachtet wird. Leer = nur Upload. |
|
|
||||||
| `token` | Wenn gesetzt, braucht die API `Authorization: Bearer <token>`. |
|
|
||||||
| `fqdn` | Öffentliche Basis-URL für Spulen-Etiketten-QR. Leer = Request-Host. |
|
|
||||||
| `energy` | Watt und Strompreis für die Kosten im Protokoll. |
|
|
||||||
| `telegram`, `alerts` | Bot und welche Ereignisse als Nachricht kommen. |
|
|
||||||
| `auto_queue` | Wenn `false`, startet die Warteschlange nichts Neues. |
|
|
||||||
|
|
||||||
Die Drucker stehen beim ersten Start in `drucker.json` und landen dann in der Datenbank. Danach werden sie unter Einstellungen gepflegt.
|
|
||||||
|
|
||||||
```json
|
|
||||||
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
|
|
||||||
```
|
|
||||||
|
|
||||||
Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen.
|
|
||||||
|
|
||||||
## Starten
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\.venv\Scripts\python.exe server.py
|
.\.venv\Scripts\python.exe server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Dann <http://127.0.0.1:8770/> öffnen. Handbuch in der Oberfläche unter **Doku**.
|
Dann <http://127.0.0.1:8770/>. Bedienung unter **Doku** in der App (`docs/Anleitung-*.md`).
|
||||||
|
|
||||||
`config.json` und `data/` sind lokal und nicht im Git.
|
### Raspberry Pi (`.deb`)
|
||||||
|
|
||||||
|
Auf dem Pi (arm64 / Bookworm) bauen und installieren — PostgreSQL und systemd-Dienst werden mit eingerichtet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packaging/build-deb.sh
|
||||||
|
sudo apt install ./packaging/dist/grotti_*_arm64.deb
|
||||||
|
journalctl -u grotti -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Details: [docs/Anleitung-Installation-Pi.md](docs/Anleitung-Installation-Pi.md). Öffentliches apt-Repo folgt später.
|
||||||
|
|
||||||
|
`config.json` und `data/` bleiben lokal (nicht im Git). Drucker zuerst in `drucker.json`, danach unter Einstellungen.
|
||||||
|
|
||||||
|
| Schlüssel | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| `host`, `port` | Oberfläche (Standard `127.0.0.1:8770`) |
|
||||||
|
| `database_url` | PostgreSQL |
|
||||||
|
| `watch_dir` | Slice-Ordner (oder leer = nur Upload) |
|
||||||
|
| `energy` / `telegram` / `alerts` | Kosten und Benachrichtigungen |
|
||||||
|
|
||||||
|
Schema-Migrationen laufen beim Start (`grotti/migrations/`). Vor Major-Updates: `pg_dump`.
|
||||||
|
|
||||||
## Ordner
|
## 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/` | Technische Notizen, gerendert unter `/doku/<name>` |
|
| `docs/` | Anleitungen (`/doku`), Community-Screens, Technik unter `internal/` |
|
||||||
| `CHANGELOG.md` | Änderungen je Version |
|
| `CHANGELOG.md` | Versionen |
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
Alles unter `/api/v1`, JSON. Die wichtigsten Endpunkte:
|
Alles unter `/api/v1`, JSON. Auth: Session oder `Authorization: Bearer grt_…`.
|
||||||
|
|
||||||
| Methode und Pfad | Zweck |
|
| Methode und Pfad | Zweck |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
| `POST /auth/setup`, `/auth/login`, `/auth/logout` | Ersten Admin, Login, Logout |
|
||||||
|
| `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens |
|
||||||
| `GET /version` | Version |
|
| `GET /version` | Version |
|
||||||
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag |
|
| `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe |
|
||||||
|
| `GET /drucker?live=1` | Drucker mit Live-Zustand |
|
||||||
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
|
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
|
||||||
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter |
|
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE |
|
||||||
| `GET /dateien`, `POST /dateien` | Bibliothek, Upload |
|
| `POST /drucker/<host>/abraeumen` | Platte geräumt (`confirm`) |
|
||||||
| `POST /senden` | Datei an Drucker. `confirm: true` ist Pflicht. `when_free: true` reiht ein. |
|
| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek |
|
||||||
| `GET /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange, Abbrechen (`status: cancelled`), Ablauf fortsetzen (`resume`) |
|
| `POST /senden`, `GET`/`POST /druck-queue` | Senden und Warteschlange (`confirm`) |
|
||||||
| `POST /spulen/<id>/einlegen` | Spule an Drucker/Platz; alte Rolle dort ins Regal |
|
| `GET /spulen`, `POST /spulen/<id>/einlegen` | Material |
|
||||||
| `POST /ablauf` | Ablauf für Kobra 3 einreihen |
|
| `GET /laeufe`, `/statistik`, `/ereignisse` | Protokoll und Aktivität |
|
||||||
| `GET /ereignisse?host=&kind=&since=&limit=` | Aktivität |
|
|
||||||
| `POST /ereignisse/gelesen` | Alerts als gelesen |
|
MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` / `GROTTI_TOKEN` → `docs/internal/MCP.md`.
|
||||||
| `GET /spulen`, `GET /laeufe`, `GET /projekte` | Material, Protokoll, Projekte |
|
|
||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
- Druckstarts brauchen `confirm: true`. Die Oberfläche fragt per Zwei-Klick.
|
- Login Pflicht. Rollen: Admin, Operator.
|
||||||
- Keine freie G-code-Konsole.
|
- Druckstarts brauchen `confirm: true` (Zwei-Klick in der UI).
|
||||||
- Kein `FIRMWARE_RESTART`, GoKlipper hängt sich dabei auf. Fehler werden über „Fehler löschen“ geräumt.
|
- Keine freie G-code-Konsole; kein `FIRMWARE_RESTART` über Grotti.
|
||||||
|
- Nach außen: Reverse Proxy mit TLS.
|
||||||
|
|
||||||
## Versionen und Changelog
|
## Versionen
|
||||||
|
|
||||||
- Versionierung nach [SemVer](https://semver.org/lang/de/): Patch für Fehlerbehebung, Minor für neue Funktion, Major für Bruch an API oder Datenbankschema.
|
- 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>
|
||||||
@@ -2,29 +2,54 @@
|
|||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8770,
|
"port": 8770,
|
||||||
"data_dir": "data",
|
"data_dir": "data",
|
||||||
"watch_dir": "",
|
"watch_dir": "slice",
|
||||||
"database_url": "postgresql://[email protected]:5432/grotti",
|
"database_url": "postgresql://[email protected]:5432/grotti",
|
||||||
"token": "",
|
"token": "",
|
||||||
|
"secret_key": "",
|
||||||
"fqdn": "",
|
"fqdn": "",
|
||||||
|
"log_level": "INFO",
|
||||||
"auto_queue": true,
|
"auto_queue": true,
|
||||||
|
"auto_retry": true,
|
||||||
|
"oauth": {
|
||||||
|
"enabled": false,
|
||||||
|
"issuer": "",
|
||||||
|
"client_id": "",
|
||||||
|
"client_secret": "",
|
||||||
|
"redirect_uri": ""
|
||||||
|
},
|
||||||
"energy": {
|
"energy": {
|
||||||
"watts": 120,
|
"watts": 40,
|
||||||
|
"nozzle_watts": 60,
|
||||||
|
"bed_watts": 220,
|
||||||
"euro_per_kwh": 0.35,
|
"euro_per_kwh": 0.35,
|
||||||
"dryer_watts": 0,
|
"dryer_watts": 0,
|
||||||
"dry_hours": 0,
|
"dry_hours": 0,
|
||||||
"waste_percent": 0,
|
"waste_percent": 0,
|
||||||
"utilization": 50
|
"utilization": 50,
|
||||||
|
"tariff": {
|
||||||
|
"ht_euro": 0.35,
|
||||||
|
"nt_euro": 0.22,
|
||||||
|
"nt_from": "22:00",
|
||||||
|
"nt_to": "06:00"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"bot_token": "",
|
"bot_token": "",
|
||||||
"chat_id": ""
|
"chat_id": ""
|
||||||
},
|
},
|
||||||
|
"ntfy": {
|
||||||
|
"enabled": false,
|
||||||
|
"server": "https://ntfy.sh",
|
||||||
|
"topic": "",
|
||||||
|
"token": ""
|
||||||
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"fertig": true,
|
"fertig": true,
|
||||||
"fehler": true,
|
"fehler": true,
|
||||||
"offline": true,
|
"offline": true,
|
||||||
"queue": true,
|
"queue": true,
|
||||||
|
"spule": true,
|
||||||
"ablauf": false,
|
"ablauf": false,
|
||||||
"start": false
|
"start": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Spule einlegen
|
||||||
|
|
||||||
|
1. Unter **Material → Spulen** die Rolle wählen oder anlegen.
|
||||||
|
2. Ort = Drucker und **ACE-Platz** (1–8) oder externe Spule.
|
||||||
|
3. Materialtyp und Farbe sollten zur Slice-Datei passen.
|
||||||
|
|
||||||
|
Es gibt **kein freies Spulen-Dropdown** beim Start: Grotti nimmt die Rolle(n), die am Ziel-Drucker liegen. Material-Mismatch oder zu wenig Rest → Warnung vor dem Start.
|
||||||
|
|
||||||
|
### Rollenwechsel während Pause
|
||||||
|
|
||||||
|
Während Druck oder Pause die neue Spule unter Material dem Drucker zuweisen und speichern. Grotti schließt den Abschnitt der alten Rolle und öffnet einen neuen — im Druckbericht erscheinen dann zwei Spulenzeilen und Ereignisse. Nur Filament am Gerät laden ohne Zuweisung: Abzug und Protokoll bleiben bei der alten Rolle.
|
||||||
|
|
||||||
|
Wenn Moonraker den Druck nach Filament-Pause als neuen History-Job führt, übernimmt Grotti die neu zugewiesene Rolle für den fortgesetzten Teil (Send- und Lauf-Spule werden beim Einlegen aktualisiert).
|
||||||
|
|
||||||
|
Etiketten mit QR: [Material und mehr](Anleitung-Material.md).
|
||||||
|
|
||||||
|
## ACE-Map beim Senden
|
||||||
|
|
||||||
|
Unter **Dateien → Optionen → ACE-Zuordnung**:
|
||||||
|
|
||||||
|
- Jede Farbe aus der Datei (`Tn` / Slicer-`paint_index`) einem **ACE-Platz** zuordnen
|
||||||
|
- Bei Bambu-/Anycubic-Dateien sind die Nummern oft **nicht 0–3** (z. B. T1 und T10 bei 16 Slots) — Grotti zeigt `T…`, verdichtet beim Senden aber auf T0…n (sonst Fehler **invalid filament id**)
|
||||||
|
- Liegt unter Material eine Spule auf dem Platz, zeigt die Liste Name, Farbe und Restgramm mit — Vorauswahl bevorzugt die **Katalog-Sorte** aus dem Orca-Profil (Meta/Matte …)
|
||||||
|
- Ohne passende Map schreibt der Drucker oft **„cannot parse the file“** und startet nicht
|
||||||
|
- Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit
|
||||||
|
|
||||||
|
Bei **Nächster passender** (kein Drucker gewählt) baut Grotti die Map erst beim Claim aus den Spulen am gewählten Drucker. Passt die Sorte nicht → Wartegrund in der Queue.
|
||||||
|
|
||||||
|
Externe Spule / keine ACE-Slots: keine `.acm` nötig.
|
||||||
|
|
||||||
|
Nach einem Grotti-Update mit ACE-Farben: unter Dateien **neu lesen**, damit alte Einträge die echten `Tn` bekommen.
|
||||||
|
|
||||||
|
## Typische Fehler
|
||||||
|
|
||||||
|
| Symptom | Check |
|
||||||
|
| --- | --- |
|
||||||
|
| „cannot parse the file“ | ACE-Zuordnung vollständig? Farben richtig? |
|
||||||
|
| „T… fehlt in der Map“ | Datei neu lesen, Zuordnung erneut setzen (Slicer-T ≠ ACE-Platz) |
|
||||||
|
| **invalid filament id** (10011700) | Alte Grotti-Version ohne T-Verdichtung, oder Map nicht neu gesetzt. Grotti neu starten, Datei neu lesen, nochmal senden. |
|
||||||
|
| Material-Warnung | Eingelegte Sorte ≠ Datei. Der Start geht trotzdem. PA12-CF steht in Grotti so, an den ACE geht PA6-CF. |
|
||||||
|
| Rest zu gering | Andere Spule oder leichteres Teil |
|
||||||
|
| ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten |
|
||||||
|
| Drucker hängt kurz nach Multi-Color-Start | Oft falsche Map (T10 gemappt als Platz 0). Neu lesen + Zuordnung. |
|
||||||
|
|
||||||
|
## Weiter
|
||||||
|
|
||||||
|
- [Dateien senden](Anleitung-Dateien.md)
|
||||||
|
- [Material](Anleitung-Material.md)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Aufträge
|
||||||
|
|
||||||
|
Stand **18.10.2026**. Kundenaufträge mit Positionen (Datei × Menge), Fortschritt und Lieferschein.
|
||||||
|
|
||||||
|
## Seite Aufträge
|
||||||
|
|
||||||
|
Unter **Aufträge** legst du Kunden und Aufträge an. Status: Idee → Angebot → Bestätigt → Druckt → Fertig → Geliefert → Bezahlt.
|
||||||
|
|
||||||
|
Pro Position: Datei, Stückzahl, optional Preis und Material. **Einreihen** schickt die Position in die Warteschlange (`confirm`). Fertige gute Stücke aus dem Abräumen-Dialog zählen auf den Fortschritt „n von m“.
|
||||||
|
|
||||||
|
## Lieferschein
|
||||||
|
|
||||||
|
Im Auftrag **Lieferschein** öffnen: Positionen, Chargen `D-…`, druckbar. Danach Status auf **Geliefert** setzen.
|
||||||
|
|
||||||
|
## Weiter
|
||||||
|
|
||||||
|
- [Projekte](Anleitung-Projekte.md) (Arbeitsmappe / Konstruktion — nicht dasselbe wie Auftrag)
|
||||||
|
- [Warteschlange](Anleitung-Warteschlange.md)
|
||||||
|
- [Material](Anleitung-Material.md)
|
||||||
|
- [Protokoll / Bericht](Anleitung-Oberflaeche.md)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Dateien senden
|
||||||
|
|
||||||
|
Stand **18.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Bibliothek
|
||||||
|
|
||||||
|
- Kompakte Filterleiste: Suche, Maschine, Projekt, Material, Ordner, Sortierung
|
||||||
|
- Filter: Maschine, Projekt, Material, Ordner (Unterordner unter dem Slice-Ordner; Uploads als „Upload“)
|
||||||
|
- Sortierung: Neueste, Name, Material, Ordner, Druckzeit, Gewicht — bei Material/Ordner mit Gruppenköpfen
|
||||||
|
- Auswahl der Filter bleibt im Browser merken
|
||||||
|
- Vorschaubild, Material, Zeit, Gewicht, Schichthöhe
|
||||||
|
- Info-Popup mit Slice-Metadaten (Kopf und Footer)
|
||||||
|
- **Hochladen** per Knopf oder Drag & Drop
|
||||||
|
- Mit gesetztem Slice-Ordner ([Einstellungen](Anleitung-Einstellungen.md)): **Ordner anlegen**; Watch- **und** Upload-Dateien per Auswahl **verschieben** (Upload wird zur Watch-Datei im Slice-Ordner)
|
||||||
|
- **Umbenennen** (Stift-Icon) — Datei auf der Platte und in der Bibliothek; Endung `.gcode`/`.3mf` bleibt, wenn du sie weglässt
|
||||||
|
- **Löschen** (Papierkorb, Zwei-Klick) für jede Bibliotheksdatei — entfernt Eintrag und Datei auf der Platte (nur unter Slice-Ordner oder `eingang`)
|
||||||
|
- **Ordner neu lesen**, wenn unter Einstellungen ein Slice-Ordner gesetzt ist
|
||||||
|
|
||||||
|
Ohne Slice-Ordner geht nur manueller Upload; Verschieben in Ordner braucht den Slice-Pfad.
|
||||||
|
|
||||||
|
Projekte mit eigenem Ordner: [Projekte](Anleitung-Projekte.md).
|
||||||
|
|
||||||
|
## Senden: drei Schritte
|
||||||
|
|
||||||
|
1. **Datei** wählen (Klick in der Liste).
|
||||||
|
2. **Drucker** wählen — Kacheln zeigen Live-Zustand und eingelegte Rolle. Passt die Maschine nicht, ist die Kachel blass.
|
||||||
|
3. **Start:**
|
||||||
|
- **Dieser Drucker** — frei und Platte klar → sofort starten; sonst Warteschlange für diesen Drucker
|
||||||
|
- **Nächster passender** — Grotti wählt den nächsten freien Drucker mit passender Sorte (z. B. Sunlu PLA Meta, nicht Matte)
|
||||||
|
|
||||||
|
Auswahl merkt sich der Browser (`localStorage`).
|
||||||
|
|
||||||
|
### Optionen
|
||||||
|
|
||||||
|
Unter Optionen u. a.:
|
||||||
|
|
||||||
|
- **Bettnetz / Auto-Level** beim MQTT-Start (Anycubic)
|
||||||
|
- Maschinen-Override, Projekt zuweisen
|
||||||
|
- **ACE-Zuordnung** — siehe [ACE und Spulen](Anleitung-ACE.md)
|
||||||
|
|
||||||
|
## Warum es dauert
|
||||||
|
|
||||||
|
Große Dateien brauchen oft **eine Minute**. In der Sidebar unter **Senden** siehst du den MB-Fortschritt. Danach startet der Druck.
|
||||||
|
|
||||||
|
## Nach dem Start
|
||||||
|
|
||||||
|
- Fortschritt auf der [Wand](Anleitung-Wand.md) und in der Sidebar **Läuft jetzt**
|
||||||
|
- Bei Warteschlange / Nächster passender: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md)
|
||||||
|
- Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map)
|
||||||
|
|
||||||
|
## Aus Orca
|
||||||
|
|
||||||
|
Nach dem G-code-Export kann Orca die Datei direkt nach Grotti laden und diese Seite mit der Datei öffnen — siehe [Orca an Grotti](Anleitung-Orca.md).
|
||||||
|
|
||||||
|
## Weiter
|
||||||
|
|
||||||
|
- [Projekte](Anleitung-Projekte.md)
|
||||||
|
- [Orca an Grotti](Anleitung-Orca.md)
|
||||||
|
- [ACE und Spulen](Anleitung-ACE.md)
|
||||||
|
- [Warteschlange](Anleitung-Warteschlange.md)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
## Was du brauchst
|
||||||
|
|
||||||
|
- Python 3.11
|
||||||
|
- PostgreSQL (leere Datenbank `grotti` reicht; Schema legt Grotti an)
|
||||||
|
- Drucker im selben Netz, Moonraker auf Port 80
|
||||||
|
- Dieses Repo und eine `config.json`
|
||||||
|
|
||||||
|
## Einmalig einrichten
|
||||||
|
|
||||||
|
Im Projektordner:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\Projekte\Grotti
|
||||||
|
python -m venv .venv
|
||||||
|
.\.venv\Scripts\pip install -r requirements.txt
|
||||||
|
copy config.example.json config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`config.json` anpassen. Die wichtigsten Schlüssel:
|
||||||
|
|
||||||
|
| Schlüssel | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. |
|
||||||
|
| `database_url` | PostgreSQL-Verbindung. |
|
||||||
|
| `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. |
|
||||||
|
| `watch_dir` | Slice-Ordner (meist unter **Einstellungen** setzen). Leer = nur manueller Upload. Standard-Vorschlag: Ordner `slice` im Repo. |
|
||||||
|
| `token` | Optionaler Shared-API-Token (Übergang). Mit Benutzerverwaltung nutzt du API-Tokens unter Einstellungen. |
|
||||||
|
| `fqdn` | Öffentliche Basis-URL für QR auf Spulen-Etiketten. Leer = Request-Host. |
|
||||||
|
| `auto_queue` | `false` stoppt neue „wenn frei“-Starts. |
|
||||||
|
| `energy`, `telegram`, `alerts` | Kosten im Protokoll und Telegram — später auch unter Einstellungen. |
|
||||||
|
| `secret_key` | Für Login-Sessions. Beim ersten Start wird ein Wert erzeugt, wenn leer. |
|
||||||
|
|
||||||
|
Drucker beim ersten Start über `drucker.json` (Seed). Danach pflegt man sie unter [Einstellungen](Anleitung-Einstellungen.md).
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen.
|
||||||
|
|
||||||
|
## Starten
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\.venv\Scripts\python.exe server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann <http://127.0.0.1:8770/> öffnen. Beim ersten Start legst du den Admin-Benutzer an. Nach Code-Änderungen an Python oft neu starten.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Druckstart über die API nur mit `confirm: true` (Dateien-UI: ein Klick).
|
||||||
|
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
|
||||||
|
- Benutzer und API-Tokens unter Einstellungen. 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.
|
||||||
|
|
||||||
|
## Weiter
|
||||||
|
|
||||||
|
- [Oberfläche](Anleitung-Oberflaeche.md) — Navigation und Sidebar
|
||||||
|
- [Dateien senden](Anleitung-Dateien.md) — erster Druck
|
||||||
|
- Ausführlich im `README.md` im Projektordner
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Einstellungen
|
||||||
|
|
||||||
|
Stand **18.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram/ntfy, Energie.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Dieser Browser
|
||||||
|
|
||||||
|
- **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche.md).
|
||||||
|
- **Darstellung** — Dunkel, Hell oder System (folgt dem Gerät).
|
||||||
|
- **Zeitzone** — Browser automatisch oder fest (z. B. Europe/Berlin) für Protokoll, Bericht und Aktivität.
|
||||||
|
|
||||||
|
## Automatik
|
||||||
|
|
||||||
|
Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen Queue-Starts. Laufende Abläufe und manuelle Sofort-Starts bleiben möglich.
|
||||||
|
|
||||||
|
## Slice-Ordner
|
||||||
|
|
||||||
|
Pfad zum Ordner, den die Bibliothek beobachtet (Export aus Orca/Anycubic, **Ordner neu lesen**, Unterordner). **Standardordner** setzt `slice` im Grotti-Repo. Leer = nur Hochladen. Nur Admin; der Ordner wird beim Speichern angelegt, falls er fehlt.
|
||||||
|
|
||||||
|
## Drucker
|
||||||
|
|
||||||
|
Pro Drucker: Name, Host, Familie, **gesperrt**, optional **Start-Zeitfenster**.
|
||||||
|
|
||||||
|
- Seed beim allerersten Start aus `drucker.json`, danach nur noch hier (Datenbank).
|
||||||
|
- Familie aus dem Namen (Kobra 3 / S1) oder gesetzt.
|
||||||
|
- **Gesperrt:** keine neuen Queue-Starts auf diesem Host.
|
||||||
|
- **Zeitfenster:** Queue startet nur zwischen Von/Bis (z. B. 22:00–06:00). Optional Max.-Stunden für Nachtjobs.
|
||||||
|
|
||||||
|
## FQDN
|
||||||
|
|
||||||
|
Öffentliche Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host aus dem aktuellen Request. Wichtig, wenn du Etiketten von einem Handy scannst, das nicht `127.0.0.1` kennt.
|
||||||
|
|
||||||
|
## Telegram, ntfy und Alerts
|
||||||
|
|
||||||
|
Telegram: Bot-Token und Chat-ID, pro Ereignisart an/aus, Test-Knopf. **ntfy:** Server, Topic, optional Token — gleiche Meldungen, unabhängig schaltbar, ebenfalls mit Test. Ereignisse stehen auch in der Sidebar unter Alerts.
|
||||||
|
|
||||||
|
## Benutzer und API-Tokens
|
||||||
|
|
||||||
|
Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`). Ein Shared-`token` in der Config-Datei gilt nicht mehr.
|
||||||
|
|
||||||
|
MCP (optional): Token erzeugen, dann `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN`. Für nur Lesen: `GROTTI_MCP_READ_ONLY=1`.
|
||||||
|
|
||||||
|
## Energie
|
||||||
|
|
||||||
|
**Grundlast W** (Elektronik/Motion, nicht die Heizer) und Strompreis für die Kosten im **Protokoll**. **Düse W** / **Bett W** zusammen mit ED-Proben im Verlauf → Schätzung „aus ED“; stehen sie auf 0, bleibt es bei Dauer × Grundlast („pauschal“). Typisch Kobra: Grundlast ~40 W, Düse ~60 W, Bett ~200–300 W. Optional **HT/NT-Tarif** mit Zeiten und Preisen — Grotti gewichtet dann die Stromkosten nach Uhrzeit. Beim Speichern warnt die Seite, wenn Düse oder Bett noch 0 sind.
|
||||||
|
|
||||||
|
## Weiter
|
||||||
|
|
||||||
|
- [Einrichtung](Anleitung-Einrichtung.md) (Config-Datei)
|
||||||
|
- [Störungen](Anleitung-Stoerungen.md)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Installation auf dem Raspberry Pi
|
||||||
|
|
||||||
|
Stand **18.10.2026**. Phase 1: Installation per `.deb`-Datei. Ein öffentliches apt-Repository kommt später.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Raspberry Pi OS **Bookworm** (64-Bit / arm64), Pi 4 oder 5 empfohlen
|
||||||
|
- Netz zu den Druckern (Moonraker/Rinkhals)
|
||||||
|
- ca. 1 GB freier Speicher für Paket + PostgreSQL
|
||||||
|
|
||||||
|
## Paket bauen (auf dem Pi oder arm64-Host)
|
||||||
|
|
||||||
|
Im geklonten Repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y python3-venv rsync dpkg-dev
|
||||||
|
bash packaging/build-deb.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergebnis: `packaging/dist/grotti_<version>_arm64.deb`
|
||||||
|
|
||||||
|
## Installieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ./packaging/dist/grotti_*_arm64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
Das zieht **PostgreSQL** mit, legt Benutzer `grotti`, Datenbank `grotti` und den Dienst an.
|
||||||
|
|
||||||
|
- Oberfläche: `http://<pi-ip>:8770/`
|
||||||
|
- Config: `/etc/grotti/config.json` (wird bei Updates nicht überschrieben)
|
||||||
|
- Daten: `/var/lib/grotti/`
|
||||||
|
- Slice-Ordner: `/var/lib/grotti/slice` (unter Einstellungen änderbar)
|
||||||
|
|
||||||
|
Beim ersten Aufruf Admin-Konto anlegen (wie gewohnt).
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Logs gehen nach **journald**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u grotti -f
|
||||||
|
journalctl -u grotti --since "1h ago"
|
||||||
|
journalctl -u grotti -n 200
|
||||||
|
```
|
||||||
|
|
||||||
|
Log-Level in `/etc/grotti/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"log_level": "INFO"
|
||||||
|
```
|
||||||
|
|
||||||
|
Werte: `DEBUG`, `INFO`, `WARNING`, `ERROR`. Danach:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart grotti
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dienst
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status grotti
|
||||||
|
sudo systemctl restart grotti
|
||||||
|
sudo systemctl stop grotti
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update
|
||||||
|
|
||||||
|
Neue `.deb` bauen bzw. holen, dann erneut:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ./packaging/dist/grotti_*_arm64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
Config und `/var/lib/grotti` bleiben erhalten.
|
||||||
|
|
||||||
|
## Deinstallieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt remove grotti # Dienst weg, Daten bleiben
|
||||||
|
sudo apt purge grotti # zusätzlich /etc/grotti
|
||||||
|
# optional Datenbank:
|
||||||
|
# sudo -u postgres dropdb grotti
|
||||||
|
# sudo -u postgres dropuser grotti
|
||||||
|
# sudo rm -rf /var/lib/grotti
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Standard lauscht auf `0.0.0.0:8770` (LAN). Für Internet: Reverse Proxy mit TLS.
|
||||||
|
- Drucker unter **Einstellungen** anlegen (kein vorgefülltes `drucker.json` nötig).
|
||||||
|
- Öffentliches `apt install grotti` ohne Datei: kommt mit dem Repo-Server (Phase 2).
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Material und mehr
|
||||||
|
|
||||||
|
Stand **18.10.2026**. Spulen, Etiketten, Aufträge, Protokoll, Statistik, Aktivität.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Material
|
||||||
|
|
||||||
|
Zwei Reiter: **Spulen** und **Sorten**.
|
||||||
|
|
||||||
|
Oben die Karte **Bedarf aus Warteschlange**, wenn wartende Jobs Filament brauchen: Queue-Gewicht vs. Spulenrest je Sorte/Farbe. Fehlmengen erscheinen auch in der Sidebar und als Chip in der Warteschlange.
|
||||||
|
|
||||||
|
**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest / Archiv; zusätzlich Sorte und Farbe. Leere und archivierte Spulen sind standardmäßig ausgeblendet — **Archiv** zeigt sie. Formular bei Auswahl oder „Neue Spule“. **Leer** setzt Rest auf 0, legt die Rolle ins Regal und archiviert; **Archivieren** / **Wiederherstellen** ohne Restzwang. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in.
|
||||||
|
|
||||||
|
**Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
||||||
|
|
||||||
|
### Etikett und QR
|
||||||
|
|
||||||
|
Pro Spule ein Etikett mit Chargennummer `S-…` und QR. Der Code öffnet Material mit der Spule vorausgewählt. Dafür in den Einstellungen ggf. **FQDN** setzen (öffentliche URL), sonst gilt der Host aus dem Request.
|
||||||
|
|
||||||
|
Druck-Etiketten (`D-…`) kommen vom Druckbericht: ein Etikett für den ganzen Lauf oder je Objekt. Scan von `D-…` öffnet den Bericht.
|
||||||
|
|
||||||
|
## Aufträge
|
||||||
|
|
||||||
|
Unter **Aufträge** Kunden, Positionen und Fortschritt. Dateien lassen sich einem Auftrag zuweisen; siehe [Aufträge](Anleitung-Auftraege.md).
|
||||||
|
|
||||||
|
## Protokoll und Statistik
|
||||||
|
|
||||||
|
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Kosten-Aufschlüsselung Filament + Strom mit Tortendiagramm, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis.
|
||||||
|
|
||||||
|
Druck-Etiketten (`D-…`) schon während des Drucks: an der Wand **Etikett**, im Druckbericht **Etikett Druck** / **Etiketten Objekte**. Auf dem Bogen steht das Sollgewicht aus dem Slicer (geplante Filamentmenge). Bei Ausschuss die gedruckten Bögen wegwerfen; die Charge bleibt im Protokoll.
|
||||||
|
|
||||||
|
Abzug nach dem Druck nimmt das Ist aus `filament_used` (Abbruch nur anteilig). Mehrfarb-Anteile sind als Schätzwert gekennzeichnet. Restgramm darf unter 0 gehen.
|
||||||
|
|
||||||
|
## Kameras
|
||||||
|
|
||||||
|
Auf der **Druckerwand** erscheint je Drucker ein kleines Snapshot-Bild (ca. alle 3 s), wenn eine Webcam erreichbar ist. Klick öffnet das Cockpit — dort gilt weiter die Einstellung Snapshot/Stream unter Einstellungen → Dieser Browser. Die frühere Seite `/kameras` leitet auf die Wand um.
|
||||||
|
|
||||||
|
## Aktivität
|
||||||
|
|
||||||
|
Zeitstrahl: Starts, Schritte, Fertig, Fehler, Abbrüche, offline. Filter nach Drucker, Art, Zeitraum. Ungelesene Alerts sind markiert; in der Sidebar kannst du sie als gelesen setzen.
|
||||||
|
|
||||||
|
## Ideen
|
||||||
|
|
||||||
|
`/ideen` — Notizen und Ideen für später. Kein Druckfluss, nur Merkliste.
|
||||||
|
|
||||||
|
## Weiter
|
||||||
|
|
||||||
|
- [ACE und Spulen](Anleitung-ACE.md)
|
||||||
|
- [Einstellungen](Anleitung-Einstellungen.md)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Oberfläche
|
||||||
|
|
||||||
|
Stand **18.10.2026**. Wie die App aufgebaut ist — Menü, Jetzt-Liste, Desktop und Regal.
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
Links (Desktop), gruppiert: **Live** (Drucker, Dateien, Warteschlange, Aufträge), **Rückschau** (Protokoll, Statistik, Aktivität), **Verwalten** (Projekte, Material, Wartung). Darüber im Inhaltsbereich die **Topbar**: Suche, Live-Chips, Einstellungen, Doku und Benutzer-Menü (Abmelden, Version).
|
||||||
|
|
||||||
|
Unter ca. 760 px: untere Leiste (Drucker, Dateien, Warteschlange, **Jetzt**, Mehr) plus **Mehr**-Sheet (inkl. Aktivität und Abmelden). Das Seitenmenü lässt sich zur Icon-Rail einklappen.
|
||||||
|
|
||||||
|
Shortcut **Ctrl+K** (Mac: Cmd+K) oder der Suche-Knopf in der Topbar: Suche über Drucker, Dateien, Spulen, Chargen und Aufträge.
|
||||||
|
|
||||||
|
## Jetzt
|
||||||
|
|
||||||
|
Desktop: Chips in der Topbar (**Jetzt**, laufende Drucker, Senden, Meldungen). Klick öffnet ein Popover mit Liste und Aktionen. Bei mehreren Läufen zusätzlich **Alles fertig um …**.
|
||||||
|
|
||||||
|
Mobile / Touch: Tab **Jetzt** in der unteren Leiste (mit Badge) öffnet `/jetzt` — dieselbe Liste als volle Seite. Aktivität liegt unter **Mehr**.
|
||||||
|
|
||||||
|
| Priorität | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| Platte räumen | Quittieren mit Urteil (optional „gleich nochmal“) |
|
||||||
|
| Material nachlegen | Druck pausiert, Filament vermutlich leer (ACE/Runout) — Spule zuweisen, dann an der Wand **Weiter** |
|
||||||
|
| Druck fehlgeschlagen | Fortsetzen / Ausblenden |
|
||||||
|
| Spule fehlt | Link zu Material / Bedarf |
|
||||||
|
| Wartung fällig | Link zu Wartung |
|
||||||
|
| Auftrag überfällig | Link zu Aufträgen |
|
||||||
|
| Wartet auf Material | Wartegrund aus dem Routing |
|
||||||
|
|
||||||
|
Aktualisierung über Live-Stream (SSE), sonst Polling.
|
||||||
|
|
||||||
|
## Desktop und Regal
|
||||||
|
|
||||||
|
Unter **Einstellungen → Dieser Browser**:
|
||||||
|
|
||||||
|
- **Desktop** — normale Dichte
|
||||||
|
- **Regal (Touch)** — größere Bedienelemente, Sidebar oft eingeklappt; auf schmalem Viewport die untere Leiste. Alle Menüpunkte bleiben in der Sidebar sichtbar.
|
||||||
|
- **Darstellung** — Dunkel, Hell oder System
|
||||||
|
- **Zeitzone** — Browser oder fest (z. B. Europe/Berlin) für Uhrzeiten in Protokoll und Bericht
|
||||||
|
- **Kamera** — Snapshot (schonend, alle paar Sekunden) oder Stream (flüssig, hält eine Verbindung). Gilt für das Cockpit; Wand-Thumbs sind immer Snapshot.
|
||||||
|
|
||||||
|
Shortcut in der URL: `?shelf=1` bzw. `?shelf=0`. Nur dieser Browser (`localStorage`).
|
||||||
|
|
||||||
|
Drucker-Cockpit: Alltag (Job, ETA, Rolle, Steuerung der Aktionen) oben; **Technik** (Steuerung, Verlauf, 3D-Pfad, Bettnetz) zugeklappt.
|
||||||
|
|
||||||
|
## Weiter
|
||||||
|
|
||||||
|
- [Druckerwand](Anleitung-Wand.md)
|
||||||
|
- [Dateien senden](Anleitung-Dateien.md)
|
||||||
|
- [Projekte](Anleitung-Projekte.md)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Orca an Grotti
|
||||||
|
|
||||||
|
Stand **18.10.2026**. Nach dem Export landet der G-code in der Bibliothek und Grotti öffnet sich als **eigenes Fenster** (Chrome/Edge-App auf dem Desktop). Drucker, ACE-Platz und Einreihen wählst du dort.
|
||||||
|
|
||||||
|
## Einmal einrichten
|
||||||
|
|
||||||
|
1. Grotti starten und im Browser anmelden (die Session bleibt für die Senden-Seite).
|
||||||
|
2. Unter **Einstellungen → API-Token** ein Token anlegen (Rolle Operator reicht). Den Wert `grt_…` nur einmal sichtbar.
|
||||||
|
3. Datei `data/orca-senden.json` anlegen (gleicher Ordner wie `config.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "http://127.0.0.1:8770",
|
||||||
|
"token": "grt_…",
|
||||||
|
"browser": "app"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`url` ist die Adresse, unter der du Grotti im Browser öffnest.
|
||||||
|
`browser`: `app` (Standard) = eigenes Desktop-Fenster; `tab` = klassischer Browser-Tab.
|
||||||
|
|
||||||
|
4. Im **Prozessprofil** (Druckqualität, z. B. „0.20mm Standard“): **Sonstiges → Nachbearbeitungsskripte**, eine Zeile:
|
||||||
|
|
||||||
|
```text
|
||||||
|
D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_senden.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Gilt für **OrcaSlicer** und **AnycubicSlicerNext**. Pfade anpassen. Der Slicer hängt den Export-Pfad selbst an.
|
||||||
|
|
||||||
|
### Gerät-Reiter = Grotti (nur OrcaSlicer)
|
||||||
|
|
||||||
|
Damit unter **Gerät** die Grotti-Oberfläche erscheint:
|
||||||
|
|
||||||
|
1. Orca → Drucker-Dropdown → **Physikalischer Drucker** (Verbindung/Stecker-Symbol).
|
||||||
|
2. **Host-Typ:** z. B. `Octo/Klipper` (egal, solange du nicht „Drucken“ an den Host schickst).
|
||||||
|
3. **Hostname / API:** leer lassen — oder nur eine Dummy-Adresse. **Nicht** die Moonraker-IP eines Kobras eintragen.
|
||||||
|
4. **Device UI / Web-UI-URL:** `http://127.0.0.1:8770/` (Grotti muss laufen).
|
||||||
|
5. Speichern → Reiter **Gerät** öffnen → einmal bei Grotti anmelden.
|
||||||
|
|
||||||
|
Wichtig: Den Orca-Button **Drucken / Upload zum Host** nicht nutzen — der spricht Moonraker/OctoPrint und umgeht ACE-Zuordnung in Grotti. Weiterhin **G-code exportieren** + Nachbearbeitungsskript.
|
||||||
|
|
||||||
|
In **AnycubicSlicerNext** steckt unter Gerät/Arbeitsfläche die Anycubic-Cloud — dort lässt sich Grotti so nicht einbinden.
|
||||||
|
|
||||||
|
## Ablauf (wichtig bei Anycubic)
|
||||||
|
|
||||||
|
**Nicht** „Remote Print“ / „Aufgabe starten“ / Cloud-Dialog — der Anycubic-Dialog lässt sich von Grotti nicht abschalten.
|
||||||
|
|
||||||
|
Stattdessen:
|
||||||
|
|
||||||
|
1. Slicen, dann **G-code exportieren** / **Platte schreiben** (Ziel: Slice-Ordner; Unterordner ok).
|
||||||
|
2. Das Nachbearbeitungsskript lädt die Datei nach Grotti und öffnet das Grotti-Fenster.
|
||||||
|
3. Drucker wählen, unter Optionen Farbe → ACE-Platz, **Dieser Drucker** oder **Nächster passender**, dann **Starten**.
|
||||||
|
|
||||||
|
Erstes App-Fenster: einmal bei Grotti anmelden (eigenes Profil unter `data/grotti-app-profile`). Die Session hält **30 Tage** und überlebt das Schließen des Fensters. Läuft das Fenster noch, startet Orca kein zweites Chrome (sonst leeres Profil → erneutes Login) — die neue Datei wird im offenen Fenster ausgewählt.
|
||||||
|
|
||||||
|
Test ohne Orca:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_senden.py C:\pfad\datei.gcode
|
||||||
|
```
|
||||||
|
|
||||||
|
## Störungen
|
||||||
|
|
||||||
|
| Symptom | Check |
|
||||||
|
| --- | --- |
|
||||||
|
| Anycubic-Dialog trotz Grotti | Du hast Remote Print / Aufgabe starten genutzt → nur **G-code exportieren** |
|
||||||
|
| Meldungsfenster „Config fehlt“ | `data/orca-senden.json` anlegen |
|
||||||
|
| „Grotti nicht erreichbar“ | Server läuft? `url` stimmt? |
|
||||||
|
| „Token fehlt oder stimmt nicht“ | Token neu anlegen, in der JSON eintragen |
|
||||||
|
| App-Fenster will Login | Einmal anmelden (Session 30 Tage). Profil: `data/grotti-app-profile`. Zweites Chrome-Fenster schließen, nur eines offen lassen. Grotti neu starten nach Update. |
|
||||||
|
| Browser-Tab statt Fenster | In `orca-senden.json` `"browser": "app"` setzen (oder Chrome/Edge fehlt) |
|
||||||
|
| Zwei Fenster / Tab hängt | Debounce (~25 s); erneuter Export aktualisiert den offenen Tab. 3D-Pfad lädt verzögert |
|
||||||
|
| ACE zeigt T0/T1 statt T1/T10 | Seite neu laden oder **Ordner neu lesen**; Deep-Link zieht `paint_index` nach |
|
||||||
|
| Skript läuft nicht | Export wirklich speichern; Zeile im **Prozessprofil**; Python-Pfad prüfen |
|
||||||
|
|
||||||
|
## Was Grotti nicht kann
|
||||||
|
|
||||||
|
- Den **Anycubic-Cloud-/Aufgabe-Dialog** unterdrücken (liegt im Slicer, nicht im Skript).
|
||||||
|
- Sich in Anycubics Reiter **Arbeitsfläche** einbetten. In **Orca** dagegen: Device-UI-URL → Grotti unter **Gerät** (siehe oben).
|
||||||
|
|
||||||
|
## Weiter
|
||||||
|
|
||||||
|
- [Dateien senden](Anleitung-Dateien.md)
|
||||||
|
- [ACE und Spulen](Anleitung-ACE.md)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Projekte
|
||||||
|
|
||||||
|
Stand **18.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.
|
||||||
|
|
||||||
|
G-code und 3MF in diesem Ordner (oder dorthin [verschoben](Anleitung-Dateien.md)) werden dem Projekt zugeordnet und erscheinen in der Druck-Bibliothek.
|
||||||
|
|
||||||
|
## Weitere Dateien
|
||||||
|
|
||||||
|
Im Projekt-Detail: Upload von STEP, STL, PDF, SolidWorks usw. — liegen im selben Ordner, erscheinen **nicht** in der Druck-Bibliothek. Download und Löschen pro Datei.
|
||||||
|
|
||||||
|
## 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)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Störungen
|
||||||
|
|
||||||
|
Stand **15.10.2026**. Was tun, wenn etwas hängt. Kurzfassung auch auf der [Doku-Startseite](/doku#haengt).
|
||||||
|
|
||||||
|
## Auftrag startet nicht
|
||||||
|
|
||||||
|
| Anzeige | Maßnahme |
|
||||||
|
| --- | --- |
|
||||||
|
| **Platte belegt** | Teil runter, dann **Platte geräumt** (Karte oder Sidebar → Aufgaben). |
|
||||||
|
| Drucker **gesperrt** | Unter Einstellungen entsperren. |
|
||||||
|
| Automatik aus | Einstellungen → Automatik-Warteschlange an. |
|
||||||
|
| Falsche Maschine | Andere Datei oder anderen Drucker wählen. |
|
||||||
|
|
||||||
|
## Fehlerzustand auf dem Drucker
|
||||||
|
|
||||||
|
Auf der Karte **Fehler löschen** (Zwei-Klick). Grotti räumt den hängenden Fehler und schaltet die Heizung aus. Ein Neustart der Firmware über Grotti ist absichtlich nicht möglich — bei Bedarf Display-Neustart oder Strom aus/an.
|
||||||
|
|
||||||
|
Hängende Fehler vor einem Start räumt Grotti oft selbst, wenn kein Druck aktiv ist.
|
||||||
|
|
||||||
|
## Ablauf gestoppt
|
||||||
|
|
||||||
|
1. Grund in Sidebar / Karte / Aktivität lesen (Schieben, Tasten, Timeout, …).
|
||||||
|
2. Mechanisch klären (Kiste, Teil, Platte).
|
||||||
|
3. **Weiter** ab dem fehlgeschlagenen Schritt — oder Verwerfen und neu einreihen.
|
||||||
|
|
||||||
|
## Start scheitert nach Abbruch
|
||||||
|
|
||||||
|
Manchmal bleibt nach einem Abbruch ein alter Auftrag auf dem Drucker hängen. Der nächste Start scheitert dann oft mit einer Fehlermeldung (z. B. Code **10101**).
|
||||||
|
|
||||||
|
Was tun: Abbruch über Grotti (nicht nur am Display), auf der Wand den Task-Hinweis prüfen, erneut starten. Bleibt es hängen: Strom aus und wieder an.
|
||||||
|
|
||||||
|
## ACE / „cannot parse“
|
||||||
|
|
||||||
|
ACE-Zuordnung unter Dateien → Optionen prüfen. Spulen am richtigen Platz. Siehe [ACE und Spulen](Anleitung-ACE.md).
|
||||||
|
|
||||||
|
## Offline
|
||||||
|
|
||||||
|
Strom, WLAN und Erreichbarkeit des Druckers prüfen. Grotti meldet „wieder erreichbar“ unter Aktivität und ggf. Telegram.
|
||||||
|
|
||||||
|
## E-Stop
|
||||||
|
|
||||||
|
Im Cockpit (nur bei aktivem Druck/Heizen). Danach oft **Strom aus und wieder an**. Firmware-Neustart über Grotti ist absichtlich gesperrt.
|
||||||
|
|
||||||
|
## Upload hängt bei großen Dateien
|
||||||
|
|
||||||
|
Oft noch Verarbeitung auf dem Drucker. In der Sidebar unter **Senden** den Fortschritt beobachten. Nach einem Grotti-Update den Dienst einmal neu starten.
|
||||||
|
|
||||||
|
## „gcode … not found“ (10115)
|
||||||
|
|
||||||
|
Oft Dateinamen mit Nicht-ASCII-Zeichen (z. B. Chinesisch). Grotti normalisiert den Namen beim Senden auf ASCII. Nochmal senden; in der Bibliothek darf der Originalname bleiben.
|
||||||
|
|
||||||
|
## Drucker „heruntergefahren“ / 10111 / 506 nach Upload
|
||||||
|
|
||||||
|
Oft **Einfarbig**-G-code mit `; filament_colour_type` (Orca) — gklib stürzt ab. Grotti entfernt die Zeile beim Senden nur bei Einfarbig. Multicolor bleibt unverändert. Betroffene Drucker: Display-Reboot oder Strom aus/an, dann in Grotti ggf. **Fehler löschen**.
|
||||||
|
|
||||||
|
## Wo nachschauen
|
||||||
|
|
||||||
|
- Sidebar: Alerts, Läuft, Wartet, Senden
|
||||||
|
- **Aktivität** — Zeitstrahl mit Filtern
|
||||||
|
|
||||||
|
## Weiter
|
||||||
|
|
||||||
|
- [Druckerwand](Anleitung-Wand.md)
|
||||||
|
- [Warteschlange](Anleitung-Warteschlange.md)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Druckerwand
|
||||||
|
|
||||||
|
Stand **18.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Karte
|
||||||
|
|
||||||
|
Pro Drucker siehst du:
|
||||||
|
|
||||||
|
- Zustand (Chip), Fertig-Uhrzeit, Fortschrittsbalken
|
||||||
|
- Kleines Kamerabild (Snapshot ca. alle 3 s), wenn eine Webcam erreichbar ist — Klick öffnet das Cockpit
|
||||||
|
- Restzeit, Schicht, Laufzeit
|
||||||
|
- Temperaturen Düse/Bett (mit Ziel)
|
||||||
|
- Dateiname, bei ACE oft Farb-Swatches
|
||||||
|
- Bei Ablauf: Schrittleiste und Abbrechen
|
||||||
|
|
||||||
|
Aktionen auf der Karte brauchen oft **zwei Klicks** (erster Klick „scharf“, zweiter bestätigt):
|
||||||
|
|
||||||
|
| Aktion | Wann |
|
||||||
|
| --- | --- |
|
||||||
|
| **Pause** / **Weiter** | Während des Drucks; nach Materialwechsel Spule in Grotti an den Drucker hängen, dann **Weiter** |
|
||||||
|
| **Abbrechen** | Druck oder Pause |
|
||||||
|
| **Fehler löschen** | Zustand Fehler — räumt den hängenden Fehler, Heizung aus |
|
||||||
|
| **Platte geräumt** | Nach Fertig, wenn die Platte wieder frei ist |
|
||||||
|
| **Weiter** (Ablauf) | Gestoppter Ablauf — ab dem fehlgeschlagenen Schritt, wenn die Kiste steht |
|
||||||
|
|
||||||
|
Während eines Abräum-Ablaufs blendet Grotti „Platte geräumt“ aus — der Ablauf räumt selbst.
|
||||||
|
|
||||||
|
## Cockpit
|
||||||
|
|
||||||
|
URL mit Hash: `/#<host>` (z. B. nach Klick auf die Karte).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Inhalt:
|
||||||
|
|
||||||
|
- **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg. Modus Snapshot/Stream unter Einstellungen → Dieser Browser (gilt nur im Cockpit; die Wand-Thumbs sind immer Snapshot)
|
||||||
|
- Status, Temps inkl. Bauraum wo verfügbar, Lüfter
|
||||||
|
- **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole. Mit ACE zusätzlich Trockner (Temp, Dauer, Start/Stopp) und pro bestücktem Platz **an den Drucker** (Sorte und Farbe aus der Grotti-Spule)
|
||||||
|
- **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an
|
||||||
|
- **Verlauf** Temperatur und ED des aktuellen oder letzten Drucks; Link zum Druckbericht (dort Kosten: Filament + Grundlast/Düse/Bett)
|
||||||
|
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
|
||||||
|
- **Bettnetz**-Heatmap, wenn vorhanden
|
||||||
|
|
||||||
|
## Tipps
|
||||||
|
|
||||||
|
- Gesperrte Drucker (Einstellungen) starten keine neuen „wenn frei“-Jobs.
|
||||||
|
- Live-Daten kommen mit wenigen Sekunden Verzögerung.
|
||||||
|
- Ein Task-Hinweis auf der Karte zeigt, wenn auf dem Drucker noch ein alter Auftrag hängt — siehe [Störungen](Anleitung-Stoerungen.md).
|
||||||
|
|
||||||
|
## Weiter
|
||||||
|
|
||||||
|
- [Dateien senden](Anleitung-Dateien.md)
|
||||||
|
- [Warteschlange und Ablauf](Anleitung-Warteschlange.md)
|
||||||
|
- [Störungen](Anleitung-Stoerungen.md)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Warteschlange und Ablauf
|
||||||
|
|
||||||
|
Stand **18.10.2026**. Aufträge in der Warteschlange (Dieser Drucker / Nächster passender), Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Seite Warteschlange
|
||||||
|
|
||||||
|
Filter: **Läuft** / **Wartet** / **Fehler** / **Historie** (und „Alle aktiv“).
|
||||||
|
|
||||||
|
Pro Auftrag:
|
||||||
|
|
||||||
|
- Priorität hoch/runter
|
||||||
|
- **Abbrechen**
|
||||||
|
- Bei gestopptem Ablauf: **Fortsetzen** (ab dem fehlgeschlagenen Schritt)
|
||||||
|
|
||||||
|
Unter **Läuft** siehst du auch Direktstarts ohne Queue-Zeile (Link zur Wand).
|
||||||
|
|
||||||
|
Dieselbe Information steht kompakt in der Sidebar (**Läuft jetzt** / **Wartet**). Unter **Läuft jetzt** eine Zeile pro Drucker; Klick öffnet diese Seite, × bricht ab.
|
||||||
|
|
||||||
|
Reicht der Spulenrest nicht für wartende Jobs, erscheint oben ein **Fehlmenge**-Hinweis (Details unter Material → Bedarf) und in der Sidebar unter **Bedarf**.
|
||||||
|
|
||||||
|
## Wann startet ein wartender Auftrag?
|
||||||
|
|
||||||
|
Sobald der Drucker bereit ist (`standby` / fertig / abgebrochen), die **Platte frei** ist und der Drucker nicht gesperrt. Automatik aus (`auto_queue`) oder gesperrt → keine neuen Starts; laufende Abläufe laufen weiter. Mit **Start-Zeitfenster** in den Einstellungen nur innerhalb von Von/Bis.
|
||||||
|
|
||||||
|
## Ablauf (nur Kobra 3)
|
||||||
|
|
||||||
|
Unter **Dateien → Ablauf** eine Kette bauen und einreihen:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Drucken → Abkühlen → Schieben → Tasten → (Wiederholung)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Schritt | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| **Abkühlen** | Wartet, bis Bett und Düse unter der Grenze sind. PLA löst sich oft von selbst. |
|
||||||
|
| **Schieben** | Extruder schiebt das Teil von hinten nach vorn in die Kiste. |
|
||||||
|
| **Tasten** | LeviQ-Netz tippt; Maß aus `Result is z=`. Über 3 mm stoppt den Ablauf (Default; enger geht unter Einstellungen/Ablauf). |
|
||||||
|
|
||||||
|
Aktueller Schritt: auf der Druckerkarte und in der Sidebar. Abbrechen an beiden Stellen. Jeder Schritt landet unter **Aktivität**.
|
||||||
|
|
||||||
|
**Schieben testen** und „Bett ist leer“: im Ablauf-Reiter, bevor du unbeaufsichtigt läuft.
|
||||||
|
|
||||||
|
## Gestoppter Ablauf
|
||||||
|
|
||||||
|
1. Ursache beheben (Kiste voll, Teil hängt, Tasten zu hoch, …).
|
||||||
|
2. Auf der Karte oder in der Sidebar **Weiter** — nicht nur neu einreihen.
|
||||||
|
3. Oder verwerfen / abbrechen und später neu starten.
|
||||||
|
|
||||||
|
## Weiter
|
||||||
|
|
||||||
|
- [Druckerwand](Anleitung-Wand.md)
|
||||||
|
- [Störungen](Anleitung-Stoerungen.md)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Wartung
|
||||||
|
|
||||||
|
Stand **18.10.2026**. Druckstunden, Filament-Meter und Verschleißteile je Drucker.
|
||||||
|
|
||||||
|
## Seite Wartung
|
||||||
|
|
||||||
|
Unter **Wartung** siehst du je Drucker die Summe aus dem Protokoll (Stunden, Meter, Starts). Teile mit Soll-Lebensdauer (Stunden oder Meter) und Einbaudatum; bei Reset wird der Zählerstand protokolliert.
|
||||||
|
|
||||||
|
## Weiter
|
||||||
|
|
||||||
|
- [Einstellungen](Anleitung-Einstellungen.md)
|
||||||
|
- [Statistik](Anleitung-Oberflaeche.md)
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Chargen, Etiketten und 3D-Viewer
|
|
||||||
|
|
||||||
Stand 14.10.2026.
|
|
||||||
|
|
||||||
## Spulen-Chargen
|
|
||||||
|
|
||||||
Jede Spule in Grotti bekommt automatisch eine `chargen_nr` der Form `S-YYYYMMDD-0001`. Derselbe Wert steht in `code` und auf dem Etikett. Optional: `hersteller_charge` vom Rollenetikett.
|
|
||||||
|
|
||||||
Scannen: auf Material den Code tippen oder per USB-Handscanner (Tastatur + Enter). Lookup `GET /api/v1/spulen/by-code/<code>`. QR auf dem Etikett öffnet `/material?spool=<id>`.
|
|
||||||
|
|
||||||
Etikett: `/etikett/<id>`, Druck über den Browser. QR per qrcode.js (CDN). Basis-URL für den QR kommt aus Einstellungen → **FQDN** (`config.json` Key `fqdn`); leer = Host der aktuellen Anfrage. Beispiel: `https://grotti.example.de/material?spool=12`.
|
|
||||||
|
|
||||||
## 3D-Pfad
|
|
||||||
|
|
||||||
`GET /api/v1/dateien/<id>/pfad` liefert einen gedünnten Toolpath (bis ca. 120 000 Punkte, plus `layer_breaks`, Cache-`version` 5) aus dem lokalen G-code. Die Dateien-Seite und das Cockpit zeichnen ihn mit Three.js.
|
|
||||||
|
|
||||||
Darstellung: nur Extrusionswege. Preview = orange. Live: gedruckt leicht verdickt grün, offen grau; Schnitt nach `progress`. Parser akzeptiert `E.39`/`Z.6`. Bett: Gitter 10 mm, mm-Sprites, Legende.
|
|
||||||
|
|
||||||
**Exclude-Object** (nur Klipper/Moonraker): Status aus `exclude_object`. Polygone im Viewer; Liste unter dem Pfad; Zwei-Klick → `POST .../steuern` Action `exclude_object` mit `confirm`. Fehlt Modul/DEFINEs → Panel aus. BambuLab später eigener Adapter.
|
|
||||||
|
|
||||||
Cockpit-Live: Kamera-Frames werden unsichtbar vorgeladen (letztes Bild bleibt), `no-cam`-Layout nur ohne Kamera-URL. 3D-Orbit bleibt beim 5‑s-Poll erhalten (`setPath` nur beim ersten Laden / Dateiwechsel).
|
|
||||||
|
|
||||||
Auf der Wand: Caption `Dateiname · % · Schicht a/b`, sonst „Datei nicht in Grotti“.
|
|
||||||
|
|
||||||
Bedienung: Orbit / Zoom / Schwenken / Doppelklick-Reset; Tippen auf Polygon wählt das Objekt.
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# Grotti: Alltag und Oberfläche
|
|
||||||
|
|
||||||
Stand **14.10.2026**, v1.4.0.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
- Dunkles UI mit Orange-Akzent, Lucide-ähnliche Stroke-Icons (SVG-Sprite in `layout.html`).
|
|
||||||
- Kurze Seitenuntertitel; Technik-/Config-Hinweise liegen in der Doku, nicht als Fließtext in den Formularen.
|
|
||||||
- Einheitliche Seitenköpfe, Toolbar-Filter, kompakte Tabellen, Formularfelder mit kleinen Labels, Empty-States mit Icon.
|
|
||||||
- **Anzeige pro Browser** (Einstellungen → Dieser Browser): `Desktop` oder `Regal (Touch)` in `localStorage grotti-display`. Shortcut `?shelf=1` / `?shelf=0`. Regal: größere Controls, kompakte Nav (Drucker/Kameras/Warteschlange), Sidebar default eingeklappt.
|
|
||||||
- Seitenmenü einklappbar (Icon-Rail, `grotti-nav-collapsed`). Unter 760 px weiter Bottom-Nav.
|
|
||||||
|
|
||||||
## Seitenleiste
|
|
||||||
|
|
||||||
- Hauptmenü: Drucker, Dateien, Kameras, Aktivität, Warteschlange, Protokoll, Material, Projekte. Darunter Einstellungen, Ideen, Doku (mit Icons); Fuß **© by dominic@gundel.dev** und Version (Changelog).
|
|
||||||
- Live-Leiste, eigene Scrollfläche, Boxen auf- und zuklappbar (Zustand in `localStorage` `grotti-live-closed`):
|
|
||||||
- **Läuft jetzt**: laufende Aufträge und Abläufe mit Schrittleiste, Durchlauf `n/m`, aktueller Phase und ×-Abbrechen; auch Direktstarts ohne Queue.
|
|
||||||
- **Wartet**: wartende Aufträge mit Ziel, Priorität hoch/runter, Abbrechen; die letzten Fehler mit Fortsetzen/ausblenden.
|
|
||||||
- **Aufgaben**: Platte belegt ohne Ablauf — „geräumt“ per Zwei-Klick.
|
|
||||||
- **Senden**: laufende Uploads mit Fortschritt.
|
|
||||||
- **Alerts**: ungelesene wichtige Ereignisse, „gelesen“, Link auf Aktivität.
|
|
||||||
- Aktualisierung: Senden 2 s, Queue 4 s, Aufgaben/Alerts 8 s.
|
|
||||||
|
|
||||||
## Wand
|
|
||||||
|
|
||||||
- Karte je Drucker: Zustandschip, Fertig-Uhrzeit groß, Fortschrittsbalken, drei Kacheln (Rest, Schicht, Läuft), Temperaturbalken Düse/Bett mit Ziel, Datei. Klick auf die Karte öffnet das Cockpit. Pause, Abbrechen, Fehler löschen und Platte geräumt bleiben auf der Karte (Zwei-Klick).
|
|
||||||
- Laufender Ablauf als Streifen mit Schrittleiste und Abbrechen. Wartende Aufträge für diesen Drucker als Hinweis.
|
|
||||||
- Streifen „Fehler“ mit **Fehler löschen** und „Platte belegt“ mit **Platte geräumt** (während eines Ablaufs ausgeblendet, der räumt selbst).
|
|
||||||
- **Cockpit** (Hash `/#<host>`): Kamera (Frame-Vorladen; zugeklappt oder ohne Kamera → eine Spalte, kein leeres 16:10), Status, Temps inkl. Bauraum wenn vorhanden, Lüfter-Icons, Steuerung, 3D-Pfad (Exclude-Object), Bettnetz. E-Stop nur bei printing/paused/heating, Zwei-Klick. Live vom Watcher.
|
|
||||||
|
|
||||||
## Dateien und Senden
|
|
||||||
|
|
||||||
- Links Bibliothek: Suche, Filter Maschine/Projekt, Vorschaubild; Zeile zeigt Material, Zeit, Gewicht, Schichthöhe, Maschine, Projekt; Details per Info-Popup. Metadaten aus G-code-Kopf **und** Footer. Hochladen / Ordner neu lesen.
|
|
||||||
- Rechts der Flow: Datei → Drucker (Live-Zustand, eingelegte Rolle) → Wann (Jetzt / Wenn frei / Nächster freier) oder Reiter Ablauf.
|
|
||||||
- Kein freies Spulen-Dropdown: Abzug nimmt die Rolle(n) am Ziel-Drucker zur Startzeit. ACE: Zuordnung Datei-Farbe → Platz. Warnung bei Material-Mismatch oder zu wenig Rest.
|
|
||||||
- Startknopf mit Zwei-Klick bei „Jetzt“. Auswahl in `localStorage` `grotti-send-v1`.
|
|
||||||
|
|
||||||
## Material
|
|
||||||
|
|
||||||
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf.
|
|
||||||
- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal.
|
|
||||||
- Sorten: Profile (Dichte, mm³/s, Preis, Maschinen). Anlegen/ändern/löschen; „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
|
||||||
|
|
||||||
## Warteschlange
|
|
||||||
|
|
||||||
- Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen.
|
|
||||||
- Unter **Läuft** / **Alle aktiv** auch Direktstarts aus `/laeuft` (ohne Queue-Zeile), mit Fortschritt und Link zur Wand.
|
|
||||||
|
|
||||||
## Aktivität
|
|
||||||
|
|
||||||
- Zeitstrahl nach Tagen. Filter in der URL. Ungelesene Alerts markiert.
|
|
||||||
|
|
||||||
## Kameras und Handy
|
|
||||||
|
|
||||||
- Kamerakacheln mit Live-Zustand (`live=1`). Snapshot-Cache 2 s. Unter 760 px: Schnellmenü Drucker / Dateien / Aktivität.
|
|
||||||
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 194 KiB |
@@ -0,0 +1,47 @@
|
|||||||
|
# Grotti — Community-Vorstellung
|
||||||
|
|
||||||
|
Screenshots aus dem laufenden Betrieb (v3.4.0, Okt. 2026). Zielgruppe: mittlere bis große Printfarmen.
|
||||||
|
|
||||||
|
Logo und Repo-Banner: [`docs/brand/`](../brand/). Haupt-README: [`../../README.md`](../../README.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kurztext (zum Posten)
|
||||||
|
|
||||||
|
**Grotti** ist ein Farm-Manager für Moonraker/Klipper-Drucker — gebaut für den Alltag mit mehreren Maschinen, nicht für den Einzelplatz.
|
||||||
|
|
||||||
|
Statt zehn Tabs und Excel: eine Wand, eine Warteschlange, Material und Abräumen an einem Ort. Orca/AnycubicSlicerNext schicken den G-code per Nachbearbeitungsskript rein; Grotti wählt den nächsten freien Drucker (inkl. ACE-Farbe/Rest), startet im Zeitfenster und meldet, wenn die Platte geräumt werden muss oder Spule fehlt.
|
||||||
|
|
||||||
|
**Was schon geht**
|
||||||
|
|
||||||
|
- **Druckerwand + Cockpit** — Live-Status, Temps, Pause/Cancel/E-Stop, Farm-ETA („Alles fertig um …“)
|
||||||
|
- **Jetzt-Liste** — Platte räumen, Materialbedarf, Wartung, überfällige Aufträge als To-do oben
|
||||||
|
- **Warteschlange** — Priorität, Termine, Auto-Retry, Routing „nächster freier“ mit Material-Match
|
||||||
|
- **Kobra-3-Ablauf** — Druck → Abkühlen → Schieben → Tasten (unbeaufsichtigte Serie)
|
||||||
|
- **Material** — Spulen am Drucker/Regal, Chargen, Restgewicht, Fehlmenge aus der Queue
|
||||||
|
- **Aufträge** — Kunden, Positionen, Einreihen, Lieferschein, Stück-Fortschritt aus dem Abräumen
|
||||||
|
- **Kosten & Statistik** — Material + Strom (auch HT/NT), €/Teil, Yield, Ausschussgründe
|
||||||
|
- **Wartung** — Laufstunden/Filamentmeter, Verschleißteile mit Intervallen
|
||||||
|
- **Betrieb** — Auth (Session/API-Token), Telegram/ntfy, PWA/Regal-Touch, Ctrl+K-Suche
|
||||||
|
|
||||||
|
Stack: Python, PostgreSQL, Moonraker. UI auf Deutsch. Self-hosted.
|
||||||
|
|
||||||
|
Fragen und Feedback willkommen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| [01-druckerwand.png](01-druckerwand.png) | Farm-Übersicht, gruppiert nach Modell |
|
||||||
|
| [06-jetzt.png](06-jetzt.png) | Jetzt-Popover: Abräumen + Materialbedarf |
|
||||||
|
| [07-cockpit.png](07-cockpit.png) | Einzeldrucker mit Job, Spule, Steuerung |
|
||||||
|
| [02-warteschlange.png](02-warteschlange.png) | Queue mit Status und Fehlmenge |
|
||||||
|
| [09-dateien.png](09-dateien.png) | Bibliothek + Senden an mehrere Drucker |
|
||||||
|
| [03-material.png](03-material.png) | Spulenbestand und Bedarf aus der Queue |
|
||||||
|
| [04-auftraege.png](04-auftraege.png) | Kundenaufträge und Positionen |
|
||||||
|
| [05-statistik.png](05-statistik.png) | KPIs: Läufe, Kosten, Erfolg, Schätztreue |
|
||||||
|
| [08-wartung.png](08-wartung.png) | Laufzeit und Filament je Maschine |
|
||||||
|
|
||||||
|
Empfohlene Reihenfolge im Post: Wand → Jetzt → Warteschlange → Material → Statistik.
|
||||||
@@ -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 bei Druckende/Abbruch/Fehler (nicht schon beim Start). Solange gesetzt, startet „wenn frei“ den nächsten Job nicht.
|
- **Wand „Platte geräumt“:** Quittierung + optional Urteil. Tabelle `bed_status`, API `POST /api/v1/drucker/<host>/abraeumen` mit `confirm`. Fährt keine Bahn. Offener Live-Lauf wird als `completed` geschlossen (History von Moonraker oft ohne Endzeit). `needs_clear` setzt der Watcher beim Live-Übergang Druckende/Abbruch/Fehler (nicht schon beim Start; nicht blind beim ersten Blick nach Neustart). Solange gesetzt, startet „wenn frei“ den nächsten Job nicht.
|
||||||
- **Ablauf (Dateien):** Drucken → Abkühlen → Schieben → Tasten, dann der nächste Druck mit LeviQ. Eine Bestätigung beim Einreihen; Schieben läuft ohne zweite Rückfrage. Nach erfolgreichem Schieben ist die Platte automatisch quittiert.
|
- **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.
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ Unter **Dateien** erscheint die Karte **Ablauf**, sobald mindestens eine Kobra 3
|
|||||||
- **Drucken** — Datei, Bettnetz (LeviQ) standardmäßig an
|
- **Drucken** — Datei, Bettnetz (LeviQ) standardmäßig an
|
||||||
- **Abkühlen** — Heizung aus, Lüfter an, Düse in die Bettmitte, warten bis Bett/Düse unter den Grenzen
|
- **Abkühlen** — Heizung aus, Lüfter an, Düse in die Bettmitte, warten bis Bett/Düse unter den Grenzen
|
||||||
- **Schieben** — Bahn mit einstellbaren Zahlen, danach Lüfter aus
|
- **Schieben** — Bahn mit einstellbaren Zahlen, danach Lüfter aus
|
||||||
- **Tasten** — kein `G28 Z`. **Mit aktivem LeviQ-Netz** (kein `BED_MESH_CLEAR`): `Result` = Höhe über der lokalen Platte. Bezugspunkt + Tastpunkt (x/y) einstellbar. Maß: letztes `Result is z=` aus dem Store. Über 1 mm = Restteil.
|
- **Tasten** — kein `G28 Z`. **Mit aktivem LeviQ-Netz** (kein `BED_MESH_CLEAR`): `Result` = Höhe über der lokalen Platte. Bezugspunkt + Tastpunkt (x/y) einstellbar. Maß: letztes `Result is z=` aus dem Store. Über `max_delta_mm` (Default 3 mm) = Restteil. Enge 1 mm/2,5 mm-Werte aus alten Abläufen werden auf 3 mm angehoben (kalt an der Düse sonst Fehlalarm).
|
||||||
|
|
||||||
Darunter **wie oft hintereinander**. Einreihen legt einen Eintrag in die Warteschlange „Wenn frei“. **Schieben testen** fährt die Bahn auf einer freien Kobra 3 (Bestätigung „Bett ist leer“). Werte merkt sich der Browser lokal.
|
Darunter **wie oft hintereinander**. Einreihen legt einen Eintrag in die Warteschlange „Wenn frei“. **Schieben testen** fährt die Bahn auf einer freien Kobra 3 (Bestätigung „Bett ist leer“). Werte merkt sich der Browser lokal.
|
||||||
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Anycubic-Stack: wie Druck, Abbruch und ACE wirklich laufen
|
||||||
|
|
||||||
|
Stand **15.10.2026**. Quellen: Anycubic [Klipper-go](https://github.com/ANYCUBIC-3D/Klipper-go), [K3-klipper-mcu](https://github.com/ANYCUBIC-3D/K3-klipper-mcu); Rinkhals `kobra.py`, `mmu_ace.py`, `mmu_ace_metadata.py`, Docs `firmware/mqtt.md` und `firmware/goklipper.md` (lokal unter `D:\Projekte\_ref\`). Nicht geraten — am Quellcode und an der Rinkhals-Doku gelesen.
|
||||||
|
|
||||||
|
## Schichten
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph host [PC]
|
||||||
|
Grotti[Grotti]
|
||||||
|
end
|
||||||
|
subgraph printer [Kobra 3 / S1]
|
||||||
|
Moonraker[Moonraker + kobra.py]
|
||||||
|
MmuAce[mmu_ace + mmu_ace_metadata]
|
||||||
|
Gkapi[gkapi MQTT Broker]
|
||||||
|
Gklib[gklib GoKlipper]
|
||||||
|
Ui[K3SysUi Touch]
|
||||||
|
Mcu[MCU Firmware]
|
||||||
|
AceHw[ACE Hardware]
|
||||||
|
end
|
||||||
|
Grotti -->|"HTTP :80 upload / print/start / cancel"| Moonraker
|
||||||
|
Grotti -->|"MQTTS :9883 print start/stop"| Gkapi
|
||||||
|
Grotti -->|"HTTP :18910 info/ctrl Credentials"| Gkapi
|
||||||
|
Moonraker -->|"bei print/start oft MQTT lokal :2883"| Gkapi
|
||||||
|
Moonraker --> Gklib
|
||||||
|
MmuAce --> Moonraker
|
||||||
|
Gkapi --> Gklib
|
||||||
|
Ui --> Gkapi
|
||||||
|
Gklib --> Mcu
|
||||||
|
Gklib --> AceHw
|
||||||
|
```
|
||||||
|
|
||||||
|
| Komponente | Offen? | Rolle |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **gklib** (GoKlipper) | teilweise ([Klipper-go](https://github.com/ANYCUBIC-3D/Klipper-go)) | Klipper-Host: Motion, Heater, `virtual_sdcard`, ACE-G-codes |
|
||||||
|
| **MCU** | teilweise ([K3-klipper-mcu](https://github.com/ANYCUBIC-3D/K3-klipper-mcu)) | Stepper/Sensoren |
|
||||||
|
| **gkapi** | nein (proprietär) | MQTT-Broker, Druck-Tasks, Cloud/LAN, Credentials `:18910` |
|
||||||
|
| **K3SysUi** | nein | Touch-UI |
|
||||||
|
| **Rinkhals Moonraker** | ja | Vanilla Moonraker + `kobra.py` + `mmu_ace*` |
|
||||||
|
|
||||||
|
Anycubic schreibt in den READMEs: proprietäre Features sind gekapselt. MQTT-Taskverwaltung und Cloud stecken nicht in Klipper-go.
|
||||||
|
|
||||||
|
## Zwei Welten beim Druckstart
|
||||||
|
|
||||||
|
1. **Klipper-Pfad** (Moonraker `POST /printer/print/start` → gklib `virtual_sdcard`): setzt `print_stats`, lädt G-code in gklib.
|
||||||
|
2. **Anycubic-Task** (MQTT `type: print`, `action: start` an Topic `…/slicer/printer/{modelId}/{deviceId}/print`): legt den Job an, den Display und Cloud kennen; steuert LeviQ/`auto_leveling`, ACE-Map in der Payload.
|
||||||
|
|
||||||
|
Rinkhals `kobra.py` `patch_mqtt_print` hängt den Moonraker-`print/start` oft auf MQTT um (`mqtt_print_file`), lokal `127.0.0.1:2883`. Grotti kann denselben Start von außen über **MQTTS `:9883`** mit Credentials aus `:18910` machen — dann mit eigener `task_settings` und `ams_settings` (siehe [Senden-Queue](Senden-Queue)).
|
||||||
|
|
||||||
|
### MQTT Start (belegt)
|
||||||
|
|
||||||
|
Aus Rinkhals-Doku `firmware/mqtt.md` und `kobra.py`:
|
||||||
|
|
||||||
|
- Publish: `anycubic/anycubicCloud/v1/slicer/printer/{modelId}/{deviceId}/print`
|
||||||
|
- Report: `…/printer/public/{modelId}/{deviceId}/print/report`
|
||||||
|
- Pflichtfelder Start: `filename`, `filetype` (1), `taskid`
|
||||||
|
- Optional: `ams_settings.ams_box_mapping`, `task_settings.auto_leveling` (0/1 Integer)
|
||||||
|
- Rinkhals und Grotti setzen `taskid` auf eine Zufallszahl; die Doc-Minimalbeispiel nutzt `"-1"` — beides kommt vor
|
||||||
|
|
||||||
|
### MQTT Stop (belegt) — warum 10101
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "print", "action": "stop", "data": { "taskid": "-1" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Moonraker `/printer/print/cancel` trifft nur gklib (`print_stats` → `cancelled`). Der Anycubic-Task in gkapi bleibt → nächster MQTT-Start mit **code 10101** („Print task already exists“ / chinesische Meldung). Rinkhals `mqtt_print_file` kennt 10101 und **retryt einmal**; Grotti sendet bei Abbruch Moonraker-cancel **und** MQTT-stop.
|
||||||
|
|
||||||
|
### Task-Hygiene (wie Rinkhals)
|
||||||
|
|
||||||
|
Vorbild: `kobra.py` `mqtt_print_file` + `firmware/mqtt.md`. **Kobra 3 und Kobra S1** — dieselbe gkapi-API (`:18910` / MQTTS `:9883`). Gemessen 15.10.2026: S1 `modelId` 20025 / `state: free`, K3 `modelId` 20024; `project: null` zählt nicht als busy. Abräumen-Ablauf bleibt nur Kobra 3.
|
||||||
|
|
||||||
|
| Schritt | Verhalten in Grotti |
|
||||||
|
| --- | --- |
|
||||||
|
| Vor MQTT-Start | `ensure_task_free`: `info/query`; wenn `state` nicht free oder `project` gesetzt → MQTT-stop, bis zu 3× pollen |
|
||||||
|
| Vor Moonraker-`print/start` | dieselbe Hygiene best-effort (Rinkhals wandelt Start oft intern zu MQTT um) |
|
||||||
|
| gkapi kurz weg | `print_info` → `None`, negativer Cache ~45 s; Start über Moonraker läuft weiter |
|
||||||
|
| Report **10101** | einmal cancel + 1,5 s Pause + erneuter Start |
|
||||||
|
| Report **10107** | klare Filament-Meldung |
|
||||||
|
| Live | Snapshot-Feld `anycubic_task` (`state`, `task_id`, `filename`) aus gecachtem `print_info` (TTL ~10 s); Wand zeigt die Zeile |
|
||||||
|
|
||||||
|
Codes und Retry: `grotti/anycubic_lan.py` (`message_for_code`, `ensure_task_free`, `start_print`).
|
||||||
|
|
||||||
|
### Report-Timeout
|
||||||
|
|
||||||
|
`kobra.py` wartet max. 30 s auf `print/report`. Nach Bettnetz/`auto_leveling` kann der Report später kommen, der Druck trotzdem laufen (`info/query` → `project.task_id`). Grotti prüft bei Timeout deshalb `print_info` (MQTT `info/query`).
|
||||||
|
|
||||||
|
## FIRMWARE_RESTART
|
||||||
|
|
||||||
|
In **Klipper-go** ist der Befehl implementiert wie bei Klipper:
|
||||||
|
|
||||||
|
- `Cmd_FIRMWARE_RESTART` → `Request_restart("firmware_restart")` (`gcode.go`)
|
||||||
|
- Nach dem Reactor-Ende: Event `project:firmware_restart` → MCU `_firmware_restart` (`k3c.go`, `Mcu.go`)
|
||||||
|
|
||||||
|
Auf dem Gerät **deadlockt gklib** trotzdem (beobachtet, Rinkhals #32): Log zeigt Annahme des Befehls, danach keine weitere Verarbeitung, Moonraker bleibt in `startup`. Rinkhals `patch_klipper_restart` **lehnt** `RESTART` / `FIRMWARE_RESTART` unter GoKlipper ab. Grotti ebenso. Config: Display-Reboot oder Strom. Hängenden Fehler: **Fehler löschen** (`SDCARD_RESET_FILE`), nicht Firmware-Restart.
|
||||||
|
|
||||||
|
Der Open-Source-Code erklärt den Intent des Befehls; die Deadlock-Ursache steckt vermutlich im ausgelieferten Binary / der Anbindung an gkapi — nicht im veröffentlichten Happy Path.
|
||||||
|
|
||||||
|
## ACE
|
||||||
|
|
||||||
|
### In gklib (Klipper-go `extras_ace.go`)
|
||||||
|
|
||||||
|
G-codes: `ACE_CHANGE_TOOL`, `ACE_FEED`, `ACE_RETRACT`, `ACE_SET_SLOT`, Trocknen, Endless Spool, … Kommunikation über Seriell (`extras_ace_commun.go`). `Get_status` liefert ACE-`info` + Endless-Spool-Flags — **nicht** das Moonraker-Objekt `mmu` mit `gate_color`.
|
||||||
|
|
||||||
|
### In Rinkhals Moonraker (`mmu_ace.py`)
|
||||||
|
|
||||||
|
- Emuliert Happy-Hare-ähnliches **`mmu`**-Objekt (`gate_color`, `gate_material`, …) für Clients
|
||||||
|
- Beim MQTT-Start: `register_print_data_patcher` → wenn keine `ams_settings` in der Payload, baut Identity-/TTG-Map aus den Gates und hängt `ams_settings` an
|
||||||
|
- Liest/schreibt Sidecar **`basename.acm`** neben dem G-code; ohne ACE kann sie nach `.acm.disabled` umbenannt werden
|
||||||
|
- Auto-Feed-Logik vor Start, wenn nichts im Toolhead ist (Issue #464)
|
||||||
|
|
||||||
|
### Upload-Metadaten (`mmu_ace_metadata.py`)
|
||||||
|
|
||||||
|
Beim Moonraker-Upload: ganze Datei lesen, ggf. umschreiben. Fingerprint `; processed by MmuAcePatcher`. Trailer `; referenced_tools = 0,2,…`. Ohne Trailer: gklib „skipping auto-feed (no trailer found)“. Grotti setzt Fingerprint + Trailer vor dem Upload, damit der teure Durchlauf entfällt ([Senden-Queue](Senden-Queue)).
|
||||||
|
|
||||||
|
### `.acm` vs MQTT-`ams_settings`
|
||||||
|
|
||||||
|
Zwei Kanäle für dieselbe Zuordnung:
|
||||||
|
|
||||||
|
| Kanal | Wann |
|
||||||
|
| --- | --- |
|
||||||
|
| Datei `name.gcode.acm` / `name.acm` auf dem Drucker | Stock/gklib liest Sidecar beim Start |
|
||||||
|
| MQTT-Feld `data.ams_settings` | gkapi / Rinkhals-Patcher legt Map in die Payload |
|
||||||
|
|
||||||
|
Ohne Map: Rinkhals füllt oft Identity T0→Platz0 … — deshalb Grotti bei eigener ACE-Zuordnung per MQTT mit expliziter Map startet und beide `.acm`-Varianten hochlädt ([ACE-Live](ACE-Live)).
|
||||||
|
|
||||||
|
## Was Grotti bewusst nutzt
|
||||||
|
|
||||||
|
| Aktion | Weg | Beleg |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Einfacher Start | Upload + Moonraker `print/start` → Rinkhals MQTT | `kobra.py` `patch_mqtt_print` |
|
||||||
|
| Start mit Bettnetz oder ACE-Map | Upload (+ `.acm`) + Grotti-MQTT `:9883` | `anycubic_lan.start_print`, mqtt.md |
|
||||||
|
| Abbruch | Moonraker cancel + MQTT stop `taskid:-1` | `mqtt_stop_print`, mqtt.md |
|
||||||
|
| Credentials | `:18910/info` + `/ctrl` AES | `anycubic_lan.credentials` |
|
||||||
|
| Slots anzeigen | Moonraker `mmu` (Rinkhals) | `mmu_ace.py` |
|
||||||
|
| Kein Firmware-Restart | abgelehnt | Rinkhals #32, `patch_klipper_restart` |
|
||||||
|
|
||||||
|
## Offene Grenzen
|
||||||
|
|
||||||
|
- Quellcode von **gkapi** (Task-State-Machine, exakte 10101-Bedingungen) ist nicht öffentlich.
|
||||||
|
- Deadlock in **FIRMWARE_RESTART** ist beobachtet und in Rinkhals abgefangen, Root Cause im Binary nicht freigelegt.
|
||||||
|
- Kobra X / K4-Stack ist ein anderes System (C++-Klipper), nicht GoKlipper — für unsere Drucker irrelevant.
|
||||||
|
|
||||||
|
## Siehe auch
|
||||||
|
|
||||||
|
- [Makros-Kobra](Makros-Kobra.md) — Nicht-tun, Vanilla-Alltag, ACE, LeviQ/Wipe je Druckertyp
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Chargen, Etiketten und 3D-Viewer
|
||||||
|
|
||||||
|
Stand 17.10.2026.
|
||||||
|
|
||||||
|
## Spulen-Chargen
|
||||||
|
|
||||||
|
Jede Spule in Grotti bekommt automatisch eine `chargen_nr` der Form `S-YYYYMMDD-0001`. Derselbe Wert steht in `code` und auf dem Etikett. Optional: `hersteller_charge` vom Rollenetikett.
|
||||||
|
|
||||||
|
Scannen: auf Material den Code tippen oder per USB-Handscanner (Tastatur + Enter). Lookup `GET /api/v1/spulen/by-code/<code>`. QR auf dem Etikett öffnet `/material?spool=<id>`.
|
||||||
|
|
||||||
|
Etikett: `/etikett/<id>`, Druck über den Browser. QR per lokalem `qrcode.min.js` (`web/static/`). Basis-URL für den QR kommt aus Einstellungen → **FQDN** (`config.json` Key `fqdn`); leer = Host der aktuellen Anfrage. Beispiel: `https://grotti.example.de/material?spool=12`.
|
||||||
|
|
||||||
|
## Druck-Chargen
|
||||||
|
|
||||||
|
`D-YYYYMMDD-0001` vergibt der Watcher am aktiven Lauf (History-Job, sonst Live). Die nächste Nummer zählt Läufe und schon vergebene Objekt-Etiketten, damit eine Charge nicht doppelt entsteht. `adopt_live_run` übernimmt die Nummer nur auf einen fertigen History-Lauf ohne eigene Charge. Objekt-Etiketten (`D-…-01`) wandern nur mit, wenn der Ziel-Lauf dieselbe Charge behält; sonst fallen sie mit dem Live-Lauf weg und werden unter der verbleibenden Charge neu vergeben.
|
||||||
|
|
||||||
|
Druck über `/etikett/lauf/<id>?mode=druck|objekt`. Auf dem Bogen: Charge, **Soll … g** (Slicer-Filamentmenge), Drucker, Material (Abzug oder Send/ACE/Lauf-Spule), QR 20 mm im 70‑mm-Bogen. Wand: Knopf **Etikett** bei Druck/Pause. Urteil Ausschuss ändert die Charge nicht — gedruckte Bögen verwerfen.
|
||||||
|
|
||||||
|
## 3D-Pfad
|
||||||
|
|
||||||
|
`GET /api/v1/dateien/<id>/pfad` liefert einen gedünnten Toolpath (bis ca. 220 000 Punkte, plus `layer_breaks`, Cache-`version` 8) aus dem lokalen G-code. Thinning behält ganze Z-Höhen dicht und lässt nur ganze Schichten weg (sonst gesprenkelte Wände). Die Dateien-Seite und das Cockpit zeichnen ihn mit Three.js.
|
||||||
|
|
||||||
|
Darstellung: nur Extrusionswege. Preview = orange. Live: gedruckt leicht verdickt grün, offen grau; Schnitt nach `progress`. Parser akzeptiert `E.39`/`Z.6`. Bett: Gitter 10 mm, mm-Sprites, Legende.
|
||||||
|
|
||||||
|
**Exclude-Object** (nur Klipper/Moonraker): Status aus `exclude_object`. Polygone im Viewer; Liste unter dem Pfad; Zwei-Klick → `POST .../steuern` Action `exclude_object` mit `confirm`. Fehlt Modul/DEFINEs → Panel aus. BambuLab später eigener Adapter.
|
||||||
|
|
||||||
|
Cockpit-Live: Kamera-Frames werden unsichtbar vorgeladen (letztes Bild bleibt), `no-cam`-Layout nur ohne Kamera-URL. 3D-Orbit bleibt beim 5‑s-Poll erhalten (`setPath` nur beim ersten Laden / Dateiwechsel).
|
||||||
|
|
||||||
|
Auf der Wand: Caption `Dateiname · % · Schicht a/b`, sonst „Datei nicht in Grotti“.
|
||||||
|
|
||||||
|
Bedienung: Orbit / Zoom / Schwenken / Doppelklick-Reset; Tippen auf Polygon wählt das Objekt.
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# Grotti-Dateien Metadaten
|
# Grotti-Dateien Metadaten
|
||||||
|
|
||||||
`stored_path` liegt nur in der DB und in Rohzeilen (`get_file_row`, `list_file_rows`). Die API-Antwort (`file_out` / `GET /api/v1/dateien`) enthält den Pfad bewusst nicht.
|
`stored_path` liegt nur in der DB und in Rohzeilen (`get_file_row`, `list_file_rows`). Die API-Antwort (`file_out` / `GET /api/v1/dateien`) enthält den Pfad bewusst nicht — stattdessen berechnet `folder` den Relativ-Elternordner unter `watch_dir` (Uploads → `Upload`).
|
||||||
|
|
||||||
|
Verschieben einer Watch-Datei: Dateisystem unter `watch_dir` + `UPDATE files.stored_path` über dieselbe ID (`library.move_file`), damit Projekt/Spule erhalten bleiben. Reiner Re-Scan nach manuellem Verschieben würde die ID verlieren.
|
||||||
|
|
||||||
`POST /api/v1/dateien/neu-lesen` liest Watch-Ordner neu und ruft `refresh_file_meta` auf Rohzeilen auf — nicht auf `list_files`, sonst `KeyError: 'stored_path'`.
|
`POST /api/v1/dateien/neu-lesen` liest Watch-Ordner neu und ruft `refresh_file_meta` auf Rohzeilen auf — nicht auf `list_files`, sonst `KeyError: 'stored_path'`.
|
||||||
|
|
||||||
@@ -12,7 +14,7 @@ In der Bibliothek gespeichert:
|
|||||||
|
|
||||||
| Feld | Bedeutung |
|
| 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` |
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Druckbericht und Statistik
|
||||||
|
|
||||||
|
Stand **17.10.2026**.
|
||||||
|
|
||||||
|
## Tabellen
|
||||||
|
|
||||||
|
- `runs`: zusätzlich `weight_planned_g`, `weight_used_g`, `weight_source`, `filament_used_mm`/`filament_total_mm`, `progress_last`, `estimated_s`, `chargen_nr`, `message`, `objects`
|
||||||
|
- `run_spools`: Abzug je Spule (`is_estimate` bei Mehrfarb)
|
||||||
|
- `run_segments`: Abschnitte bei Rollenwechsel (Einlegen während Druck/Pause)
|
||||||
|
- `run_samples`: Temperatur/Soll/ED (`nozzle_power`/`bed_power`) — alle ~30 s, beim Aufheizen ~10 s; Proben schon vor `printing`, wenn Targets gesetzt und Auftrag/Live-Lauf bekannt
|
||||||
|
- `run_labels`: Objekt-Chargen `D-…-01`
|
||||||
|
- `events.run_id`: optionaler Bezug zum Lauf
|
||||||
|
|
||||||
|
## Abzug
|
||||||
|
|
||||||
|
`grotti/usage.py` rechnet Ist-Gramm aus `filament_used` / `filament_total` × Soll. Abbruch ohne Extrusion: 0. Ohne Zähler: Fortschritt × Soll (Schätzwert). Fertig ohne Messgröße: Soll bleibt, Quelle `estimate`.
|
||||||
|
|
||||||
|
`apply_deduction` schreibt zuerst `run_spools`, dann `deducted`/`remaining_g` (auch unter 0); `ensure_run_chargen` oft schon während des Laufs. Ohne `run_spools` bleibt die Spulenakte leer.
|
||||||
|
|
||||||
|
## Live
|
||||||
|
|
||||||
|
`StateWatcher` hängt am offenen History-Job oder legt `live:…` an, schreibt Proben, vergibt `chargen_nr`, merkt Objektnamen. Vor neuem Start: `abandon_live_runs`. `sync_history` übernimmt Proben/Segmente (`adopt_live_run`, nicht auf ältere fertige Jobs derselben Datei). Die Live-Charge geht nur auf einen fertigen Lauf ohne Nummer über; Objekt-Etiketten nur zusammen mit dieser Charge. Sonst werden sie neu vergeben, statt eine fremde `D-…-01` zu blockieren.
|
||||||
|
|
||||||
|
## Statistik-Filter
|
||||||
|
|
||||||
|
`since` inklusiv, reines `until` als Datum (`YYYY-MM-DD`) inklusiv (intern Folgetag exklusiv). Presets in der UI setzen Von/Bis. CSV nutzt dieselben Query-Parameter.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Grotti: Alltag und Oberfläche
|
||||||
|
|
||||||
|
Stand **18.10.2026**, v3.3.0.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
- Dunkles UI mit Orange-Akzent; Light-Theme über dieselben Tokens (`html[data-theme]`).
|
||||||
|
- Schrift **Inter** (variable, lokal unter `web/static/fonts/`).
|
||||||
|
- Icons: **Lucide** als Sprite `web/static/icons.svg` (bauen mit `python scripts/build_icons.py`), IDs `i-<name>`.
|
||||||
|
- Komponenten: Toolbar, Card-Head, KPI-Karten, Chips (Zustände + Queue + Urteile), Dialog-Kopf/Fuß, Empty-States, Skeleton.
|
||||||
|
- Primär: `btn-primary` / `go-btn`; Danger: `btn-danger` / `warn`; Icon-Buttons mit `title`/`aria-label`.
|
||||||
|
- **Anzeige pro Browser**: Desktop oder Regal (Touch); Zeitzone; Nav einklappbar.
|
||||||
|
- PWA: `manifest.webmanifest` + Service-Worker (Shell-Cache inkl. Icons/Font).
|
||||||
|
- Live: SSE `/api/v1/stream` plus Poll-Fallback; Verbindungsbanner.
|
||||||
|
- Ctrl+K: globale Suche (`/api/v1/suche`); Knopf in der Topbar.
|
||||||
|
- Kamera: Snapshot (gecacht) oder MJPEG-Stream (`/api/v1/kamera/stream`), Browser-Einstellung `grotti-camera`.
|
||||||
|
|
||||||
|
## Seitenleiste und Topbar
|
||||||
|
|
||||||
|
- Sidebar: Abschnittslabels **Live** / **Rückschau** / **Verwalten** (ohne Live-Panel).
|
||||||
|
- Topbar-Live-Chips (**Jetzt** · Läufe · Senden · Meldungen) mit Popover; Farm-ETA nur bei mehreren Läufen.
|
||||||
|
- Mobile: Tab **Jetzt** (`/jetzt`) statt Aktivität in der unteren Leiste; Aktivität unter Mehr.
|
||||||
|
- Topbar: Suche, Einstellungen, Doku, Benutzer-Menü (Avatar → Abmelden, Version, Copyright).
|
||||||
|
- Mobile: Bottom-Nav Drucker · Dateien · Warteschlange · Aktivität · **Mehr** (Sheet inkl. Abmelden); Topbar ausgeblendet, Suche als Icon in der Brand-Zeile.
|
||||||
|
|
||||||
|
## Wand und Abräumen
|
||||||
|
|
||||||
|
- Karten + Cockpit; Technik-Fold mit Icons je Abschnitt.
|
||||||
|
- **Platte geräumt** öffnet Dialog: gut/Ausschuss, Grund, Stückzahlen.
|
||||||
|
|
||||||
|
## Aufträge und Wartung
|
||||||
|
|
||||||
|
- `/auftraege`: Kunden, Positionen, Einreihen, Lieferschein. Status als Chip.
|
||||||
|
- `/wartung`: Stunden/Meter und Verschleißteile.
|
||||||
|
|
||||||
|
## Dateien, Material, Statistik
|
||||||
|
|
||||||
|
- Bibliothek mit Toolbar/Suche; Senden-Schritte nummeriert.
|
||||||
|
- Material: Spulen/Sorten-Tabs, Filter-Toolbar.
|
||||||
|
- Statistik: KPI-Karten mit Icons.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Glossar: Moonraker, Mainsail und der Rest
|
||||||
|
|
||||||
|
Stand **15.10.2026**. Begriffe rund um Kobra 3 / S1, Rinkhals und Grotti. Architektur-Details: [Anycubic-Stack](Anycubic-Stack).
|
||||||
|
|
||||||
|
## Die Schichten in einem Satz
|
||||||
|
|
||||||
|
| Schicht | Rolle |
|
||||||
|
| --- | --- |
|
||||||
|
| **MCU-Firmware** | Läuft auf der Drucker-Platine: Stepper, Heizer, Sensoren. |
|
||||||
|
| **gklib (GoKlipper)** | „Gehirn“ auf dem Linux der Kobra — Anycubics Go-Port von Klipper. |
|
||||||
|
| **gkapi** | Anycubic-Dienst: MQTT, Cloud/LAN, Druck-Tasks, Touch-Anbindung. |
|
||||||
|
| **Moonraker** | HTTP/WebSocket-API vor gklib — das, was Apps und Grotti ansprechen. |
|
||||||
|
| **Mainsail / Fluidd** | Web-Oberflächen *auf* Moonraker (Live-Steuerung, Konsole). |
|
||||||
|
| **Grotti** | Eure lokale Farm-Seite: Queue, Wand, Material, Abräumen — spricht Moonraker (+ MQTT wo nötig). |
|
||||||
|
| **Rinkhals** | Community-Aufsatz auf der Stock-Firmware: bringt Moonraker, SSH, Apps. |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
UI[Mainsail / Fluidd / Grotti / Orca]
|
||||||
|
UI --> MR[Moonraker]
|
||||||
|
UI -.->|Start mit ACE/Bettnetz, Stop| MQTT[gkapi MQTT]
|
||||||
|
MR --> GK[gklib GoKlipper]
|
||||||
|
MQTT --> GK
|
||||||
|
Touch[K3SysUi Touch] --> MQTT
|
||||||
|
GK --> MCU[MCU]
|
||||||
|
GK --> ACE[ACE]
|
||||||
|
```
|
||||||
|
|
||||||
|
## A–Z
|
||||||
|
|
||||||
|
### ACE
|
||||||
|
Anycubic Color Engine — Multimaterial-Box (vier Plätze). Ansteuerung in gklib (`ACE_*`-G-codes); Zuordnung Farbe→Platz oft über Sidecar **`.acm`** und/oder MQTT **`ams_settings`**. In Moonraker erscheint das bei Rinkhals oft als Objekt **`mmu`** (`gate_color`, …).
|
||||||
|
|
||||||
|
### Fluidd
|
||||||
|
Web-UI für Klipper/Moonraker, Alternative zu Mainsail. Gleiche API, anderes Aussehen. Bei Rinkhals typisch Port **4408**.
|
||||||
|
|
||||||
|
### FIRMWARE_RESTART
|
||||||
|
Klipper-Befehl: Host + MCU neu laden. Auf **GoKlipper** deadlockt gklib oft (Rinkhals #32). Grotti und Rinkhals lehnen ihn ab → Display-Reboot oder Strom.
|
||||||
|
|
||||||
|
### gklib / GoKlipper
|
||||||
|
Anycubics Klipper-Host in Go (Open Source: [Klipper-go](https://github.com/ANYCUBIC-3D/Klipper-go)). Entspricht grob dem klassischen `klippy`. Dateiname auf dem Drucker: `gklib`.
|
||||||
|
|
||||||
|
### gkapi
|
||||||
|
Geschlossener Anycubic-Dienst neben gklib. Betreibt den lokalen MQTT-Broker, Druck-Tasks, Credentials (`:18910`), Anbindung Touch/Cloud. Hier sitzt der Task, der bei Abbruch **10101** verursacht, wenn nur Moonraker-cancel kommt.
|
||||||
|
|
||||||
|
### Grotti
|
||||||
|
Dieses Tool: lokale deutsche Seite für mehrere Moonraker-Drucker — Wand, Warteschlange „wenn frei“, Abräumen (Kobra 3), Material, Kosten, Telegram. Kein Slicer, keine Cloud.
|
||||||
|
|
||||||
|
### K3SysUi
|
||||||
|
Anycubic-Touch-Oberfläche (geschlossen). Rinkhals patcht sie, um das eigene Menü einzuhängen.
|
||||||
|
|
||||||
|
### Klipper
|
||||||
|
Open-Source-Firmware-Architektur: schwere Berechnung auf dem Host (Linux), MCU führt Bewegungen aus. Vanilla-Klipper ist Python (`klippy`); auf der Kobra 3 läuft die Go-Variante.
|
||||||
|
|
||||||
|
### Mainsail
|
||||||
|
Beliebte Web-UI für Moonraker: Status, Temp, G-code-Konsole, Dateien, Webcam. Bei Rinkhals oft Port **80** / **4409**. Fühlt sich „direkter“ an als Grotti, weil die Konsole offen ist.
|
||||||
|
|
||||||
|
### Moonraker
|
||||||
|
API-Server vor Klipper/gklib: HTTP und WebSocket. Upload, `print/start`, Pause, `gcode/script`, `objects/query`, Kamera-Proxy. **OctoPrint-Äquivalent** in der Klipper-Welt. Grotti und Mainsail sind Clients davon — Moonraker ist nicht die schöne Oberfläche selbst.
|
||||||
|
|
||||||
|
### MQTT (hier)
|
||||||
|
Protokoll zu **gkapi**, nicht zu Moonraker. Topics wie `…/slicer/printer/{modelId}/{deviceId}/print`. Genutzt für Druck **start/stop**, Info-Query, optional Temp/Speed/Licht. Ports: lokal oft **2883**, von außen TLS **9883**.
|
||||||
|
|
||||||
|
### OctoPrint
|
||||||
|
Klassische Host-Software für (meist) Marlin-Drucker: Web-UI, Plugins, oft G-code-**Streaming** Zeile für Zeile. Bei Klipper/Rinkhals ersetzt durch **Moonraker + Mainsail/Fluidd** (Datei komplett hochladen, Host spielt ab).
|
||||||
|
|
||||||
|
### Orca / AnycubicSlicerNext
|
||||||
|
Slicer auf dem PC. Schicken fertige `.gcode` per Moonraker (oder USB). Kein Teil der Drucker-Firmware.
|
||||||
|
|
||||||
|
### printer.cfg / printer.generated.cfg
|
||||||
|
Klipper-Konfiguration. Auf Anycubic oft generiert/neu geschrieben; Rinkhals warnt vor wildem Editieren (Boot-Risiko). Z-Offset u. a. in `printer_mutable.cfg`.
|
||||||
|
|
||||||
|
### Repetier-Server
|
||||||
|
Host-Software ähnlich OctoPrint (eigene UI, oft Streaming/direktes G-code). Gleicher Kontrast: auf der Kobra ist der Host **gklib**, die API **Moonraker**.
|
||||||
|
|
||||||
|
### Rinkhals
|
||||||
|
Community-Overlay auf Stock-Kobra-OS: Moonraker, Mainsail/Fluidd, SSH, App-Store, ohne die Stock-Firmware zu ersetzen. Installiert der Nutzer selbst (SWU). Docs: [rinkhals-community/Rinkhals](https://github.com/rinkhals-community/Rinkhals).
|
||||||
|
|
||||||
|
### SSH
|
||||||
|
Root-Shell auf dem Drucker-Linux (Rinkhals: Port 22, `root` / `rockchip`). Zum Debuggen, Logs, App-Properties — **nicht** der normale Weg für Drucksteuerung (das bleibt Moonraker).
|
||||||
|
|
||||||
|
### SWU
|
||||||
|
Anycubic-Update-Paket auf USB (Ordner `aGVscF9zb3Nf`). Darüber kommen Stock-Updates und Rinkhals/Installer.
|
||||||
|
|
||||||
|
### virtual_sdcard
|
||||||
|
Klipper-Modul: G-code liegt als Datei auf dem Host und wird von gklib abgespielt. Deshalb Upload + Start statt Streaming über WLAN.
|
||||||
|
|
||||||
|
### 10101
|
||||||
|
Anycubic-MQTT-Fehler: „Druck-Task existiert schon“. Typisch nach Abbruch nur über Moonraker, ohne MQTT `action: stop`.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# MCP-Server
|
||||||
|
|
||||||
|
Stand **15.10.2026**. Agenten steuern Grotti nur über die HTTP-API mit einem API-Token.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:GROTTI_URL = "http://127.0.0.1:8770"
|
||||||
|
$env:GROTTI_TOKEN = "grt_…" # unter Einstellungen → API-Tokens
|
||||||
|
$env:GROTTI_MCP_READ_ONLY = "1" # optional: nur lesen
|
||||||
|
.\.venv\Scripts\pip install mcp
|
||||||
|
.\.venv\Scripts\python.exe -m grotti.mcp_server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| Tool | Schreibend | Confirm |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `list_printers`, `get_printer`, `list_running`, `list_queue`, `list_events`, `list_files`, `list_spools`, `get_version` | nein | — |
|
||||||
|
| `pause_printer`, `resume_printer` | ja | — |
|
||||||
|
| `cancel_printer`, `clear_bed`, `enqueue_*` | ja | `confirm=true` |
|
||||||
|
| `steer_temp`, `steer_speed`, `steer_fan` | ja | — |
|
||||||
|
|
||||||
|
Nicht exponiert: freie G-code, Firmware-Restart, E-Stop, User/Token/Settings.
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Token mit Rolle Operator reicht für den Alltag; Admin-Token vermeiden.
|
||||||
|
- Read-only-Flag für vorsichtige Agents.
|
||||||
|
- Server ideal nur lokal; Grotti selbst hinter Login/TLS.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Interne Technik-Notizen
|
||||||
|
|
||||||
|
Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App.
|
||||||
|
|
||||||
|
Nutzer-Handbuch: `docs/Anleitung-*.md`.
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| [Makros-Kobra.md](Makros-Kobra.md) | G-code/Makros Kobra S1 und 3: Nicht-tun, Vanilla-Alltag, ACE, LeviQ/Wipe |
|
||||||
|
| [Anycubic-Stack.md](Anycubic-Stack.md) | GoKlipper, MQTT-Start/Stop, ACE-Kanäle |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Grotti: Senden und Start dauert
|
# Grotti: Senden und Start dauert
|
||||||
|
|
||||||
Stand **14.10.2026**. Spulen kommen vom Ziel-Drucker zur Startzeit (`ace_map` + eingelegte Rolle), nicht aus einem freien Dropdown. `sends` speichert `ace_map` und Bettnetz für „Nochmal“.
|
Stand **15.10.2026** (Stack-Quellen und Task-Hygiene in [Anycubic-Stack](Anycubic-Stack)). Spulen kommen vom Ziel-Drucker zur Startzeit (`ace_map` + eingelegte Rolle), nicht aus einem freien Dropdown. `sends` speichert `ace_map` und Bettnetz für „Nochmal“.
|
||||||
|
|
||||||
## Warum Sekunden / Minuten
|
## Warum Sekunden / Minuten
|
||||||
|
|
||||||
@@ -18,7 +18,9 @@ Ab 12.10.2026: Start legt Hintergrund-Aufträge an (`pending` → `running` →
|
|||||||
|
|
||||||
## Slice-Ordner / Upload
|
## Slice-Ordner / Upload
|
||||||
|
|
||||||
`watch_dir` in `config.json` zeigt auf den Slice-Ordner der Bibliothek. Ohne Ordner geht Hochladen über die Dateien-Seite trotzdem.
|
Slice-Ordner unter **Einstellungen** (persistiert als `watch_dir`). Ohne Ordner geht Hochladen über die Dateien-Seite trotzdem.
|
||||||
|
|
||||||
|
**Dateinamen:** Moonraker-Multipart nutzt nur noch einen ASCII-`filename` (`moonraker.safe_upload_name`). Früher standen Nicht-ASCII-Zeichen in `filename*=UTF-8''…`, während `filename=` Unterstriche bekam — gklib speicherte A, Start suchte B → **10115 not found**.
|
||||||
|
|
||||||
## ACE / `.acm`
|
## ACE / `.acm`
|
||||||
|
|
||||||
@@ -71,6 +73,10 @@ Credentials holt Grotti ohne SSH: `GET :18910/info` → signiertes `POST :18910/
|
|||||||
|
|
||||||
**Abbruch:** Grotti `abbrechen` = Moonraker `/printer/print/cancel` **plus** MQTT `action: stop` / `taskid: "-1"` (gleicher Topic wie Start). Nur Moonraker-cancel lässt den Anycubic-Task stehen → nächster MQTT-Start mit **10101**. 13.10.2026: Gridfinity auf da26 so hängen geblieben, per MQTT-stop freigemacht (`data.state: free`).
|
**Abbruch:** Grotti `abbrechen` = Moonraker `/printer/print/cancel` **plus** MQTT `action: stop` / `taskid: "-1"` (gleicher Topic wie Start). Nur Moonraker-cancel lässt den Anycubic-Task stehen → nächster MQTT-Start mit **10101**. 13.10.2026: Gridfinity auf da26 so hängen geblieben, per MQTT-stop freigemacht (`data.state: free`).
|
||||||
|
|
||||||
|
### Task-Hygiene vor dem Start (15.10.2026)
|
||||||
|
|
||||||
|
Vor jedem Start (MQTT und reiner Moonraker-Pfad) räumt Grotti einen hängenden gkapi-Task best-effort — **Kobra 3 und S1**: `print_info` → ggf. MQTT-stop → poll. Kommt trotzdem **10101**, einmal cancel + 1,5 s + Retry (wie Rinkhals). Ist gkapi kurz unerreichbar, läuft der Moonraker-Start trotzdem (negativer Cache ~45 s). Live zeigt die Wand `anycubic_task` aus demselben `info/query` (Cache ~10 s). Details: [Anycubic-Stack](Anycubic-Stack) Abschnitt Task-Hygiene.
|
||||||
|
|
||||||
**Start-Report verspätet:** 13.10.2026 ~21:35, A2 (`10.10.1.59`), Ablauf 2/5 direkt nach der Bettnetz-Prüfung. Innerhalb von 30 s kam kein `print/report`, der Druck lief trotzdem an. `info/query` (Topic `…/slicer/printer/{modelId}/{deviceId}/info`, Antwort auf `…/info/report`) zeigte `project.task_id` = Grotti-`taskid`, `state: auto_leveling`. Moonraker meldet in dieser Phase noch `print_stats.state: complete` vom Vorauftrag. Grotti prüft deshalb bei einem Start-Timeout bis zu dreimal `info/query`. Steht dort die eigene `taskid`, gilt der Start als erfolgreich (`anycubic_lan.print_info`).
|
**Start-Report verspätet:** 13.10.2026 ~21:35, A2 (`10.10.1.59`), Ablauf 2/5 direkt nach der Bettnetz-Prüfung. Innerhalb von 30 s kam kein `print/report`, der Druck lief trotzdem an. `info/query` (Topic `…/slicer/printer/{modelId}/{deviceId}/info`, Antwort auf `…/info/report`) zeigte `project.task_id` = Grotti-`taskid`, `state: auto_leveling`. Moonraker meldet in dieser Phase noch `print_stats.state: complete` vom Vorauftrag. Grotti prüft deshalb bei einem Start-Timeout bis zu dreimal `info/query`. Steht dort die eigene `taskid`, gilt der Start als erfolgreich (`anycubic_lan.print_info`).
|
||||||
|
|
||||||
Fallback ohne MQTT: global in Rinkhals Touch → Manage apps → 40-moonraker → Auto leveling (gilt dann für alle Moonraker-Starts inkl. Mainsail/Orca).
|
Fallback ohne MQTT: global in Rinkhals Touch → Manage apps → 40-moonraker → Auto leveling (gilt dann für alle Moonraker-Starts inkl. Mainsail/Orca).
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -24,13 +24,23 @@ from cryptography.hazmat.primitives import padding
|
|||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
|
||||||
CRED_TTL = 300
|
CRED_TTL = 300
|
||||||
|
INFO_TTL = 45
|
||||||
|
FAIL_TTL = 45
|
||||||
_CRED_CACHE = {}
|
_CRED_CACHE = {}
|
||||||
|
_INFO_CACHE = {}
|
||||||
|
_FAIL_CACHE = {}
|
||||||
|
|
||||||
|
# Anycubic MQTT-Report-Codes (Rinkhals kobra.py / mqtt.md)
|
||||||
|
# Gilt für Kobra 3 und Kobra S1 (gleiche gkapi-Ports :18910 / :9883).
|
||||||
|
CODE_TASK_ACTIVE = 10101
|
||||||
|
CODE_FILAMENT = 10107
|
||||||
|
|
||||||
|
|
||||||
class LanError(Exception):
|
class LanError(Exception):
|
||||||
def __init__(self, message):
|
def __init__(self, message, code=None):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.message = message
|
self.message = message
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
def _info(host):
|
def _info(host):
|
||||||
@@ -141,17 +151,103 @@ def normalize_ams(ams_settings):
|
|||||||
return {"use_ams": True, "ams_box_mapping": entries}
|
return {"use_ams": True, "ams_box_mapping": entries}
|
||||||
|
|
||||||
|
|
||||||
def _report_error(body, default_message):
|
def _parse_code(value):
|
||||||
code = body.get("code")
|
if value is None or value == "":
|
||||||
if code == 10107:
|
return None
|
||||||
return "Filament gerissen. Bitte neues laden. (code 10107)"
|
try:
|
||||||
if code == 10101:
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def message_for_code(code, default_message="Druckbefehl abgelehnt"):
|
||||||
|
"""MQTT-Report-Code → deutsche Meldung (für Tests und UI)."""
|
||||||
|
parsed = _parse_code(code)
|
||||||
|
if parsed == CODE_FILAMENT:
|
||||||
|
return "Filament gerissen oder leer. Bitte neues laden. (code 10107)"
|
||||||
|
if parsed == CODE_TASK_ACTIVE:
|
||||||
return (
|
return (
|
||||||
"Alter Druckauftrag noch aktiv (Anycubic-MQTT). "
|
"Alter Druckauftrag noch aktiv (Anycubic-Task). "
|
||||||
"Bitte zuerst abbrechen. (code 10101)"
|
"Wird geräumt und ggf. erneut gestartet. (code 10101)"
|
||||||
)
|
)
|
||||||
message = str(body.get("msg") or default_message)
|
message = str(default_message or "Druckbefehl abgelehnt")
|
||||||
return "%s%s" % (message, (" (code %s)" % code) if code else "")
|
if parsed is None:
|
||||||
|
return message
|
||||||
|
return "%s (code %s)" % (message, parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def _report_error(body, default_message):
|
||||||
|
code = _parse_code(body.get("code"))
|
||||||
|
raw = str(body.get("msg") or "").strip()
|
||||||
|
if code in (CODE_FILAMENT, CODE_TASK_ACTIVE):
|
||||||
|
text = message_for_code(code, default_message)
|
||||||
|
elif raw:
|
||||||
|
text = "%s%s" % (raw, (" (code %s)" % code) if code is not None else "")
|
||||||
|
else:
|
||||||
|
text = message_for_code(code, default_message)
|
||||||
|
return text, code
|
||||||
|
|
||||||
|
|
||||||
|
def _task_busy(info):
|
||||||
|
"""True, wenn gkapi einen laufenden/hängenden Task meldet."""
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
return False
|
||||||
|
state = str(info.get("state") or "").strip().lower()
|
||||||
|
if state and state not in ("free", "idle", "done", ""):
|
||||||
|
return True
|
||||||
|
project = info.get("project")
|
||||||
|
if isinstance(project, dict) and (project.get("task_id") or project.get("filename")):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def task_summary(info):
|
||||||
|
"""Kompaktes Live-Feld aus print_info-data."""
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
return None
|
||||||
|
project = info.get("project") if isinstance(info.get("project"), dict) else {}
|
||||||
|
state = str(info.get("state") or "").strip() or None
|
||||||
|
task_id = project.get("task_id")
|
||||||
|
if task_id is not None:
|
||||||
|
task_id = str(task_id)
|
||||||
|
filename = project.get("filename") or project.get("name") or None
|
||||||
|
if isinstance(filename, str):
|
||||||
|
filename = filename.replace("\\", "/").rsplit("/", 1)[-1] or None
|
||||||
|
if not state and not task_id and not filename:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"state": state,
|
||||||
|
"task_id": task_id,
|
||||||
|
"filename": filename,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_task_free(host, attempts=3, wait_s=1.5):
|
||||||
|
"""Hängenden Anycubic-Task best-effort räumen (Stop + poll).
|
||||||
|
|
||||||
|
Kobra 3 und S1. Ohne erreichbares gkapi: None (kein Fehler) — Moonraker-Start
|
||||||
|
darf nicht an LAN-Ausfall scheitern.
|
||||||
|
"""
|
||||||
|
info = print_info(host, timeout=8, use_cache=False)
|
||||||
|
if info is None or not _task_busy(info):
|
||||||
|
return info
|
||||||
|
last_err = None
|
||||||
|
for _ in range(max(1, int(attempts))):
|
||||||
|
try:
|
||||||
|
cancel_print(host, timeout=12)
|
||||||
|
except LanError as exc:
|
||||||
|
last_err = exc
|
||||||
|
time.sleep(max(0.5, float(wait_s)))
|
||||||
|
info = print_info(host, timeout=8, use_cache=False)
|
||||||
|
if info is None or not _task_busy(info):
|
||||||
|
return info
|
||||||
|
raise LanError(
|
||||||
|
last_err.message if last_err else (
|
||||||
|
"Anycubic-Task ließ sich nicht freimachen (state=%s)."
|
||||||
|
% ((info or {}).get("state") or "?")
|
||||||
|
),
|
||||||
|
code=getattr(last_err, "code", CODE_TASK_ACTIVE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Druckbefehl."):
|
def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Druckbefehl."):
|
||||||
@@ -165,7 +261,7 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
|
|||||||
model_id, device_id,
|
model_id, device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
state = {"done": False, "error": None, "report": None}
|
state = {"done": False, "error": None, "code": None, "report": None}
|
||||||
|
|
||||||
def on_connect(client, userdata, flags, reason_code, properties=None):
|
def on_connect(client, userdata, flags, reason_code, properties=None):
|
||||||
if getattr(reason_code, "is_failure", False):
|
if getattr(reason_code, "is_failure", False):
|
||||||
@@ -184,9 +280,13 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
|
|||||||
report = str(body.get("state") or "")
|
report = str(body.get("state") or "")
|
||||||
action = str(request.get("action") or "")
|
action = str(request.get("action") or "")
|
||||||
if action == "start" and report in ("failed", "stoped", "stopped"):
|
if action == "start" and report in ("failed", "stoped", "stopped"):
|
||||||
state["error"] = _report_error(body, "Druckstart abgelehnt")
|
text, code = _report_error(body, "Druckstart abgelehnt")
|
||||||
|
state["error"] = text
|
||||||
|
state["code"] = code
|
||||||
elif action == "stop" and report in ("failed",):
|
elif action == "stop" and report in ("failed",):
|
||||||
state["error"] = _report_error(body, "Druckabbruch abgelehnt")
|
text, code = _report_error(body, "Druckabbruch abgelehnt")
|
||||||
|
state["error"] = text
|
||||||
|
state["code"] = code
|
||||||
# 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
|
||||||
@@ -221,59 +321,93 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if state["error"]:
|
if state["error"]:
|
||||||
raise LanError(state["error"])
|
raise LanError(state["error"], code=state.get("code"))
|
||||||
return state["report"]
|
return state["report"]
|
||||||
|
|
||||||
|
|
||||||
def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=30):
|
def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=30):
|
||||||
"""MQTT-Start wie Rinkhals, mit task_settings.auto_leveling 1/0."""
|
"""MQTT-Start wie Rinkhals: Task freimachen, bei 10101 einmal Retry."""
|
||||||
name = (filename or "").replace("\\", "/").split("/")[-1]
|
name = (filename or "").replace("\\", "/").split("/")[-1]
|
||||||
if not name:
|
if not name:
|
||||||
raise LanError("Dateiname fehlt.")
|
raise LanError("Dateiname fehlt.")
|
||||||
|
|
||||||
data = {
|
|
||||||
"filename": name,
|
|
||||||
"filepath": "/",
|
|
||||||
"taskid": str(random.randint(0, 1000000)),
|
|
||||||
"task_mode": 1,
|
|
||||||
"filetype": 1,
|
|
||||||
"task_settings": {
|
|
||||||
"auto_leveling": 1 if auto_leveling else 0,
|
|
||||||
"vibration_compensation": 0,
|
|
||||||
"flow_calibration": 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
ams = normalize_ams(ams_settings)
|
|
||||||
if ams:
|
|
||||||
data["ams_settings"] = ams
|
|
||||||
|
|
||||||
request = {
|
|
||||||
"type": "print",
|
|
||||||
"action": "start",
|
|
||||||
"msgid": str(uuid.uuid4()),
|
|
||||||
"timestamp": int(time.time() * 1000),
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
timeout_message = "Timeout beim MQTT-Druckstart."
|
|
||||||
try:
|
try:
|
||||||
_mqtt_print(host, request, timeout=timeout, timeout_message=timeout_message)
|
ensure_task_free(host)
|
||||||
except LanError as exc:
|
except LanError:
|
||||||
# Nach Bettnetz-Prüfung kam das Start-Report schon > 30 s zu spät, der Druck lief trotzdem.
|
# freimachen fehlgeschlagen — Start trotzdem versuchen (Retry fängt 10101)
|
||||||
if exc.message != timeout_message:
|
pass
|
||||||
|
|
||||||
|
ams = normalize_ams(ams_settings)
|
||||||
|
timeout_message = "Timeout beim MQTT-Druckstart."
|
||||||
|
last_exc = None
|
||||||
|
for attempt in range(2):
|
||||||
|
data = {
|
||||||
|
"filename": name,
|
||||||
|
"filepath": "/",
|
||||||
|
"taskid": str(random.randint(0, 1000000)),
|
||||||
|
"task_mode": 1,
|
||||||
|
"filetype": 1,
|
||||||
|
"task_settings": {
|
||||||
|
"auto_leveling": 1 if auto_leveling else 0,
|
||||||
|
"vibration_compensation": 0,
|
||||||
|
"flow_calibration": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if ams:
|
||||||
|
data["ams_settings"] = ams
|
||||||
|
request = {
|
||||||
|
"type": "print",
|
||||||
|
"action": "start",
|
||||||
|
"msgid": str(uuid.uuid4()),
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
_mqtt_print(host, request, timeout=timeout, timeout_message=timeout_message)
|
||||||
|
return {"filename": name, "auto_leveling": bool(auto_leveling)}
|
||||||
|
except LanError as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if exc.message == timeout_message:
|
||||||
|
for _ in range(3):
|
||||||
|
project = (print_info(host, use_cache=False) or {}).get("project") or {}
|
||||||
|
if str(project.get("task_id") or "") == data["taskid"]:
|
||||||
|
return {"filename": name, "auto_leveling": bool(auto_leveling)}
|
||||||
|
time.sleep(5)
|
||||||
|
raise
|
||||||
|
if exc.code == CODE_TASK_ACTIVE and attempt == 0:
|
||||||
|
try:
|
||||||
|
cancel_print(host, timeout=12)
|
||||||
|
except LanError:
|
||||||
|
pass
|
||||||
|
time.sleep(1.5)
|
||||||
|
continue
|
||||||
raise
|
raise
|
||||||
for attempt in range(3):
|
if last_exc:
|
||||||
project = (print_info(host) or {}).get("project") or {}
|
raise last_exc
|
||||||
if str(project.get("task_id") or "") == data["taskid"]:
|
raise LanError("Druckstart fehlgeschlagen.")
|
||||||
break
|
|
||||||
time.sleep(5)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
return {"filename": name, "auto_leveling": bool(auto_leveling)}
|
|
||||||
|
|
||||||
|
|
||||||
def print_info(host, timeout=10):
|
def print_info(host, timeout=10, use_cache=True):
|
||||||
"""MQTT info/query, liefert data aus info/report (project.task_id, state …)."""
|
"""MQTT info/query, liefert data aus info/report (project.task_id, state …).
|
||||||
cred = credentials(host)
|
|
||||||
|
Bei LAN-/MQTT-Fehler: None (kein Raise), kurz negativ cachen — Live-Wand und
|
||||||
|
Moonraker-Start bleiben für Kobra 3 und S1 nutzbar, wenn gkapi kurz weg ist.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
if use_cache:
|
||||||
|
cached = _INFO_CACHE.get(host)
|
||||||
|
if cached and now - cached[0] < INFO_TTL:
|
||||||
|
return cached[1]
|
||||||
|
fail_at = _FAIL_CACHE.get(host)
|
||||||
|
if fail_at and now - fail_at < FAIL_TTL:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
cred = credentials(host)
|
||||||
|
except LanError:
|
||||||
|
_FAIL_CACHE[host] = time.time()
|
||||||
|
return None
|
||||||
|
|
||||||
model_id = cred["modelId"]
|
model_id = cred["modelId"]
|
||||||
device_id = cred["deviceId"]
|
device_id = cred["deviceId"]
|
||||||
topic_query = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/info" % (model_id, device_id)
|
topic_query = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/info" % (model_id, device_id)
|
||||||
@@ -316,6 +450,7 @@ def print_info(host, timeout=10):
|
|||||||
try:
|
try:
|
||||||
client.connect(host, 9883, keepalive=30)
|
client.connect(host, 9883, keepalive=30)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
_FAIL_CACHE[host] = time.time()
|
||||||
return None
|
return None
|
||||||
deadline = time.time() + max(3, int(timeout))
|
deadline = time.time() + max(3, int(timeout))
|
||||||
try:
|
try:
|
||||||
@@ -326,9 +461,22 @@ def print_info(host, timeout=10):
|
|||||||
client.disconnect()
|
client.disconnect()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if state["data"] is None:
|
||||||
|
_FAIL_CACHE[host] = time.time()
|
||||||
|
return None
|
||||||
|
_FAIL_CACHE.pop(host, None)
|
||||||
|
_INFO_CACHE[host] = (time.time(), state["data"])
|
||||||
return state["data"]
|
return state["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def anycubic_task(host, use_cache=True):
|
||||||
|
"""Live-Zusammenfassung des gkapi-Tasks (cached), oder null."""
|
||||||
|
try:
|
||||||
|
return task_summary(print_info(host, timeout=6, use_cache=use_cache))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def 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 = {
|
||||||
@@ -344,4 +492,5 @@ def cancel_print(host, timeout=15):
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
timeout_message="Timeout beim MQTT-Druckabbruch.",
|
timeout_message="Timeout beim MQTT-Druckabbruch.",
|
||||||
)
|
)
|
||||||
|
_INFO_CACHE.pop(host, None)
|
||||||
return {"mqtt": "stop", "report": report}
|
return {"mqtt": "stop", "report": report}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Benutzer, Sessions, API-Tokens und CSRF."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
from grotti.errors import GrottiError
|
||||||
|
|
||||||
|
ROLES = ("admin", "operator")
|
||||||
|
ROLE_RANK = {"operator": 1, "admin": 2}
|
||||||
|
TOKEN_PREFIX = "grt_"
|
||||||
|
LOGIN_WINDOW_S = 300
|
||||||
|
LOGIN_MAX_ATTEMPTS = 12
|
||||||
|
|
||||||
|
_login_hits: dict[str, list[float]] = defaultdict(list)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return generate_password_hash(password, method="scrypt")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password_hash: str, password: str) -> bool:
|
||||||
|
if not password_hash or not password:
|
||||||
|
return False
|
||||||
|
return check_password_hash(password_hash, password)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_username(username: str) -> str:
|
||||||
|
name = (username or "").strip()
|
||||||
|
if len(name) < 2 or len(name) > 64:
|
||||||
|
raise GrottiError("Benutzername: 2–64 Zeichen.", "user")
|
||||||
|
if not all(ch.isalnum() or ch in "._-" for ch in name):
|
||||||
|
raise GrottiError("Benutzername: nur Buchstaben, Ziffern, . _ -", "user")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password(password: str) -> str:
|
||||||
|
if not password or len(password) < 8:
|
||||||
|
raise GrottiError("Passwort mindestens 8 Zeichen.", "password")
|
||||||
|
if len(password) > 200:
|
||||||
|
raise GrottiError("Passwort zu lang.", "password")
|
||||||
|
return password
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_role(role: str, *, default: str = "operator") -> str:
|
||||||
|
role = (role or default).strip().lower()
|
||||||
|
if role not in ROLES:
|
||||||
|
raise GrottiError("Rolle muss admin oder operator sein.", "role")
|
||||||
|
return role
|
||||||
|
|
||||||
|
|
||||||
|
def role_at_least(have: str, need: str) -> bool:
|
||||||
|
return ROLE_RANK.get(have or "", 0) >= ROLE_RANK.get(need or "", 99)
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_token_role(user_role: str, token_role: str | None) -> str:
|
||||||
|
wanted = normalize_role(token_role or user_role, default=user_role)
|
||||||
|
if not role_at_least(user_role, wanted):
|
||||||
|
raise GrottiError("Token-Rolle darf nicht höher als die des Benutzers sein.", "role")
|
||||||
|
return wanted
|
||||||
|
|
||||||
|
|
||||||
|
def new_api_token() -> str:
|
||||||
|
return TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(raw: str) -> str:
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def token_prefix(raw: str) -> str:
|
||||||
|
return (raw or "")[:12]
|
||||||
|
|
||||||
|
|
||||||
|
def new_csrf() -> str:
|
||||||
|
return secrets.token_urlsafe(24)
|
||||||
|
|
||||||
|
|
||||||
|
def login_rate_ok(key: str) -> bool:
|
||||||
|
now = time.time()
|
||||||
|
hits = _login_hits[key]
|
||||||
|
_login_hits[key] = [t for t in hits if now - t < LOGIN_WINDOW_S]
|
||||||
|
return len(_login_hits[key]) < LOGIN_MAX_ATTEMPTS
|
||||||
|
|
||||||
|
|
||||||
|
def login_rate_hit(key: str) -> None:
|
||||||
|
_login_hits[key].append(time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def public_user(row) -> dict:
|
||||||
|
if not row:
|
||||||
|
return {}
|
||||||
|
created = row.get("created_at")
|
||||||
|
last = row.get("last_login")
|
||||||
|
return {
|
||||||
|
"id": row.get("id"),
|
||||||
|
"username": row.get("username"),
|
||||||
|
"role": row.get("role"),
|
||||||
|
"active": bool(row["active"]) if "active" in row else True,
|
||||||
|
"created_at": created.isoformat() if created and hasattr(created, "isoformat") else created,
|
||||||
|
"last_login": last.isoformat() if last and hasattr(last, "isoformat") else last,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def public_token(row) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"user_id": row["user_id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"prefix": row["token_prefix"],
|
||||||
|
"role": row["role"],
|
||||||
|
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
||||||
|
"last_used": row["last_used"].isoformat() if row.get("last_used") else None,
|
||||||
|
"expires_at": row["expires_at"].isoformat() if row.get("expires_at") else None,
|
||||||
|
"revoked": bool(row.get("revoked_at")),
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Datenbank- und Datenordner-Backup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def backup_dir(cfg):
|
||||||
|
base = Path(cfg.get("data_dir") or "data")
|
||||||
|
path = base / "backup"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_name(name):
|
||||||
|
name = re.sub(r"[^\w.\-]+", "_", name or "")
|
||||||
|
return name[:180] or "backup.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def create_backup(cfg):
|
||||||
|
"""ZIP mit pg_dump (stdout) und data/ ohne toolpath/ und backup/."""
|
||||||
|
data_dir = Path(cfg.get("data_dir") or "data")
|
||||||
|
out_dir = backup_dir(cfg)
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
filename = _safe_name("grotti-%s.zip" % stamp)
|
||||||
|
dest = out_dir / filename
|
||||||
|
|
||||||
|
url = cfg.get("database_url") or ""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
db_name = (parsed.path or "/grotti").lstrip("/") or "grotti"
|
||||||
|
env = {}
|
||||||
|
if parsed.password:
|
||||||
|
env["PGPASSWORD"] = parsed.password
|
||||||
|
|
||||||
|
cmd = ["pg_dump", "--no-owner", "--no-acl", db_name]
|
||||||
|
if parsed.hostname:
|
||||||
|
cmd[1:1] = ["-h", parsed.hostname]
|
||||||
|
if parsed.port:
|
||||||
|
cmd[1:1] = ["-p", str(parsed.port)]
|
||||||
|
if parsed.username:
|
||||||
|
cmd[1:1] = ["-U", parsed.username]
|
||||||
|
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, env={**os.environ, **env})
|
||||||
|
if proc.returncode != 0:
|
||||||
|
err = (proc.stderr or b"").decode("utf-8", "replace").strip()
|
||||||
|
raise RuntimeError(err or "pg_dump fehlgeschlagen.")
|
||||||
|
|
||||||
|
skip_dirs = {"toolpath", "backup"}
|
||||||
|
with zipfile.ZipFile(dest, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr("database.sql", proc.stdout)
|
||||||
|
if data_dir.is_dir():
|
||||||
|
for path in sorted(data_dir.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(data_dir)
|
||||||
|
if rel.parts and rel.parts[0] in skip_dirs:
|
||||||
|
continue
|
||||||
|
zf.write(path, "data/" + rel.as_posix())
|
||||||
|
return {
|
||||||
|
"name": filename,
|
||||||
|
"path": str(dest),
|
||||||
|
"size": dest.stat().st_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_backups(cfg):
|
||||||
|
out_dir = backup_dir(cfg)
|
||||||
|
items = []
|
||||||
|
for path in sorted(out_dir.glob("*.zip"), reverse=True):
|
||||||
|
try:
|
||||||
|
size = path.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
size = 0
|
||||||
|
items.append({"name": path.name, "size": size})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def backup_path(cfg, name):
|
||||||
|
out_dir = backup_dir(cfg)
|
||||||
|
safe = _safe_name(name)
|
||||||
|
path = (out_dir / safe).resolve()
|
||||||
|
if not str(path).startswith(str(out_dir.resolve())):
|
||||||
|
return None
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
return path
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Filament-Bedarf aus wartender Queue vs. Spulenrest."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_HEX_COLOR_RE = re.compile(r"^#?[0-9a-f]{6}([0-9a-f]{2})?$", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def _group_key(material_type, color):
|
||||||
|
return (
|
||||||
|
(material_type or "").strip().lower(),
|
||||||
|
(color or "").strip().lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_slicer_hex(color):
|
||||||
|
"""Orca/Anycubic speichert oft #26A69A — Lagerfarben heißen „Grau“/„Weiß“."""
|
||||||
|
return bool(_HEX_COLOR_RE.match((color or "").strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def _stock_for_need(stock_map, mat, color):
|
||||||
|
"""Exakte Farbe zuerst; bei Slice-Hex oder leerer Farbe: Summe gleicher Sorte."""
|
||||||
|
key = _group_key(mat, color)
|
||||||
|
exact = stock_map.get(key, 0.0)
|
||||||
|
if exact > 0.5:
|
||||||
|
return exact
|
||||||
|
if not color or _is_slicer_hex(color):
|
||||||
|
mat_key = (mat or "").strip().lower()
|
||||||
|
return sum(
|
||||||
|
grams
|
||||||
|
for (m, _c), grams in stock_map.items()
|
||||||
|
if m == mat_key and grams > 0
|
||||||
|
)
|
||||||
|
return exact
|
||||||
|
|
||||||
|
|
||||||
|
def compute(conn):
|
||||||
|
"""Wartende Jobs × Dateigewicht vs. Spulenrest, gruppiert nach Sorte/Farbe."""
|
||||||
|
queue_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT q.id, q.spool_id, q.repeat_total, q.repeat_done,
|
||||||
|
f.weight_g, f.filaments,
|
||||||
|
s.material_type AS spool_material, s.color AS spool_color
|
||||||
|
FROM print_queue q
|
||||||
|
JOIN files f ON f.id = q.file_id
|
||||||
|
LEFT JOIN spools s ON s.id = q.spool_id
|
||||||
|
WHERE q.status = 'waiting'
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
needed_map = {}
|
||||||
|
for row in queue_rows:
|
||||||
|
filaments = row.get("filaments") or []
|
||||||
|
first = filaments[0] if isinstance(filaments, list) and filaments else {}
|
||||||
|
if not isinstance(first, dict):
|
||||||
|
first = {}
|
||||||
|
mat = (row.get("spool_material") or first.get("type") or "").strip()
|
||||||
|
color = (row.get("spool_color") or first.get("color") or "").strip()
|
||||||
|
key = _group_key(mat, color)
|
||||||
|
try:
|
||||||
|
repeats = max(1, int(row.get("repeat_total") or 1) - int(row.get("repeat_done") or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
repeats = 1
|
||||||
|
weight = float(row.get("weight_g") or 0) * repeats
|
||||||
|
if key not in needed_map:
|
||||||
|
needed_map[key] = {
|
||||||
|
"material_type": mat,
|
||||||
|
"color": color,
|
||||||
|
"weight_g": 0.0,
|
||||||
|
"jobs": 0,
|
||||||
|
}
|
||||||
|
needed_map[key]["weight_g"] += weight
|
||||||
|
needed_map[key]["jobs"] += 1
|
||||||
|
|
||||||
|
spool_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT material_type, color, COALESCE(SUM(remaining_g), 0) AS remaining_g
|
||||||
|
FROM spools
|
||||||
|
WHERE archived_at IS NULL
|
||||||
|
GROUP BY material_type, color
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
stock_map = {}
|
||||||
|
for row in spool_rows:
|
||||||
|
key = _group_key(row.get("material_type"), row.get("color"))
|
||||||
|
stock_map[key] = stock_map.get(key, 0.0) + float(row.get("remaining_g") or 0)
|
||||||
|
|
||||||
|
needed = sorted(
|
||||||
|
needed_map.values(),
|
||||||
|
key=lambda x: (-x["weight_g"], x["material_type"], x["color"]),
|
||||||
|
)
|
||||||
|
shortfall = []
|
||||||
|
for item in needed:
|
||||||
|
have = _stock_for_need(stock_map, item["material_type"], item["color"])
|
||||||
|
gap = item["weight_g"] - have
|
||||||
|
if gap > 0.5:
|
||||||
|
shortfall.append({
|
||||||
|
**item,
|
||||||
|
"remaining_g": have,
|
||||||
|
"shortfall_g": gap,
|
||||||
|
})
|
||||||
|
shortfall.sort(key=lambda x: (-x["shortfall_g"], x["material_type"], x["color"]))
|
||||||
|
return {"needed": needed, "shortfall": shortfall}
|
||||||
@@ -136,7 +136,7 @@ MATERIALS = [
|
|||||||
{
|
{
|
||||||
"name": "Sunlu PA12-CF",
|
"name": "Sunlu PA12-CF",
|
||||||
"brand": "Sunlu",
|
"brand": "Sunlu",
|
||||||
"material_type": "PA",
|
"material_type": "PA12-CF",
|
||||||
"density": 1.08,
|
"density": 1.08,
|
||||||
"volumetric": 8,
|
"volumetric": 8,
|
||||||
"volumetric_s1": 8,
|
"volumetric_s1": 8,
|
||||||
@@ -157,6 +157,14 @@ MATERIALS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def ace_material_type(material_type):
|
||||||
|
"""Typ für den ACE. PA12-CF gibt es dort nicht, der Platz heißt PA6-CF."""
|
||||||
|
text = (material_type or "").strip()
|
||||||
|
if text.lower() == "pa12-cf":
|
||||||
|
return "PA6-CF"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
SOLIDS = [
|
SOLIDS = [
|
||||||
{"id": "schwarz", "name": "Schwarz", "hex": "#1c1c1c"},
|
{"id": "schwarz", "name": "Schwarz", "hex": "#1c1c1c"},
|
||||||
{"id": "weiss", "name": "Weiß", "hex": "#f4f4f4"},
|
{"id": "weiss", "name": "Weiß", "hex": "#f4f4f4"},
|
||||||
|
|||||||
@@ -1,23 +1,76 @@
|
|||||||
"""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 threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
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"
|
||||||
|
_SETTINGS_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def config_path():
|
||||||
|
"""GROTTI_CONFIG, sonst /etc/grotti/config.json (Paket), sonst Repo-config.json."""
|
||||||
|
env = (os.environ.get("GROTTI_CONFIG") or "").strip()
|
||||||
|
if env:
|
||||||
|
return Path(env)
|
||||||
|
etc = Path("/etc/grotti/config.json")
|
||||||
|
if etc.is_file():
|
||||||
|
return etc
|
||||||
|
return ROOT / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_PATH = config_path()
|
||||||
|
|
||||||
|
|
||||||
|
def _tariff(raw):
|
||||||
|
tariff = raw.get("tariff") if isinstance(raw, dict) else None
|
||||||
|
if not isinstance(tariff, dict) or not tariff:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ht = float(tariff.get("ht_euro"))
|
||||||
|
nt = float(tariff.get("nt_euro"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"ht_euro": ht,
|
||||||
|
"nt_euro": nt,
|
||||||
|
"nt_from": str(tariff.get("nt_from") or "22:00").strip(),
|
||||||
|
"nt_to": str(tariff.get("nt_to") or "06:00").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _energy(raw):
|
def _energy(raw):
|
||||||
energy = raw.get("energy") or {}
|
energy = raw.get("energy") or {}
|
||||||
return {
|
out = {
|
||||||
"watts": float(energy.get("watts", 120)),
|
"watts": float(energy.get("watts", 40)),
|
||||||
|
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
|
||||||
|
"bed_watts": float(energy.get("bed_watts", 220)),
|
||||||
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
|
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
|
||||||
"dryer_watts": float(energy.get("dryer_watts", 0)),
|
"dryer_watts": float(energy.get("dryer_watts", 0)),
|
||||||
"dry_hours": float(energy.get("dry_hours", 0)),
|
"dry_hours": float(energy.get("dry_hours", 0)),
|
||||||
"waste_percent": float(energy.get("waste_percent", 0)),
|
"waste_percent": float(energy.get("waste_percent", 0)),
|
||||||
"utilization": float(energy.get("utilization", 50)),
|
"utilization": float(energy.get("utilization", 50)),
|
||||||
}
|
}
|
||||||
|
tariff = _tariff(energy)
|
||||||
|
if tariff:
|
||||||
|
out["tariff"] = tariff
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _ntfy(raw):
|
||||||
|
ntfy = raw.get("ntfy") or {}
|
||||||
|
return {
|
||||||
|
"enabled": bool(ntfy.get("enabled")),
|
||||||
|
"server": str(ntfy.get("server") or "https://ntfy.sh").rstrip("/"),
|
||||||
|
"topic": str(ntfy.get("topic") or ""),
|
||||||
|
"token": str(ntfy.get("token") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _telegram(raw):
|
def _telegram(raw):
|
||||||
@@ -36,6 +89,7 @@ def _alerts(raw):
|
|||||||
"fehler": bool(alerts.get("fehler", True)),
|
"fehler": bool(alerts.get("fehler", True)),
|
||||||
"offline": bool(alerts.get("offline", True)),
|
"offline": bool(alerts.get("offline", True)),
|
||||||
"queue": bool(alerts.get("queue", True)),
|
"queue": bool(alerts.get("queue", True)),
|
||||||
|
"spule": bool(alerts.get("spule", True)),
|
||||||
"ablauf": bool(alerts.get("ablauf", False)),
|
"ablauf": bool(alerts.get("ablauf", False)),
|
||||||
"start": bool(alerts.get("start", False)),
|
"start": bool(alerts.get("start", False)),
|
||||||
}
|
}
|
||||||
@@ -59,22 +113,51 @@ def public_base(cfg, request_root=None):
|
|||||||
return str(request_root or "").rstrip("/")
|
return str(request_root or "").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth(raw):
|
||||||
|
oauth = raw.get("oauth") or {}
|
||||||
|
return {
|
||||||
|
"enabled": bool(oauth.get("enabled")),
|
||||||
|
"issuer": str(oauth.get("issuer") or ""),
|
||||||
|
"client_id": str(oauth.get("client_id") or ""),
|
||||||
|
"client_secret": str(oauth.get("client_secret") or ""),
|
||||||
|
"redirect_uri": str(oauth.get("redirect_uri") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_secret_key(path, raw):
|
||||||
|
key = str(raw.get("secret_key") or "").strip()
|
||||||
|
if key:
|
||||||
|
return key
|
||||||
|
key = secrets.token_hex(32)
|
||||||
|
raw["secret_key"] = key
|
||||||
|
path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
def load():
|
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 ""
|
||||||
if host not in LOOPBACK and not token:
|
secret_key = _ensure_secret_key(path, raw)
|
||||||
raise SystemExit("Ohne Token startet der Dienst nicht, wenn er nicht nur lokal lauscht.")
|
# Nicht-Loopback ohne Shared-Token ist ok — Login/API-Tokens schützen die App.
|
||||||
|
# Neu laden falls secret_key geschrieben wurde
|
||||||
|
if raw.get("secret_key") == secret_key:
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
data_dir = Path(raw.get("data_dir") or "data")
|
data_dir = Path(raw.get("data_dir") or "data")
|
||||||
if not data_dir.is_absolute():
|
if not data_dir.is_absolute():
|
||||||
data_dir = ROOT / data_dir
|
data_dir = ROOT / data_dir
|
||||||
watch = (raw.get("watch_dir") or "").strip()
|
watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
|
||||||
watch_dir = Path(watch) if watch else None
|
printers_file = Path(raw.get("printers_file") or (ROOT / "drucker.json"))
|
||||||
if watch_dir and not watch_dir.is_absolute():
|
if not printers_file.is_absolute():
|
||||||
watch_dir = (ROOT / watch_dir).resolve()
|
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),
|
||||||
@@ -82,12 +165,17 @@ def load():
|
|||||||
"watch_dir": watch_dir,
|
"watch_dir": watch_dir,
|
||||||
"database_url": raw.get("database_url") or "postgresql://[email protected]:5432/grotti",
|
"database_url": raw.get("database_url") or "postgresql://[email protected]:5432/grotti",
|
||||||
"token": token,
|
"token": token,
|
||||||
|
"secret_key": secret_key,
|
||||||
|
"oauth": _oauth(raw),
|
||||||
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
|
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
|
||||||
"printers_file": ROOT / "drucker.json",
|
"printers_file": printers_file,
|
||||||
|
"log_level": str(raw.get("log_level") or "INFO").strip() or "INFO",
|
||||||
"energy": _energy(raw),
|
"energy": _energy(raw),
|
||||||
"telegram": _telegram(raw),
|
"telegram": _telegram(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)),
|
||||||
"_raw": raw,
|
"_raw": raw,
|
||||||
"_path": path,
|
"_path": path,
|
||||||
}
|
}
|
||||||
@@ -100,12 +188,52 @@ def mask_token(token):
|
|||||||
return token[:4] + "…" + token[-2:]
|
return token[:4] + "…" + token[-2:]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_watch_dir(value, *, create=False, missing_ok=False):
|
||||||
|
"""Pfad aus Einstellungen/Config → (Path|None, gespeicherter String).
|
||||||
|
|
||||||
|
Relativ zum Repo-Root. Leer = aus. create legt den Ordner an.
|
||||||
|
"""
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None, ""
|
||||||
|
path = Path(text)
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = (ROOT / path).resolve()
|
||||||
|
else:
|
||||||
|
path = path.resolve()
|
||||||
|
if path.exists() and not path.is_dir():
|
||||||
|
raise GrottiError("Der Slice-Ordner ist keine Ordner.", "watch")
|
||||||
|
if create:
|
||||||
|
try:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise GrottiError("Slice-Ordner konnte nicht angelegt werden: %s" % exc, "watch") from exc
|
||||||
|
elif not path.is_dir():
|
||||||
|
if missing_ok:
|
||||||
|
return None, text
|
||||||
|
raise GrottiError("Slice-Ordner fehlt: %s" % path, "watch")
|
||||||
|
try:
|
||||||
|
stored = path.relative_to(ROOT.resolve()).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
stored = str(path)
|
||||||
|
return path, stored
|
||||||
|
|
||||||
|
|
||||||
|
def default_watch_suggestion():
|
||||||
|
"""Absoluter Standardpfad (Repo/slice) für die Einstellungen."""
|
||||||
|
return str((ROOT / DEFAULT_WATCH_REL).resolve())
|
||||||
|
|
||||||
|
|
||||||
def settings_public(cfg):
|
def settings_public(cfg):
|
||||||
tg = cfg.get("telegram") or {}
|
tg = cfg.get("telegram") or {}
|
||||||
|
oauth = cfg.get("oauth") or {}
|
||||||
return {
|
return {
|
||||||
"watch_dir": str(cfg.get("watch_dir") or ""),
|
"watch_dir": str(cfg.get("watch_dir") or ""),
|
||||||
|
"watch_dir_default": default_watch_suggestion(),
|
||||||
"auto_queue": bool(cfg.get("auto_queue", True)),
|
"auto_queue": bool(cfg.get("auto_queue", True)),
|
||||||
|
"auto_retry": bool(cfg.get("auto_retry", True)),
|
||||||
"fqdn": str(cfg.get("fqdn") or ""),
|
"fqdn": str(cfg.get("fqdn") or ""),
|
||||||
|
"legacy_token_set": bool(cfg.get("token")),
|
||||||
"energy": dict(cfg.get("energy") or {}),
|
"energy": dict(cfg.get("energy") or {}),
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": bool(tg.get("enabled")),
|
"enabled": bool(tg.get("enabled")),
|
||||||
@@ -113,12 +241,60 @@ def settings_public(cfg):
|
|||||||
"bot_token_set": bool(tg.get("bot_token")),
|
"bot_token_set": bool(tg.get("bot_token")),
|
||||||
"chat_id": str(tg.get("chat_id") or ""),
|
"chat_id": str(tg.get("chat_id") or ""),
|
||||||
},
|
},
|
||||||
|
"ntfy": {
|
||||||
|
"enabled": bool((cfg.get("ntfy") or {}).get("enabled")),
|
||||||
|
"server": str((cfg.get("ntfy") or {}).get("server") or "https://ntfy.sh"),
|
||||||
|
"topic": str((cfg.get("ntfy") or {}).get("topic") or ""),
|
||||||
|
"token": mask_token((cfg.get("ntfy") or {}).get("token") or ""),
|
||||||
|
"token_set": bool((cfg.get("ntfy") or {}).get("token")),
|
||||||
|
},
|
||||||
"alerts": dict(cfg.get("alerts") or {}),
|
"alerts": dict(cfg.get("alerts") or {}),
|
||||||
|
"oauth": {
|
||||||
|
"enabled": bool(oauth.get("enabled")),
|
||||||
|
"issuer": str(oauth.get("issuer") or ""),
|
||||||
|
"client_id": str(oauth.get("client_id") or ""),
|
||||||
|
"redirect_uri": str(oauth.get("redirect_uri") or ""),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None):
|
def save_settings(
|
||||||
"""Schreibt energy/telegram/alerts/auto_queue/fqdn zurück nach config.json und aktualisiert cfg."""
|
cfg,
|
||||||
|
energy=None,
|
||||||
|
telegram=None,
|
||||||
|
ntfy=None,
|
||||||
|
alerts=None,
|
||||||
|
auto_queue=None,
|
||||||
|
auto_retry=None,
|
||||||
|
fqdn=None,
|
||||||
|
watch_dir=None,
|
||||||
|
):
|
||||||
|
"""Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
|
||||||
|
with _SETTINGS_LOCK:
|
||||||
|
return _save_settings_locked(
|
||||||
|
cfg,
|
||||||
|
energy=energy,
|
||||||
|
telegram=telegram,
|
||||||
|
ntfy=ntfy,
|
||||||
|
alerts=alerts,
|
||||||
|
auto_queue=auto_queue,
|
||||||
|
auto_retry=auto_retry,
|
||||||
|
fqdn=fqdn,
|
||||||
|
watch_dir=watch_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_settings_locked(
|
||||||
|
cfg,
|
||||||
|
energy=None,
|
||||||
|
telegram=None,
|
||||||
|
ntfy=None,
|
||||||
|
alerts=None,
|
||||||
|
auto_queue=None,
|
||||||
|
auto_retry=None,
|
||||||
|
fqdn=None,
|
||||||
|
watch_dir=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 {})
|
||||||
if path.exists():
|
if path.exists():
|
||||||
@@ -129,19 +305,37 @@ def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None,
|
|||||||
if auto_queue is not None:
|
if auto_queue is not None:
|
||||||
raw["auto_queue"] = bool(auto_queue)
|
raw["auto_queue"] = bool(auto_queue)
|
||||||
cfg["auto_queue"] = bool(auto_queue)
|
cfg["auto_queue"] = bool(auto_queue)
|
||||||
|
if auto_retry is not None:
|
||||||
|
raw["auto_retry"] = bool(auto_retry)
|
||||||
|
cfg["auto_retry"] = bool(auto_retry)
|
||||||
|
if watch_dir is not None:
|
||||||
|
resolved, stored = parse_watch_dir(watch_dir, create=True, missing_ok=False)
|
||||||
|
raw["watch_dir"] = stored
|
||||||
|
cfg["watch_dir"] = resolved
|
||||||
if fqdn is not None:
|
if fqdn is not None:
|
||||||
normalized = normalize_fqdn(fqdn)
|
normalized = normalize_fqdn(fqdn)
|
||||||
raw["fqdn"] = normalized
|
raw["fqdn"] = normalized
|
||||||
cfg["fqdn"] = normalized
|
cfg["fqdn"] = normalized
|
||||||
if energy is not None:
|
if energy is not None:
|
||||||
raw["energy"] = {
|
raw_energy = {
|
||||||
"watts": float(energy.get("watts", 120)),
|
"watts": float(energy.get("watts", 40)),
|
||||||
|
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
|
||||||
|
"bed_watts": float(energy.get("bed_watts", 220)),
|
||||||
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
|
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
|
||||||
"dryer_watts": float(energy.get("dryer_watts", 0)),
|
"dryer_watts": float(energy.get("dryer_watts", 0)),
|
||||||
"dry_hours": float(energy.get("dry_hours", 0)),
|
"dry_hours": float(energy.get("dry_hours", 0)),
|
||||||
"waste_percent": float(energy.get("waste_percent", 0)),
|
"waste_percent": float(energy.get("waste_percent", 0)),
|
||||||
"utilization": float(energy.get("utilization", 50)),
|
"utilization": float(energy.get("utilization", 50)),
|
||||||
}
|
}
|
||||||
|
tariff_in = energy.get("tariff") if isinstance(energy.get("tariff"), dict) else None
|
||||||
|
if tariff_in:
|
||||||
|
raw_energy["tariff"] = {
|
||||||
|
"ht_euro": float(tariff_in.get("ht_euro", raw_energy["euro_per_kwh"])),
|
||||||
|
"nt_euro": float(tariff_in.get("nt_euro", raw_energy["euro_per_kwh"])),
|
||||||
|
"nt_from": str(tariff_in.get("nt_from") or "22:00"),
|
||||||
|
"nt_to": str(tariff_in.get("nt_to") or "06:00"),
|
||||||
|
}
|
||||||
|
raw["energy"] = raw_energy
|
||||||
cfg["energy"] = _energy(raw)
|
cfg["energy"] = _energy(raw)
|
||||||
if telegram is not None:
|
if telegram is not None:
|
||||||
current = _telegram(raw)
|
current = _telegram(raw)
|
||||||
@@ -154,12 +348,25 @@ def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None,
|
|||||||
"chat_id": str(telegram.get("chat_id", current["chat_id"]) or ""),
|
"chat_id": str(telegram.get("chat_id", current["chat_id"]) or ""),
|
||||||
}
|
}
|
||||||
cfg["telegram"] = _telegram(raw)
|
cfg["telegram"] = _telegram(raw)
|
||||||
|
if ntfy is not None:
|
||||||
|
current = _ntfy(raw)
|
||||||
|
token = ntfy.get("token")
|
||||||
|
if token is None or token == "" or "…" in str(token) or str(token).startswith("••••"):
|
||||||
|
token = current["token"]
|
||||||
|
raw["ntfy"] = {
|
||||||
|
"enabled": bool(ntfy.get("enabled", current["enabled"])),
|
||||||
|
"server": str(ntfy.get("server", current["server"]) or "https://ntfy.sh").rstrip("/"),
|
||||||
|
"topic": str(ntfy.get("topic", current["topic"]) or ""),
|
||||||
|
"token": str(token or ""),
|
||||||
|
}
|
||||||
|
cfg["ntfy"] = _ntfy(raw)
|
||||||
if alerts is not None:
|
if alerts is not None:
|
||||||
raw["alerts"] = {
|
raw["alerts"] = {
|
||||||
"fertig": bool(alerts.get("fertig", True)),
|
"fertig": bool(alerts.get("fertig", True)),
|
||||||
"fehler": bool(alerts.get("fehler", True)),
|
"fehler": bool(alerts.get("fehler", True)),
|
||||||
"offline": bool(alerts.get("offline", True)),
|
"offline": bool(alerts.get("offline", True)),
|
||||||
"queue": bool(alerts.get("queue", True)),
|
"queue": bool(alerts.get("queue", True)),
|
||||||
|
"spule": bool(alerts.get("spule", True)),
|
||||||
"ablauf": bool(alerts.get("ablauf", False)),
|
"ablauf": bool(alerts.get("ablauf", False)),
|
||||||
"start": bool(alerts.get("start", False)),
|
"start": bool(alerts.get("start", False)),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
"""Dieselbe Rechnung wie in der Druckkalkulation."""
|
"""Dieselbe Rechnung wie in der Druckkalkulation."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from grotti.time_window import _in_segment, _parse_hm
|
||||||
|
|
||||||
|
|
||||||
def calculate(job, material):
|
def calculate(job, material):
|
||||||
part_g = float(job.get("weight_g") or 0) * max(1, float(job.get("qty") or 1))
|
part_g = float(job.get("weight_g") or 0) * max(1, float(job.get("qty") or 1))
|
||||||
@@ -37,6 +41,225 @@ def calculate(job, material):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def energy_cost(duration_s, energy):
|
def _parse_ts(value):
|
||||||
hours = float(duration_s or 0) / 3600
|
if value is None:
|
||||||
return (float(energy["watts"]) / 1000) * hours * float(energy["euro_per_kwh"])
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.timestamp()
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _avg_power(a, b):
|
||||||
|
vals = [float(v) for v in (a, b) if isinstance(v, (int, float)) and not isinstance(v, bool)]
|
||||||
|
if not vals:
|
||||||
|
return 0.0
|
||||||
|
return max(0.0, min(1.0, sum(vals) / len(vals)))
|
||||||
|
|
||||||
|
|
||||||
|
def _ed_print_parts(samples, base_w, nozzle_w, bed_w):
|
||||||
|
"""Trapez: Grundlast / Düse / Bett getrennt. None wenn zu wenig ED-Proben."""
|
||||||
|
if not samples or len(samples) < 2:
|
||||||
|
return None
|
||||||
|
base_kwh = 0.0
|
||||||
|
nozzle_kwh = 0.0
|
||||||
|
bed_kwh = 0.0
|
||||||
|
ed_steps = 0
|
||||||
|
# Max. Intervall: 3× Normal-Sample (90 s), damit Lücken nicht die ED fortschreiben
|
||||||
|
max_dt = 90.0
|
||||||
|
for i in range(len(samples) - 1):
|
||||||
|
a, b = samples[i], samples[i + 1]
|
||||||
|
t0 = _parse_ts(a.get("sampled_at"))
|
||||||
|
t1 = _parse_ts(b.get("sampled_at"))
|
||||||
|
if t0 is None or t1 is None or t1 <= t0:
|
||||||
|
continue
|
||||||
|
dt = min(t1 - t0, max_dt)
|
||||||
|
hours = dt / 3600.0
|
||||||
|
pn0, pn1 = a.get("nozzle_power"), b.get("nozzle_power")
|
||||||
|
pb0, pb1 = a.get("bed_power"), b.get("bed_power")
|
||||||
|
has_ed = any(
|
||||||
|
isinstance(v, (int, float)) and not isinstance(v, bool)
|
||||||
|
for v in (pn0, pn1, pb0, pb1)
|
||||||
|
)
|
||||||
|
if has_ed:
|
||||||
|
ed_steps += 1
|
||||||
|
ed_n = _avg_power(pn0, pn1)
|
||||||
|
ed_b = _avg_power(pb0, pb1)
|
||||||
|
base_kwh += (float(base_w) / 1000.0) * hours
|
||||||
|
nozzle_kwh += (float(nozzle_w) * ed_n / 1000.0) * hours
|
||||||
|
bed_kwh += (float(bed_w) * ed_b / 1000.0) * hours
|
||||||
|
if ed_steps < 1:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"base_kwh": base_kwh,
|
||||||
|
"nozzle_kwh": nozzle_kwh,
|
||||||
|
"bed_kwh": bed_kwh,
|
||||||
|
"print_kwh": base_kwh + nozzle_kwh + bed_kwh,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_nt_moment(moment, tariff):
|
||||||
|
start_t = _parse_hm(tariff.get("nt_from"))
|
||||||
|
end_t = _parse_hm(tariff.get("nt_to"))
|
||||||
|
if start_t is None or end_t is None:
|
||||||
|
return False
|
||||||
|
now_t = moment.timetz().replace(tzinfo=None)
|
||||||
|
return _in_segment(now_t, start_t, end_t)
|
||||||
|
|
||||||
|
|
||||||
|
def effective_euro_per_kwh(duration_s, energy, end_dt=None):
|
||||||
|
energy = energy or {}
|
||||||
|
tariff = energy.get("tariff")
|
||||||
|
if not tariff or not duration_s or float(duration_s) <= 0:
|
||||||
|
return float(energy.get("euro_per_kwh") or 0)
|
||||||
|
end = end_dt if isinstance(end_dt, datetime) else datetime.now(timezone.utc).astimezone()
|
||||||
|
if end.tzinfo is None:
|
||||||
|
end = end.replace(tzinfo=timezone.utc)
|
||||||
|
start = end - timedelta(seconds=float(duration_s))
|
||||||
|
step = 300.0
|
||||||
|
t = start
|
||||||
|
nt_s = 0.0
|
||||||
|
total = 0.0
|
||||||
|
while t < end:
|
||||||
|
chunk = min(step, (end - t).total_seconds())
|
||||||
|
if _is_nt_moment(t, tariff):
|
||||||
|
nt_s += chunk
|
||||||
|
total += chunk
|
||||||
|
t += timedelta(seconds=chunk)
|
||||||
|
if total <= 0:
|
||||||
|
return float(energy.get("euro_per_kwh") or 0)
|
||||||
|
nt_frac = nt_s / total
|
||||||
|
ht = float(tariff.get("ht_euro") or energy.get("euro_per_kwh") or 0)
|
||||||
|
nt = float(tariff.get("nt_euro") or ht)
|
||||||
|
return nt_frac * nt + (1.0 - nt_frac) * ht
|
||||||
|
|
||||||
|
|
||||||
|
def _part(key, label, kwh, euro):
|
||||||
|
kwh = float(kwh or 0)
|
||||||
|
return {
|
||||||
|
"key": key,
|
||||||
|
"label": label,
|
||||||
|
"kwh": kwh,
|
||||||
|
"cost": kwh * float(euro or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def energy_estimate(duration_s, energy, samples=None, end_dt=None):
|
||||||
|
"""Strom aus ED-Proben oder pauschal Dauer × Watt.
|
||||||
|
|
||||||
|
Quelle ``ed`` nur wenn Düse-/Bett-Watt gesetzt und Proben mit power vorliegen.
|
||||||
|
``parts`` listet Grundlast / Düse / Bett / Trockner (kWh + €).
|
||||||
|
"""
|
||||||
|
energy = energy or {}
|
||||||
|
euro = effective_euro_per_kwh(duration_s, energy, end_dt=end_dt)
|
||||||
|
dry_kwh = (float(energy.get("dryer_watts") or 0) / 1000) * float(energy.get("dry_hours") or 0)
|
||||||
|
base_w = float(energy.get("watts") or 0)
|
||||||
|
nozzle_w = float(energy.get("nozzle_watts") or 0)
|
||||||
|
bed_w = float(energy.get("bed_watts") or 0)
|
||||||
|
|
||||||
|
parts_map = None
|
||||||
|
source = "flat"
|
||||||
|
if (nozzle_w > 0 or bed_w > 0) and samples:
|
||||||
|
parts_map = _ed_print_parts(samples, base_w, nozzle_w, bed_w)
|
||||||
|
if parts_map is not None:
|
||||||
|
source = "ed"
|
||||||
|
|
||||||
|
if parts_map is None:
|
||||||
|
hours = float(duration_s or 0) / 3600
|
||||||
|
parts_map = {
|
||||||
|
"base_kwh": (base_w / 1000) * hours,
|
||||||
|
"nozzle_kwh": 0.0,
|
||||||
|
"bed_kwh": 0.0,
|
||||||
|
"print_kwh": (base_w / 1000) * hours,
|
||||||
|
}
|
||||||
|
source = "flat"
|
||||||
|
|
||||||
|
print_kwh = float(parts_map["print_kwh"])
|
||||||
|
kwh = print_kwh + dry_kwh
|
||||||
|
parts = [
|
||||||
|
_part("base", "Grundlast", parts_map["base_kwh"], euro),
|
||||||
|
_part("nozzle", "Düse", parts_map["nozzle_kwh"], euro),
|
||||||
|
_part("bed", "Bett", parts_map["bed_kwh"], euro),
|
||||||
|
_part("dryer", "Trockner", dry_kwh, euro),
|
||||||
|
]
|
||||||
|
parts = [p for p in parts if p["kwh"] > 1e-6]
|
||||||
|
return {
|
||||||
|
"kwh": kwh,
|
||||||
|
"print_kwh": print_kwh,
|
||||||
|
"dry_kwh": dry_kwh,
|
||||||
|
"base_kwh": float(parts_map["base_kwh"]),
|
||||||
|
"nozzle_kwh": float(parts_map["nozzle_kwh"]),
|
||||||
|
"bed_kwh": float(parts_map["bed_kwh"]),
|
||||||
|
"cost": kwh * euro,
|
||||||
|
"euro_per_kwh": euro,
|
||||||
|
"source": source,
|
||||||
|
"parts": parts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def energy_cost(duration_s, energy, samples=None, end_dt=None):
|
||||||
|
return energy_estimate(duration_s, energy, samples, end_dt=end_dt)["cost"]
|
||||||
|
|
||||||
|
|
||||||
|
def offer_from_file(file_row, material_price, machine_rate_per_h, setup_min, margin):
|
||||||
|
"""Angebots-Schätzung aus Datei-Gewicht und Druckzeit."""
|
||||||
|
weight_g = float((file_row or {}).get("weight_g") or 0)
|
||||||
|
est_s = float((file_row or {}).get("estimated_s") or 0)
|
||||||
|
hours = est_s / 3600.0 if est_s > 0 else 0.0
|
||||||
|
mat = float(material_price or 0)
|
||||||
|
rate = float(machine_rate_per_h or 0)
|
||||||
|
setup_h = float(setup_min or 0) / 60.0
|
||||||
|
material_cost = (weight_g / 1000.0) * mat
|
||||||
|
machine_cost = hours * rate + setup_h * rate
|
||||||
|
subtotal = material_cost + machine_cost
|
||||||
|
pct = float(margin or 0)
|
||||||
|
total = subtotal * (1.0 + pct / 100.0) if subtotal > 0 else 0.0
|
||||||
|
return {
|
||||||
|
"weight_g": weight_g,
|
||||||
|
"hours": hours,
|
||||||
|
"material_cost": material_cost,
|
||||||
|
"machine_cost": machine_cost,
|
||||||
|
"subtotal": subtotal,
|
||||||
|
"margin_percent": pct,
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def order_actuals(conn, order_id):
|
||||||
|
"""Summe Kosten, Zeit und Ausschuss der verknüpften Läufe."""
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost_total,
|
||||||
|
COALESCE(SUM(r.duration_s), 0) AS duration_s,
|
||||||
|
COALESCE(SUM(r.pieces_ok), 0) AS pieces_ok,
|
||||||
|
COALESCE(SUM(r.pieces_bad), 0) AS pieces_bad,
|
||||||
|
COALESCE(SUM(r.weight_g) FILTER (WHERE r.verdict = 'ausschuss'), 0) AS scrap_g,
|
||||||
|
COUNT(*) FILTER (WHERE r.verdict = 'ausschuss') AS scrap_runs
|
||||||
|
FROM order_items oi
|
||||||
|
JOIN runs r ON r.order_item_id = oi.id
|
||||||
|
WHERE oi.order_id = %s
|
||||||
|
""",
|
||||||
|
(order_id,),
|
||||||
|
).fetchone()
|
||||||
|
pieces_ok = int(row["pieces_ok"] or 0)
|
||||||
|
cost_total = float(row["cost_total"] or 0)
|
||||||
|
return {
|
||||||
|
"cost_total": cost_total,
|
||||||
|
"duration_s": float(row["duration_s"] or 0),
|
||||||
|
"pieces_ok": pieces_ok,
|
||||||
|
"pieces_bad": int(row["pieces_bad"] or 0),
|
||||||
|
"scrap_g": float(row["scrap_g"] or 0),
|
||||||
|
"scrap_runs": int(row["scrap_runs"] or 0),
|
||||||
|
"cost_per_part": (cost_total / pieces_ok) if pieces_ok > 0 else None,
|
||||||
|
"yield_percent": (
|
||||||
|
100.0 * pieces_ok / (pieces_ok + int(row["pieces_bad"] or 0))
|
||||||
|
if (pieces_ok + int(row["pieces_bad"] or 0)) > 0
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Tagesbericht und Push für Wartung/Bedarf/Termine."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from grotti import bedarf as bedarf_mod
|
||||||
|
from grotti import jetzt as jetzt_mod
|
||||||
|
from grotti import notify
|
||||||
|
from grotti import store
|
||||||
|
from grotti.db import connect
|
||||||
|
|
||||||
|
|
||||||
|
def _yesterday_stats(conn):
|
||||||
|
today = date.today()
|
||||||
|
yesterday = today - timedelta(days=1)
|
||||||
|
start = datetime.combine(yesterday, datetime.min.time(), tzinfo=timezone.utc)
|
||||||
|
end = datetime.combine(today, datetime.min.time(), tzinfo=timezone.utc)
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS runs,
|
||||||
|
COALESCE(SUM(duration_s), 0) AS duration_s,
|
||||||
|
COALESCE(SUM(weight_used_g), 0) AS grams,
|
||||||
|
COALESCE(SUM(CASE WHEN verdict = 'ausschuss' THEN 1 ELSE 0 END), 0) AS scrap,
|
||||||
|
COALESCE(SUM(pieces_ok), 0) AS pieces_ok,
|
||||||
|
COALESCE(SUM(pieces_bad), 0) AS pieces_bad
|
||||||
|
FROM runs
|
||||||
|
WHERE started_at >= %s AND started_at < %s
|
||||||
|
""",
|
||||||
|
(start, end),
|
||||||
|
).fetchone()
|
||||||
|
return {
|
||||||
|
"date": yesterday.isoformat(),
|
||||||
|
"runs": int(row["runs"] or 0),
|
||||||
|
"duration_s": float(row["duration_s"] or 0),
|
||||||
|
"grams": float(row["grams"] or 0),
|
||||||
|
"scrap": int(row["scrap"] or 0),
|
||||||
|
"pieces_ok": int(row["pieces_ok"] or 0),
|
||||||
|
"pieces_bad": int(row["pieces_bad"] or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_text(conn, watcher_latest=None, printers=None):
|
||||||
|
stats = _yesterday_stats(conn)
|
||||||
|
waiting = len([j for j in store.list_print_queue(conn, limit=200) if j.get("status") == "waiting"])
|
||||||
|
eta = jetzt_mod.farm_eta([
|
||||||
|
{"remain": (live or {}).get("remain") or 0}
|
||||||
|
for live in (watcher_latest or [])
|
||||||
|
if (live or {}).get("state") in ("printing", "paused", "heating")
|
||||||
|
])
|
||||||
|
lines = [
|
||||||
|
"Grotti Tagesbericht %s" % stats["date"],
|
||||||
|
"Läufe: %d · Druckzeit: %.1f h · Material: %.0f g" % (
|
||||||
|
stats["runs"], stats["duration_s"] / 3600.0, stats["grams"],
|
||||||
|
),
|
||||||
|
"Ausschuss-Läufe: %d · Stück gut/schlecht: %d/%d" % (
|
||||||
|
stats["scrap"], stats["pieces_ok"], stats["pieces_bad"],
|
||||||
|
),
|
||||||
|
"Heute in Warteschlange: %d" % waiting,
|
||||||
|
]
|
||||||
|
if eta.get("all_done_in_s"):
|
||||||
|
at = datetime.now(timezone.utc) + timedelta(seconds=eta["all_done_in_s"])
|
||||||
|
lines.append("Laufende Drucke fertig gegen %s Uhr" % at.astimezone().strftime("%H:%M"))
|
||||||
|
overdue = jetzt_mod.overdue_orders(conn, limit=5)
|
||||||
|
if overdue:
|
||||||
|
lines.append("Überfällig: " + ", ".join(o["title"] for o in overdue))
|
||||||
|
short = (bedarf_mod.compute(conn).get("shortfall") or [])[:3]
|
||||||
|
if short:
|
||||||
|
bits = []
|
||||||
|
for item in short:
|
||||||
|
label = " ".join(x for x in [item.get("material_type"), item.get("color")] if x) or "Filament"
|
||||||
|
bits.append("%s (−%sg)" % (label, int(item.get("shortfall_g") or 0)))
|
||||||
|
lines.append("Bedarf: " + ", ".join(bits))
|
||||||
|
parts = jetzt_mod.overdue_maintenance(conn, printers or store.list_printers(conn), limit=5)
|
||||||
|
if parts:
|
||||||
|
lines.append("Wartung: " + ", ".join("%s/%s" % (p["printer"], p["label"]) for p in parts))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def push_ops_alerts(conn, cfg, printers=None):
|
||||||
|
"""Einmalige Push-Hinweise für Bedarf, Wartung, Termine (mit Dedup über Events).
|
||||||
|
|
||||||
|
kind beim Speichern muss zum Dedup-Check passen — sonst jede Minute ein neuer Eintrag
|
||||||
|
(DailyReportThread tickt alle 60 s).
|
||||||
|
"""
|
||||||
|
printers = printers or store.list_printers(conn)
|
||||||
|
for order in jetzt_mod.overdue_orders(conn, limit=10):
|
||||||
|
msg = "Auftrag überfällig: %s (fällig %s)" % (order["title"], order.get("due_at") or "")
|
||||||
|
key = "order:%s" % order["id"]
|
||||||
|
if not store.recent_event_kind(conn, key, "auftrag", within_s=86400):
|
||||||
|
notify.record(conn, cfg, key, order["title"], "auftrag", msg, dedupe_s=86400)
|
||||||
|
for part in jetzt_mod.overdue_maintenance(conn, printers, limit=10):
|
||||||
|
msg = "Wartung fällig: %s · %s" % (part["printer"], part["label"])
|
||||||
|
key = "maint:%s" % part["id"]
|
||||||
|
if not store.recent_event_kind(conn, key, "wartung", within_s=86400):
|
||||||
|
notify.record(conn, cfg, key, part["printer"], "wartung", msg, dedupe_s=86400)
|
||||||
|
for item in (bedarf_mod.compute(conn).get("shortfall") or [])[:5]:
|
||||||
|
label = " ".join(x for x in [item.get("material_type"), item.get("color")] if x) or "Filament"
|
||||||
|
msg = "Bedarf: %s — %s g fehlen" % (label, int(item.get("shortfall_g") or 0))
|
||||||
|
key = "bedarf:%s:%s" % (item.get("material_type") or "", item.get("color") or "")
|
||||||
|
if not store.recent_event_kind(conn, key, "bedarf", within_s=21600):
|
||||||
|
notify.record(conn, cfg, key, label, "bedarf", msg, dedupe_s=21600)
|
||||||
|
|
||||||
|
|
||||||
|
class DailyReportThread:
|
||||||
|
"""Sendet um 7:00 Ortszeit den Tagesbericht und prüft periodisch Ops-Alerts."""
|
||||||
|
|
||||||
|
def __init__(self, database_url, cfg, watcher=None):
|
||||||
|
self.database_url = database_url
|
||||||
|
self.cfg = cfg
|
||||||
|
self.watcher = watcher
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread = None
|
||||||
|
self._last_report_day = None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._thread = threading.Thread(target=self._loop, name="grotti-daily", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
def _loop(self):
|
||||||
|
while not self._stop.wait(60):
|
||||||
|
try:
|
||||||
|
self._tick()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _tick(self):
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
with connect(self.database_url) as conn:
|
||||||
|
push_ops_alerts(conn, self.cfg, store.list_printers(conn))
|
||||||
|
conn.commit()
|
||||||
|
if now.hour == 7 and now.minute < 2:
|
||||||
|
day = now.date().isoformat()
|
||||||
|
if self._last_report_day == day:
|
||||||
|
return
|
||||||
|
latest = (self.watcher.latest if self.watcher else None) or []
|
||||||
|
text = build_text(conn, latest, store.list_printers(conn))
|
||||||
|
notify.send_telegram(self.cfg, text)
|
||||||
|
notify.send_ntfy(self.cfg, text)
|
||||||
|
store.add_event(conn, "", "Grotti", "fertig", text.split("\n")[0])
|
||||||
|
conn.commit()
|
||||||
|
self._last_report_day = day
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
"""PostgreSQL-Verbindung und Schema."""
|
"""PostgreSQL-Verbindung, Pool und Migrationen."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
@@ -8,22 +11,109 @@ from urllib.parse import urlparse, urlunparse
|
|||||||
import psycopg
|
import psycopg
|
||||||
from psycopg import sql
|
from psycopg import sql
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
SCHEMA = (Path(__file__).parent / "schema.sql").read_text(encoding="utf-8")
|
from grotti import logutil
|
||||||
|
|
||||||
|
log = logutil.get("grotti.db")
|
||||||
|
|
||||||
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
|
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
|
||||||
|
|
||||||
|
_pool: ConnectionPool | None = None
|
||||||
|
_pool_lock = threading.Lock()
|
||||||
|
_user_count_cache = {"n": None, "at": 0.0}
|
||||||
|
_USER_COUNT_TTL = 5.0
|
||||||
|
|
||||||
|
|
||||||
def database_name(url):
|
def database_name(url):
|
||||||
name = urlparse(url).path.lstrip("/")
|
name = urlparse(url).path.lstrip("/")
|
||||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name or ""):
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name or ""):
|
||||||
raise SystemExit("Der Datenbankname in database_url darf nur Buchstaben, Zahlen und Unterstrich enthalten.")
|
raise SystemExit(
|
||||||
|
"Der Datenbankname in database_url darf nur Buchstaben, Zahlen und Unterstrich enthalten."
|
||||||
|
)
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
def apply_schema(conn):
|
def _migration_files():
|
||||||
for statement in SCHEMA.split(";"):
|
files = sorted(MIGRATIONS_DIR.glob("*.sql"))
|
||||||
|
out = []
|
||||||
|
for path in files:
|
||||||
|
match = re.match(r"^(\d+)_", path.name)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
out.append((int(match.group(1)), path))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _split_statements(text):
|
||||||
|
"""Einfache Trennung an Semikolon — Migrationen ohne Funktionen mit ; im Körper."""
|
||||||
|
parts = []
|
||||||
|
for statement in text.split(";"):
|
||||||
cleaned = statement.strip()
|
cleaned = statement.strip()
|
||||||
if cleaned:
|
if cleaned:
|
||||||
conn.execute(cleaned)
|
parts.append(cleaned)
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_version_table(conn):
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
version integer PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
applied_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _applied_versions(conn):
|
||||||
|
rows = conn.execute("SELECT version FROM schema_version ORDER BY version").fetchall()
|
||||||
|
return {int(row["version"] if isinstance(row, dict) else row[0]) for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_schema_present(conn):
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'printers'
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_migrations(conn):
|
||||||
|
"""Wendet fehlende Migrationen an. Bestehende Installationen ohne Version bekommen 001 gutgeschrieben."""
|
||||||
|
_ensure_version_table(conn)
|
||||||
|
applied = _applied_versions(conn)
|
||||||
|
migrations = _migration_files()
|
||||||
|
if not migrations and SCHEMA_LEGACY.exists():
|
||||||
|
# Fallback: altes schema.sql einmalig
|
||||||
|
for statement in _split_statements(SCHEMA_LEGACY.read_text(encoding="utf-8")):
|
||||||
|
conn.execute(statement)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not applied and _legacy_schema_present(conn):
|
||||||
|
# Schema war schon da (vor Migrationssystem) → Basis als angewandt markieren.
|
||||||
|
base = next((m for m in migrations if m[0] == 1), None)
|
||||||
|
if base:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO schema_version (version, name) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||||
|
(1, base[1].name),
|
||||||
|
)
|
||||||
|
applied.add(1)
|
||||||
|
|
||||||
|
for version, path in migrations:
|
||||||
|
if version in applied:
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
for statement in _split_statements(text):
|
||||||
|
conn.execute(statement)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO schema_version (version, name) VALUES (%s, %s)",
|
||||||
|
(version, path.name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def prepare(url):
|
def prepare(url):
|
||||||
@@ -31,14 +121,16 @@ def prepare(url):
|
|||||||
admin = urlunparse(urlparse(url)._replace(path="/postgres"))
|
admin = urlunparse(urlparse(url)._replace(path="/postgres"))
|
||||||
try:
|
try:
|
||||||
with psycopg.connect(admin, autocommit=True, connect_timeout=5) as conn:
|
with psycopg.connect(admin, autocommit=True, connect_timeout=5) as conn:
|
||||||
exists = conn.execute("SELECT 1 FROM pg_database WHERE datname = %s", (name,)).fetchone()
|
exists = conn.execute(
|
||||||
|
"SELECT 1 FROM pg_database WHERE datname = %s", (name,)
|
||||||
|
).fetchone()
|
||||||
if not exists:
|
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:
|
except psycopg.Error as exc:
|
||||||
pass
|
log.warning("Datenbank anlegen: %s", exc)
|
||||||
try:
|
try:
|
||||||
with psycopg.connect(url, connect_timeout=5) as conn:
|
with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
|
||||||
apply_schema(conn)
|
apply_migrations(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except psycopg.Error as exc:
|
except psycopg.Error as exc:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
@@ -50,9 +142,50 @@ def prepare(url):
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def init_pool(url, min_size=2, max_size=16):
|
||||||
|
global _pool
|
||||||
|
with _pool_lock:
|
||||||
|
if _pool is not None:
|
||||||
|
return _pool
|
||||||
|
_pool = ConnectionPool(
|
||||||
|
conninfo=url,
|
||||||
|
min_size=min_size,
|
||||||
|
max_size=max_size,
|
||||||
|
kwargs={"row_factory": dict_row, "connect_timeout": 5},
|
||||||
|
open=True,
|
||||||
|
name="grotti",
|
||||||
|
)
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
def close_pool():
|
||||||
|
global _pool
|
||||||
|
with _pool_lock:
|
||||||
|
if _pool is not None:
|
||||||
|
_pool.close()
|
||||||
|
_pool = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_pool():
|
||||||
|
if _pool is None:
|
||||||
|
raise RuntimeError("Datenbank-Pool ist nicht gestartet.")
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def connect(url):
|
def connect(url=None):
|
||||||
conn = psycopg.connect(url, row_factory=dict_row)
|
"""Verbindung aus dem Pool, sonst einmalig direkt (Tests / prepare)."""
|
||||||
|
if _pool is not None:
|
||||||
|
with _pool.connection() as conn:
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
return
|
||||||
|
if not url:
|
||||||
|
raise RuntimeError("Keine database_url und kein Pool.")
|
||||||
|
conn = psycopg.connect(url, row_factory=dict_row, connect_timeout=5)
|
||||||
try:
|
try:
|
||||||
yield conn
|
yield conn
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -60,3 +193,35 @@ def connect(url):
|
|||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def cached_user_count(conn):
|
||||||
|
"""Zählt Benutzer mit kurzem Cache — vermeidet count(*) bei jedem Request."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
cached = _user_count_cache["n"]
|
||||||
|
if cached is not None and (now - _user_count_cache["at"]) < _USER_COUNT_TTL:
|
||||||
|
return cached
|
||||||
|
n = int(conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"])
|
||||||
|
_user_count_cache["n"] = n
|
||||||
|
_user_count_cache["at"] = now
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_user_count():
|
||||||
|
_user_count_cache["n"] = None
|
||||||
|
_user_count_cache["at"] = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def prune_run_samples(conn, keep_days=90):
|
||||||
|
"""Alte Temperaturproben löschen (Retention)."""
|
||||||
|
days = max(7, int(keep_days))
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM run_samples
|
||||||
|
WHERE sampled_at < now() - (%s || ' days')::interval
|
||||||
|
""",
|
||||||
|
(str(days),),
|
||||||
|
)
|
||||||
|
return row.rowcount if row is not None else 0
|
||||||
@@ -55,8 +55,8 @@ DEFAULT_SWEEP = {
|
|||||||
# Ein Punkt in der Bettmitte — kein LeviQ, kein 3×3, kein Heizen.
|
# Ein Punkt in der Bettmitte — kein LeviQ, kein 3×3, kein Heizen.
|
||||||
DEFAULT_CHECK = {
|
DEFAULT_CHECK = {
|
||||||
"type": "check",
|
"type": "check",
|
||||||
# Result is z= gegen Grenze. Temporär 3 mm — kalter Kunststoff an der Düse
|
# Result is z= gegen Grenze. 3 mm: kalter Kunststoff an der Düse
|
||||||
# tastet sonst oft 1–2 mm und meldet Restteil. Später wieder straffen.
|
# tastet sonst oft 1–2 mm und meldet Restteil.
|
||||||
"max_delta_mm": 3.0,
|
"max_delta_mm": 3.0,
|
||||||
"timeout_min": 2,
|
"timeout_min": 2,
|
||||||
"x": 125.0,
|
"x": 125.0,
|
||||||
@@ -454,11 +454,6 @@ def sweep_lower_to_push_script(sweep):
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
def sweep_probe_height_script(sweep):
|
|
||||||
"""Kompatibel: hoch, hinten, langsam auf z_push."""
|
|
||||||
return sweep_go_back_high_script(sweep) + "\n" + sweep_lower_to_push_script(sweep)
|
|
||||||
|
|
||||||
|
|
||||||
def assert_clear_before_lower(live, sweep, min_z_mm=15.0):
|
def assert_clear_before_lower(live, sweep, min_z_mm=15.0):
|
||||||
"""Vor dem Senken: Toolhead muss noch klar über dem Bett stehen."""
|
"""Vor dem Senken: Toolhead muss noch klar über dem Bett stehen."""
|
||||||
pos = (live or {}).get("position") or []
|
pos = (live or {}).get("position") or []
|
||||||
@@ -562,100 +557,6 @@ def check_lift_script(check):
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
def _axis_coords(lo, hi, count):
|
|
||||||
if count <= 1:
|
|
||||||
return [(lo + hi) / 2.0]
|
|
||||||
return [lo + (hi - lo) * i / (count - 1) for i in range(count)]
|
|
||||||
|
|
||||||
|
|
||||||
def _matrix_ok(matrix):
|
|
||||||
if not isinstance(matrix, list) or not matrix:
|
|
||||||
return False
|
|
||||||
width = None
|
|
||||||
for row in matrix:
|
|
||||||
if not isinstance(row, list) or not row:
|
|
||||||
return False
|
|
||||||
if width is None:
|
|
||||||
width = len(row)
|
|
||||||
if len(row) != width:
|
|
||||||
return False
|
|
||||||
for value in row:
|
|
||||||
if not isinstance(value, (int, float)):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def interpolate_mesh_z(mesh, x, y):
|
|
||||||
"""Bilinear aus Roh-probed_matrix. Klipper: Zeile 0 = mesh_min Y."""
|
|
||||||
matrix = mesh.get("matrix") if isinstance(mesh, dict) else None
|
|
||||||
if not _matrix_ok(matrix):
|
|
||||||
return None
|
|
||||||
mesh_min = mesh.get("mesh_min") or [0, 0]
|
|
||||||
mesh_max = mesh.get("mesh_max") or [0, 0]
|
|
||||||
try:
|
|
||||||
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
|
|
||||||
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
|
|
||||||
except (TypeError, ValueError, IndexError):
|
|
||||||
return None
|
|
||||||
rows = len(matrix)
|
|
||||||
cols = len(matrix[0])
|
|
||||||
if cols < 1 or rows < 1:
|
|
||||||
return None
|
|
||||||
if abs(x1 - x0) < 1e-9 or abs(y1 - y0) < 1e-9:
|
|
||||||
return float(matrix[0][0])
|
|
||||||
tx = 0.0 if cols == 1 else (float(x) - x0) / (x1 - x0) * (cols - 1)
|
|
||||||
ty = 0.0 if rows == 1 else (float(y) - y0) / (y1 - y0) * (rows - 1)
|
|
||||||
tx = max(0.0, min(float(cols - 1), tx))
|
|
||||||
ty = max(0.0, min(float(rows - 1), ty))
|
|
||||||
c0 = int(tx)
|
|
||||||
r0 = int(ty)
|
|
||||||
c1 = min(cols - 1, c0 + 1)
|
|
||||||
r1 = min(rows - 1, r0 + 1)
|
|
||||||
fx = tx - c0
|
|
||||||
fy = ty - r0
|
|
||||||
z00 = float(matrix[r0][c0])
|
|
||||||
z10 = float(matrix[r0][c1])
|
|
||||||
z01 = float(matrix[r1][c0])
|
|
||||||
z11 = float(matrix[r1][c1])
|
|
||||||
z0 = z00 * (1 - fx) + z10 * fx
|
|
||||||
z1 = z01 * (1 - fx) + z11 * fx
|
|
||||||
return z0 * (1 - fy) + z1 * fy
|
|
||||||
|
|
||||||
|
|
||||||
def mesh_delta_mm(baseline, measured):
|
|
||||||
"""Max |Δ| und Mittel |Δ|: Messpunkte gegen interpoliertes Baseline-Netz."""
|
|
||||||
if not isinstance(baseline, dict) or not _matrix_ok(baseline.get("matrix")):
|
|
||||||
raise GrottiError("Kein Bettnetz zum Vergleich — vorher nivellieren.", "mesh")
|
|
||||||
if not isinstance(measured, dict) or not _matrix_ok(measured.get("matrix")):
|
|
||||||
raise GrottiError("Neues Bettnetz fehlt nach dem Antasten.", "mesh")
|
|
||||||
matrix = measured["matrix"]
|
|
||||||
mesh_min = measured.get("mesh_min") or [0, 0]
|
|
||||||
mesh_max = measured.get("mesh_max") or [0, 0]
|
|
||||||
try:
|
|
||||||
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
|
|
||||||
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
|
|
||||||
except (TypeError, ValueError, IndexError) as exc:
|
|
||||||
raise GrottiError("Messnetz ohne gültigen Bereich.", "mesh") from exc
|
|
||||||
rows = len(matrix)
|
|
||||||
cols = len(matrix[0])
|
|
||||||
xs = _axis_coords(x0, x1, cols)
|
|
||||||
ys = _axis_coords(y0, y1, rows)
|
|
||||||
deltas = []
|
|
||||||
for ri, y in enumerate(ys):
|
|
||||||
for ci, x in enumerate(xs):
|
|
||||||
expected = interpolate_mesh_z(baseline, x, y)
|
|
||||||
if expected is None:
|
|
||||||
raise GrottiError("Baseline-Netz lässt sich nicht interpolieren.", "mesh")
|
|
||||||
deltas.append(abs(float(matrix[ri][ci]) - expected))
|
|
||||||
if not deltas:
|
|
||||||
raise GrottiError("Keine Messpunkte zum Vergleich.", "mesh")
|
|
||||||
return {
|
|
||||||
"max_delta_mm": max(deltas),
|
|
||||||
"mean_delta_mm": sum(deltas) / len(deltas),
|
|
||||||
"points": len(deltas),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def step_label(step):
|
def step_label(step):
|
||||||
if not isinstance(step, dict):
|
if not isinstance(step, dict):
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Thread-sicherer Pub/Sub für Server-Sent Events (Live-UI)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from queue import Empty, Queue
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_subscribers: list[Queue] = []
|
||||||
|
|
||||||
|
|
||||||
|
def subscribe() -> Queue:
|
||||||
|
q: Queue = Queue(maxsize=256)
|
||||||
|
with _lock:
|
||||||
|
_subscribers.append(q)
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def unsubscribe(q: Queue) -> None:
|
||||||
|
with _lock:
|
||||||
|
try:
|
||||||
|
_subscribers.remove(q)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def publish(kind: str, payload: dict | None = None) -> None:
|
||||||
|
msg = {"kind": kind or "message", "payload": payload if payload is not None else {}}
|
||||||
|
with _lock:
|
||||||
|
targets = list(_subscribers)
|
||||||
|
for q in targets:
|
||||||
|
try:
|
||||||
|
q.put_nowait(msg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def wait_event(q: Queue, timeout_s: float) -> dict | None:
|
||||||
|
try:
|
||||||
|
return q.get(timeout=timeout_s)
|
||||||
|
except Empty:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""Aggregierte „Jetzt“-Handlungsliste für Sidebar und Mobile."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from grotti import bedarf as bedarf_mod
|
||||||
|
from grotti import maintenance
|
||||||
|
from grotti import store
|
||||||
|
|
||||||
|
|
||||||
|
def _filename_label(filename):
|
||||||
|
name = (filename or "").strip()
|
||||||
|
for suffix in (".gcode.3mf", ".gcode", ".3mf"):
|
||||||
|
if name.lower().endswith(suffix):
|
||||||
|
name = name[: -len(suffix)]
|
||||||
|
break
|
||||||
|
while name and name[0].isdigit():
|
||||||
|
parts = name.split("-", 1)
|
||||||
|
if len(parts) == 2 and parts[0].isdigit():
|
||||||
|
name = parts[1]
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return name or filename or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_remain(remain_s):
|
||||||
|
remain = float(remain_s or 0)
|
||||||
|
if remain <= 0:
|
||||||
|
return None
|
||||||
|
at = datetime.now(timezone.utc).timestamp() + remain
|
||||||
|
return datetime.fromtimestamp(at, tz=timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def farm_eta(running_items):
|
||||||
|
"""Früheste und späteste Fertig-Zeit aus laufenden Drucken."""
|
||||||
|
remains = [
|
||||||
|
float(item.get("remain") or 0)
|
||||||
|
for item in (running_items or [])
|
||||||
|
if float(item.get("remain") or 0) > 0
|
||||||
|
]
|
||||||
|
if not remains:
|
||||||
|
return {"all_done_at": None, "all_done_in_s": None, "next_free_in_s": None}
|
||||||
|
return {
|
||||||
|
"all_done_at": _iso_remain(max(remains)),
|
||||||
|
"all_done_in_s": max(remains),
|
||||||
|
"next_free_in_s": min(remains),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def overdue_orders(conn, limit=8):
|
||||||
|
today = date.today()
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT o.*, c.name AS customer_name
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN customers c ON c.id = o.customer_id
|
||||||
|
WHERE o.due_at IS NOT NULL
|
||||||
|
AND o.due_at < %s
|
||||||
|
AND o.status NOT IN ('fertig', 'geliefert', 'bezahlt')
|
||||||
|
ORDER BY o.due_at ASC, o.id ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(today, int(limit)),
|
||||||
|
).fetchall()
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
out.append({
|
||||||
|
"id": row["id"],
|
||||||
|
"title": row["title"],
|
||||||
|
"status": row["status"],
|
||||||
|
"due_at": row["due_at"].isoformat() if row.get("due_at") else None,
|
||||||
|
"customer": row.get("customer_name") or "",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def overdue_maintenance(conn, printers, limit=8):
|
||||||
|
items = []
|
||||||
|
for printer in printers or []:
|
||||||
|
host = printer.get("host") or ""
|
||||||
|
counters = maintenance.counters_for_host(conn, host)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM maintenance_parts WHERE printer_host = %s ORDER BY label, part_key, id",
|
||||||
|
(host,),
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
part = maintenance.part_out(row, counters, conn=conn)
|
||||||
|
if not (part.get("overdue_hours") or part.get("overdue_meters")):
|
||||||
|
continue
|
||||||
|
items.append({
|
||||||
|
"id": part["id"],
|
||||||
|
"host": host,
|
||||||
|
"printer": printer.get("name") or host,
|
||||||
|
"label": part.get("label") or part.get("part_key") or "Teil",
|
||||||
|
"overdue_hours": bool(part.get("overdue_hours")),
|
||||||
|
"overdue_meters": bool(part.get("overdue_meters")),
|
||||||
|
})
|
||||||
|
if len(items) >= limit:
|
||||||
|
return items
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def build(conn, watcher_latest, printers=None, send_jobs=None):
|
||||||
|
"""Priorisierte Handlungsliste + laufende Drucke + Farm-ETA."""
|
||||||
|
printers = printers if printers is not None else store.list_printers(conn)
|
||||||
|
clears = store.bed_clear_map(conn)
|
||||||
|
busy_hosts = {
|
||||||
|
row["host"]
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT DISTINCT host FROM print_queue WHERE status = 'running'"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
queue = store.list_print_queue(conn, limit=80)
|
||||||
|
waiting = [j for j in queue if j.get("status") == "waiting"]
|
||||||
|
errors = [j for j in queue if j.get("status") == "error"]
|
||||||
|
running_q = [j for j in queue if j.get("status") == "running"]
|
||||||
|
|
||||||
|
actions = []
|
||||||
|
|
||||||
|
for live in watcher_latest or []:
|
||||||
|
host = live.get("host") or ""
|
||||||
|
state = (live.get("state") or "").lower()
|
||||||
|
if state == "paused" and (live.get("filament_empty") or (live.get("pause_reason") or "").strip()):
|
||||||
|
actions.append({
|
||||||
|
"kind": "filament_pause",
|
||||||
|
"priority": 12,
|
||||||
|
"host": host,
|
||||||
|
"printer": live.get("name") or host,
|
||||||
|
"title": "Material nachlegen",
|
||||||
|
"detail": (live.get("pause_reason") or "Drucker pausiert — Filament leer?").strip(),
|
||||||
|
"filename": live.get("filename") or "",
|
||||||
|
"href": "/#" + host,
|
||||||
|
})
|
||||||
|
|
||||||
|
for live in watcher_latest or []:
|
||||||
|
host = live.get("host") or ""
|
||||||
|
state = (live.get("state") or "").lower()
|
||||||
|
if not clears.get(host) or host in busy_hosts or state in ("printing", "paused", "heating"):
|
||||||
|
continue
|
||||||
|
actions.append({
|
||||||
|
"kind": "clear",
|
||||||
|
"priority": 10,
|
||||||
|
"host": host,
|
||||||
|
"printer": live.get("name") or host,
|
||||||
|
"title": "Platte räumen",
|
||||||
|
"detail": _filename_label(live.get("filename") or ""),
|
||||||
|
"filename": live.get("filename") or "",
|
||||||
|
"href": "/#" + host,
|
||||||
|
})
|
||||||
|
|
||||||
|
for job in errors[:6]:
|
||||||
|
actions.append({
|
||||||
|
"kind": "queue_error",
|
||||||
|
"priority": 20,
|
||||||
|
"id": job.get("id"),
|
||||||
|
"host": job.get("host") or "",
|
||||||
|
"printer": job.get("printer_name") or job.get("host") or "",
|
||||||
|
"title": "Druck fehlgeschlagen",
|
||||||
|
"detail": job.get("message") or _filename_label(job.get("filename") or ""),
|
||||||
|
"filename": job.get("filename") or "",
|
||||||
|
"is_ablauf": bool(job.get("is_ablauf")),
|
||||||
|
"step_index": job.get("step_index"),
|
||||||
|
"step_label_current": job.get("step_label_current") or "",
|
||||||
|
"href": "/warteschlange",
|
||||||
|
})
|
||||||
|
|
||||||
|
bedarf = bedarf_mod.compute(conn)
|
||||||
|
for item in (bedarf.get("shortfall") or [])[:5]:
|
||||||
|
label = " · ".join(
|
||||||
|
x for x in [item.get("material_type"), item.get("color")] if x
|
||||||
|
) or "Filament"
|
||||||
|
actions.append({
|
||||||
|
"kind": "bedarf",
|
||||||
|
"priority": 30,
|
||||||
|
"title": "Spule fehlt",
|
||||||
|
"detail": "%s — %s g fehlen" % (label, int(item.get("shortfall_g") or 0)),
|
||||||
|
"material_type": item.get("material_type") or "",
|
||||||
|
"color": item.get("color") or "",
|
||||||
|
"shortfall_g": item.get("shortfall_g") or 0,
|
||||||
|
"href": "/material#bedarf",
|
||||||
|
})
|
||||||
|
|
||||||
|
for part in overdue_maintenance(conn, printers):
|
||||||
|
actions.append({
|
||||||
|
"kind": "wartung",
|
||||||
|
"priority": 40,
|
||||||
|
"id": part["id"],
|
||||||
|
"host": part["host"],
|
||||||
|
"printer": part["printer"],
|
||||||
|
"title": "Wartung fällig",
|
||||||
|
"detail": "%s · %s" % (part["printer"], part["label"]),
|
||||||
|
"href": "/wartung",
|
||||||
|
})
|
||||||
|
|
||||||
|
for order in overdue_orders(conn):
|
||||||
|
actions.append({
|
||||||
|
"kind": "auftrag",
|
||||||
|
"priority": 50,
|
||||||
|
"id": order["id"],
|
||||||
|
"title": "Auftrag überfällig",
|
||||||
|
"detail": "%s · fällig %s" % (order["title"], order.get("due_at") or ""),
|
||||||
|
"due_at": order.get("due_at"),
|
||||||
|
"href": "/auftraege",
|
||||||
|
})
|
||||||
|
|
||||||
|
shortfall = bedarf.get("shortfall") or []
|
||||||
|
for job in waiting[:8]:
|
||||||
|
tip = (job.get("wait_reason") or "").strip()
|
||||||
|
if not tip and shortfall:
|
||||||
|
# Fallback ohne Routing-Hinweis
|
||||||
|
tip = ""
|
||||||
|
if not tip:
|
||||||
|
continue
|
||||||
|
actions.append({
|
||||||
|
"kind": "waiting",
|
||||||
|
"priority": 60,
|
||||||
|
"id": job.get("id"),
|
||||||
|
"host": job.get("host") or "",
|
||||||
|
"printer": job.get("printer_name") or job.get("host") or "",
|
||||||
|
"title": "Wartet auf Material",
|
||||||
|
"detail": tip,
|
||||||
|
"filename": job.get("filename") or "",
|
||||||
|
"reason": tip,
|
||||||
|
"href": "/warteschlange",
|
||||||
|
})
|
||||||
|
|
||||||
|
actions.sort(key=lambda a: (a.get("priority") or 99, a.get("id") or 0))
|
||||||
|
|
||||||
|
live_by_host = {}
|
||||||
|
running = []
|
||||||
|
for live in watcher_latest or []:
|
||||||
|
state = (live.get("state") or "").lower()
|
||||||
|
if state not in ("printing", "paused", "heating"):
|
||||||
|
continue
|
||||||
|
item = {
|
||||||
|
"host": live.get("host") or "",
|
||||||
|
"name": live.get("name") or "",
|
||||||
|
"state": state,
|
||||||
|
"progress": live.get("progress") or 0,
|
||||||
|
"remain": live.get("remain") or 0,
|
||||||
|
"filename": live.get("filename") or "",
|
||||||
|
"filament_empty": bool(live.get("filament_empty")),
|
||||||
|
"pause_reason": (live.get("pause_reason") or "").strip(),
|
||||||
|
"queue_id": None,
|
||||||
|
}
|
||||||
|
live_by_host[item["host"]] = item
|
||||||
|
running.append(item)
|
||||||
|
for job in running_q:
|
||||||
|
host = job.get("host") or ""
|
||||||
|
if host in live_by_host:
|
||||||
|
live_by_host[host]["queue_id"] = job.get("id")
|
||||||
|
live_by_host[host]["is_ablauf"] = bool(job.get("is_ablauf"))
|
||||||
|
live_by_host[host]["step_labels"] = job.get("step_labels")
|
||||||
|
live_by_host[host]["display_step_index"] = job.get("display_step_index")
|
||||||
|
live_by_host[host]["step_index"] = job.get("step_index")
|
||||||
|
else:
|
||||||
|
running.append({
|
||||||
|
"host": host,
|
||||||
|
"name": job.get("printer_name") or host,
|
||||||
|
"state": "running",
|
||||||
|
"progress": job.get("progress") or 0,
|
||||||
|
"remain": job.get("remain") or 0,
|
||||||
|
"filename": job.get("filename") or "",
|
||||||
|
"queue_id": job.get("id"),
|
||||||
|
"is_ablauf": bool(job.get("is_ablauf")),
|
||||||
|
})
|
||||||
|
|
||||||
|
sends = []
|
||||||
|
for job in send_jobs or []:
|
||||||
|
if job.get("status") in ("pending", "running"):
|
||||||
|
sends.append(job)
|
||||||
|
elif job.get("status") == "error" and len(sends) < 2:
|
||||||
|
sends.append(job)
|
||||||
|
|
||||||
|
alert_kinds = list(store.ALERT_EVENT_KINDS)
|
||||||
|
alert_items = store.list_events(conn, limit=5, unread_only=True, kinds=alert_kinds)
|
||||||
|
unread = store.unread_event_count(conn, kinds=alert_kinds)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"actions": actions,
|
||||||
|
"running": running,
|
||||||
|
"waiting_count": len(waiting),
|
||||||
|
"sends": sends[:5],
|
||||||
|
"alerts": alert_items,
|
||||||
|
"unread_alerts": unread,
|
||||||
|
"eta": farm_eta(running),
|
||||||
|
"bedarf_shortfall": len(shortfall),
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import urllib.error
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from grotti import acm, eject, library, moonraker, store
|
from grotti import acm, catalog, eject, library, moonraker, store, usage
|
||||||
from grotti.cost import energy_cost
|
from grotti.cost import energy_cost
|
||||||
from grotti.errors import GrottiError
|
from grotti.errors import GrottiError
|
||||||
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
|
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
|
||||||
@@ -15,17 +15,7 @@ TERMINAL = {"completed", "cancelled", "error", "interrupted"}
|
|||||||
|
|
||||||
|
|
||||||
def weight_of(metadata):
|
def weight_of(metadata):
|
||||||
raw = (metadata or {}).get("filament_weight_total")
|
return usage.weight_sum(metadata)
|
||||||
if isinstance(raw, list):
|
|
||||||
return sum(float(item or 0) for item in raw)
|
|
||||||
if isinstance(raw, (int, float)):
|
|
||||||
return float(raw)
|
|
||||||
if isinstance(raw, str):
|
|
||||||
try:
|
|
||||||
return float(raw.replace(",", "."))
|
|
||||||
except ValueError:
|
|
||||||
return 0.0
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def material_of(metadata):
|
def material_of(metadata):
|
||||||
@@ -35,27 +25,97 @@ def material_of(metadata):
|
|||||||
return str(raw).split(";")[0].strip()
|
return str(raw).split(";")[0].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _ace_map_of(sent):
|
||||||
|
if not sent:
|
||||||
|
return []
|
||||||
|
raw = sent.get("ace_map")
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return acm.normalize_mapping(raw)
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
return acm.normalize_mapping(json.loads(raw))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _targets_from_ace(conn, host, ace):
|
||||||
|
targets = []
|
||||||
|
for item in ace:
|
||||||
|
slot = int(item["ams_index"]) + 1
|
||||||
|
row = store.spool_at_slot(conn, host, slot)
|
||||||
|
if row:
|
||||||
|
targets.append(row)
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
def unix_to_dt(value):
|
def unix_to_dt(value):
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
return datetime.fromtimestamp(float(value), timezone.utc)
|
return datetime.fromtimestamp(float(value), timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def choose_spool(conn, host, material, hinted_id):
|
def choose_spool(conn, host, material, hinted_id, ended_at=None):
|
||||||
|
"""Spule für History-Abzug. Ohne Hint nur Rollen, die vor Druckende existierten."""
|
||||||
|
at_host = [
|
||||||
|
row for row in store.spools_for_printer(conn, host)
|
||||||
|
if _spool_valid_for_end(row, ended_at)
|
||||||
|
]
|
||||||
if hinted_id:
|
if hinted_id:
|
||||||
row = store.get_spool_row(conn, hinted_id)
|
row = store.get_spool_row(conn, hinted_id)
|
||||||
if row:
|
if row and not _spool_valid_for_end(row, ended_at):
|
||||||
|
return None
|
||||||
|
# Hint nur nutzen, wenn die Rolle noch am Drucker liegt — sonst Rollenwechsel
|
||||||
|
if row and (row.get("printer_host") or "") == (host or ""):
|
||||||
return row
|
return row
|
||||||
rows = store.spools_for_printer(conn, host)
|
if row and not at_host:
|
||||||
if len(rows) == 1:
|
# Rolle schon im Regal, kein Nachfolger am Host → Hint behalten (alte Jobs)
|
||||||
return rows[0]
|
return row
|
||||||
|
if len(at_host) == 1:
|
||||||
|
return at_host[0]
|
||||||
if material:
|
if material:
|
||||||
same = [row for row in rows if (row["material_type"] or "").lower() == material.lower()]
|
same = [row for row in at_host if (row["material_type"] or "").lower() == material.lower()]
|
||||||
if len(same) == 1:
|
if len(same) == 1:
|
||||||
return same[0]
|
return same[0]
|
||||||
|
if hinted_id:
|
||||||
|
row = store.get_spool_row(conn, hinted_id)
|
||||||
|
if row and _spool_valid_for_end(row, ended_at):
|
||||||
|
return row
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_tracking_spool(conn, host, hinted_id, ace_map=None):
|
||||||
|
"""Spule für Live-Tracking: am Host liegende Rolle schlägt veralteten Send-Hint."""
|
||||||
|
current = resolve_spools_at_host(conn, host, ace_map=ace_map)
|
||||||
|
if len(current) == 1:
|
||||||
|
return current[0]["id"]
|
||||||
|
if hinted_id:
|
||||||
|
row = store.get_spool_row(conn, hinted_id)
|
||||||
|
if row and (row.get("printer_host") or "") == (host or ""):
|
||||||
|
return hinted_id
|
||||||
|
if current:
|
||||||
|
return current[0]["id"]
|
||||||
|
return hinted_id
|
||||||
|
|
||||||
|
|
||||||
|
def _spool_valid_for_end(spool, ended_at):
|
||||||
|
"""Verhindert Abzug alter Moonraker-Jobs auf neu eingelegte Spulen."""
|
||||||
|
if ended_at is None or not spool:
|
||||||
|
return True
|
||||||
|
created = spool.get("created_at")
|
||||||
|
if created is None:
|
||||||
|
return True
|
||||||
|
end = ended_at
|
||||||
|
if getattr(created, "tzinfo", None) is None and getattr(end, "tzinfo", None) is not None:
|
||||||
|
created = created.replace(tzinfo=timezone.utc)
|
||||||
|
if getattr(end, "tzinfo", None) is None and getattr(created, "tzinfo", None) is not None:
|
||||||
|
end = end.replace(tzinfo=timezone.utc)
|
||||||
|
try:
|
||||||
|
return created <= end
|
||||||
|
except TypeError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def file_material_types(file_row):
|
def file_material_types(file_row):
|
||||||
types = []
|
types = []
|
||||||
for item in (file_row or {}).get("filaments") or []:
|
for item in (file_row or {}).get("filaments") or []:
|
||||||
@@ -72,14 +132,7 @@ def resolve_spools_at_host(conn, host, ace_map=None):
|
|||||||
if mapping:
|
if mapping:
|
||||||
for item in mapping:
|
for item in mapping:
|
||||||
slot = int(item["ams_index"]) + 1
|
slot = int(item["ams_index"]) + 1
|
||||||
row = conn.execute(
|
row = store.spool_at_slot(conn, host, slot)
|
||||||
"""
|
|
||||||
SELECT * FROM spools
|
|
||||||
WHERE printer_host = %s AND ace_slot = %s
|
|
||||||
ORDER BY id LIMIT 1
|
|
||||||
""",
|
|
||||||
(host, slot),
|
|
||||||
).fetchone()
|
|
||||||
if row:
|
if row:
|
||||||
found.append(row)
|
found.append(row)
|
||||||
return found
|
return found
|
||||||
@@ -96,21 +149,8 @@ def resolve_spools_at_host(conn, host, ace_map=None):
|
|||||||
|
|
||||||
|
|
||||||
def assert_material_match(file_row, spools):
|
def assert_material_match(file_row, spools):
|
||||||
"""Datei-Material gegen eingelegte Rollen — warnt nicht bei leerer Liste."""
|
"""Falsche Sorte bricht den Start nicht ab. Die Oberfläche warnt vorher."""
|
||||||
want = {t.lower() for t in file_material_types(file_row)}
|
return
|
||||||
if not want or not spools:
|
|
||||||
return
|
|
||||||
have = {(row.get("material_type") or "").strip().lower() for row in spools}
|
|
||||||
have.discard("")
|
|
||||||
if not have:
|
|
||||||
return
|
|
||||||
if want.isdisjoint(have):
|
|
||||||
raise GrottiError(
|
|
||||||
"Material passt nicht: Datei braucht %s, im Drucker steckt %s."
|
|
||||||
% (", ".join(sorted(want)), ", ".join(sorted(have)) or "unbekannt"),
|
|
||||||
"material",
|
|
||||||
409,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def assert_spool_at_host(conn, spool_id, host):
|
def assert_spool_at_host(conn, spool_id, host):
|
||||||
@@ -128,50 +168,153 @@ def assert_spool_at_host(conn, spool_id, host):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def apply_deduction(conn, run, energy):
|
def apply_deduction(conn, run, energy, metadata=None):
|
||||||
if run["deducted"] or run["weight_g"] <= 0 or run["state"] not in TERMINAL:
|
if run["deducted"] or run["state"] not in TERMINAL:
|
||||||
return False
|
return False
|
||||||
weight = float(run["weight_g"] or 0)
|
planned = float(run.get("weight_planned_g") or run.get("weight_g") or 0)
|
||||||
# ACE: Abzug anteilig auf die Plätze der letzten Sendung
|
used = float(run.get("weight_used_g") or 0)
|
||||||
|
source = run.get("weight_source") or ""
|
||||||
|
if used <= 0 and planned > 0 and source != "none":
|
||||||
|
used, source = usage.resolve_used_grams(
|
||||||
|
planned,
|
||||||
|
run.get("filament_used_mm") or 0,
|
||||||
|
run.get("filament_total_mm") or 0,
|
||||||
|
run.get("progress_last") or 0,
|
||||||
|
run["state"],
|
||||||
|
run.get("duration_s") or 0,
|
||||||
|
)
|
||||||
|
if used <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
sent = store.latest_send(conn, run["printer_host"], run.get("filename") or "")
|
sent = store.latest_send(conn, run["printer_host"], run.get("filename") or "")
|
||||||
ace = []
|
ace = _ace_map_of(sent)
|
||||||
if sent:
|
meta = metadata or {}
|
||||||
raw = sent.get("ace_map")
|
if not meta and run.get("file_id"):
|
||||||
if isinstance(raw, list):
|
file_row = store.get_file_row(conn, run["file_id"]) if hasattr(store, "get_file_row") else None
|
||||||
ace = raw
|
if file_row:
|
||||||
elif isinstance(raw, str):
|
meta = {"filament_weight_total": file_row.get("weight_g") or planned}
|
||||||
try:
|
|
||||||
ace = json.loads(raw)
|
store.close_open_segments(conn, run["id"], None, run.get("filament_used_mm") or 0)
|
||||||
except json.JSONDecodeError:
|
segments = store.list_run_segments(conn, run["id"])
|
||||||
ace = []
|
|
||||||
targets = []
|
entries = []
|
||||||
if ace:
|
targets = _targets_from_ace(conn, run["printer_host"], ace) if ace else []
|
||||||
for item in acm.normalize_mapping(ace):
|
|
||||||
slot = int(item["ams_index"]) + 1
|
|
||||||
row = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM spools
|
|
||||||
WHERE printer_host = %s AND ace_slot = %s
|
|
||||||
ORDER BY id LIMIT 1
|
|
||||||
""",
|
|
||||||
(run["printer_host"], slot),
|
|
||||||
).fetchone()
|
|
||||||
if row:
|
|
||||||
targets.append(row)
|
|
||||||
if not targets and run.get("spool_id"):
|
if not targets and run.get("spool_id"):
|
||||||
spool = store.get_spool_row(conn, run["spool_id"])
|
spool = store.get_spool_row(conn, run["spool_id"])
|
||||||
if spool:
|
if spool:
|
||||||
targets = [spool]
|
targets = [spool]
|
||||||
if not targets:
|
# Abschnitte ohne ACE-Map: externe Rollen nacheinander
|
||||||
|
if segments and not ace:
|
||||||
|
final_mm = float(run.get("filament_used_mm") or 0)
|
||||||
|
total_mm = float(run.get("filament_total_mm") or 0)
|
||||||
|
deltas = usage.segment_deltas(segments, final_mm)
|
||||||
|
for spool_id, ace_slot, delta_mm in deltas:
|
||||||
|
spool = store.get_spool_row(conn, spool_id)
|
||||||
|
if not spool:
|
||||||
|
continue
|
||||||
|
grams = usage.grams_from_mm(delta_mm, total_mm, planned)
|
||||||
|
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||||||
|
entries.append({
|
||||||
|
"spool_id": spool_id,
|
||||||
|
"ace_slot": ace_slot,
|
||||||
|
"weight_g": grams,
|
||||||
|
"weight_planned_g": planned / max(1, len(deltas)),
|
||||||
|
"cost": cost,
|
||||||
|
"is_estimate": source != "measured",
|
||||||
|
})
|
||||||
|
elif targets:
|
||||||
|
shares = usage.planned_shares(meta, ace, len(targets))
|
||||||
|
parts, is_estimate = usage.split_used(used, shares, multi=len(targets) > 1)
|
||||||
|
if source != "measured":
|
||||||
|
is_estimate = True
|
||||||
|
# ACE-Rollenwechsel: Anteil der gewechselten Farbe nach mm aufteilen
|
||||||
|
by_slot = {}
|
||||||
|
if segments and ace:
|
||||||
|
final_mm = float(run.get("filament_used_mm") or 0)
|
||||||
|
for spool_id, ace_slot, delta_mm in usage.segment_deltas(segments, final_mm):
|
||||||
|
by_slot.setdefault(ace_slot, []).append((spool_id, delta_mm))
|
||||||
|
for index, spool in enumerate(targets):
|
||||||
|
plan = shares[index] if index < len(shares) else 0
|
||||||
|
grams = parts[index] if index < len(parts) else 0
|
||||||
|
slot = spool.get("ace_slot")
|
||||||
|
slot_segs = by_slot.get(slot) or []
|
||||||
|
if len(slot_segs) > 1:
|
||||||
|
mm_sum = sum(d for _, d in slot_segs) or 1.0
|
||||||
|
for seg_spool_id, delta_mm in slot_segs:
|
||||||
|
seg_spool = store.get_spool_row(conn, seg_spool_id)
|
||||||
|
if not seg_spool:
|
||||||
|
continue
|
||||||
|
share_g = grams * (delta_mm / mm_sum)
|
||||||
|
cost = (share_g / 1000.0) * float(seg_spool.get("price_per_kg") or 0)
|
||||||
|
entries.append({
|
||||||
|
"spool_id": seg_spool_id,
|
||||||
|
"ace_slot": slot,
|
||||||
|
"weight_g": share_g,
|
||||||
|
"weight_planned_g": plan / len(slot_segs),
|
||||||
|
"cost": cost,
|
||||||
|
"is_estimate": True,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||||||
|
entries.append({
|
||||||
|
"spool_id": spool["id"],
|
||||||
|
"ace_slot": slot,
|
||||||
|
"weight_g": grams,
|
||||||
|
"weight_planned_g": plan,
|
||||||
|
"cost": cost,
|
||||||
|
"is_estimate": is_estimate,
|
||||||
|
})
|
||||||
|
elif segments:
|
||||||
|
# Fallback: Abschnitte ohne aktuelle Targets
|
||||||
|
final_mm = float(run.get("filament_used_mm") or 0)
|
||||||
|
total_mm = float(run.get("filament_total_mm") or 0)
|
||||||
|
for spool_id, ace_slot, delta_mm in usage.segment_deltas(segments, final_mm):
|
||||||
|
spool = store.get_spool_row(conn, spool_id)
|
||||||
|
if not spool:
|
||||||
|
continue
|
||||||
|
grams = usage.grams_from_mm(delta_mm, total_mm, planned)
|
||||||
|
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||||||
|
entries.append({
|
||||||
|
"spool_id": spool_id,
|
||||||
|
"ace_slot": ace_slot,
|
||||||
|
"weight_g": grams,
|
||||||
|
"weight_planned_g": 0,
|
||||||
|
"cost": cost,
|
||||||
|
"is_estimate": source != "measured",
|
||||||
|
})
|
||||||
|
else:
|
||||||
return False
|
return False
|
||||||
share = weight / len(targets)
|
|
||||||
filament = sum((share / 1000) * float(spool["price_per_kg"] or 0) for spool in targets)
|
if not entries:
|
||||||
power = energy_cost(run["duration_s"], energy)
|
return False
|
||||||
marked = store.mark_deducted(conn, run["id"], targets[0]["id"], filament, power)
|
used_total = sum(item["weight_g"] for item in entries)
|
||||||
|
filament = sum(item["cost"] for item in entries)
|
||||||
|
samples = store.list_run_samples(conn, run["id"])
|
||||||
|
power = energy_cost(run["duration_s"], energy, samples)
|
||||||
|
# run_spools vor deducted=true, sonst bleibt die Akte leer bei Abbruch dazwischen
|
||||||
|
store.replace_run_spools(conn, run["id"], entries)
|
||||||
|
marked = store.mark_deducted(
|
||||||
|
conn,
|
||||||
|
run["id"],
|
||||||
|
entries[0]["spool_id"],
|
||||||
|
filament,
|
||||||
|
power,
|
||||||
|
weight_used_g=used_total,
|
||||||
|
weight_source=source or ("estimate" if any(e["is_estimate"] for e in entries) else "measured"),
|
||||||
|
)
|
||||||
if not marked:
|
if not marked:
|
||||||
|
store.replace_run_spools(conn, run["id"], [])
|
||||||
return False
|
return False
|
||||||
for spool in targets:
|
for item in entries:
|
||||||
store.deduct_spool(conn, spool["id"], share)
|
store.deduct_spool(conn, item["spool_id"], item["weight_g"])
|
||||||
|
store.ensure_run_chargen(conn, run["id"], used_total)
|
||||||
|
objects = run.get("objects")
|
||||||
|
if isinstance(objects, str):
|
||||||
|
try:
|
||||||
|
objects = json.loads(objects)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
objects = []
|
||||||
|
store.ensure_run_labels(conn, run["id"], objects or [], qty=1)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -190,20 +333,45 @@ def sync_history(conn, cfg):
|
|||||||
deducted = 0
|
deducted = 0
|
||||||
for printer, job in collected:
|
for printer, job in collected:
|
||||||
job_id = str(job.get("job_id") or "")
|
job_id = str(job.get("job_id") or "")
|
||||||
if not job_id:
|
if not job_id or str(job_id).startswith("live:"):
|
||||||
continue
|
continue
|
||||||
filename = job.get("filename") or ""
|
filename = job.get("filename") or ""
|
||||||
metadata = job.get("metadata") or {}
|
metadata = job.get("metadata") or {}
|
||||||
weight = weight_of(metadata)
|
planned = weight_of(metadata)
|
||||||
material = material_of(metadata)
|
material = material_of(metadata)
|
||||||
state = job.get("status") or ""
|
state = job.get("status") or ""
|
||||||
|
used_mm = usage.filament_used_mm(job)
|
||||||
|
total_mm = usage.filament_total_mm(metadata, job)
|
||||||
|
estimated = float(metadata.get("estimated_time") or 0)
|
||||||
sent = store.latest_send(conn, printer["host"], filename)
|
sent = store.latest_send(conn, printer["host"], filename)
|
||||||
known = store.file_by_name(conn, filename)
|
known = store.file_by_name(conn, filename)
|
||||||
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
|
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
|
||||||
project_id = (sent or {}).get("project_id") or (known or {}).get("project_id")
|
project_id = (sent or {}).get("project_id") or (known or {}).get("project_id")
|
||||||
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
|
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
|
||||||
spool = choose_spool(conn, printer["host"], material, hinted)
|
ended_at = unix_to_dt(job.get("end_time"))
|
||||||
|
# Ohne Send-Eintrag keine Rate — sonst zieht eine neue Spule alle alten Jobs ab.
|
||||||
|
if not sent and not hinted:
|
||||||
|
spool = None
|
||||||
|
else:
|
||||||
|
spool = choose_spool(conn, printer["host"], material, hinted, ended_at=ended_at)
|
||||||
terminal = state in TERMINAL
|
terminal = state in TERMINAL
|
||||||
|
live = store.find_live_run(conn, printer["host"], filename)
|
||||||
|
progress = float((live or {}).get("progress_last") or 0)
|
||||||
|
if used_mm <= 0 and live:
|
||||||
|
used_mm = float(live.get("filament_used_mm") or 0)
|
||||||
|
if total_mm <= 0 and live:
|
||||||
|
total_mm = float(live.get("filament_total_mm") or 0)
|
||||||
|
if planned <= 0 and live:
|
||||||
|
planned = float(live.get("weight_planned_g") or 0)
|
||||||
|
objects = (live or {}).get("objects") or []
|
||||||
|
if isinstance(objects, str):
|
||||||
|
try:
|
||||||
|
objects = json.loads(objects)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
objects = []
|
||||||
|
used_g, source = usage.resolve_used_grams(
|
||||||
|
planned, used_mm, total_mm, progress, state, job.get("print_duration") or 0
|
||||||
|
)
|
||||||
row = store.upsert_run(conn, {
|
row = store.upsert_run(conn, {
|
||||||
"printer_host": printer["host"],
|
"printer_host": printer["host"],
|
||||||
"printer_name": printer["name"],
|
"printer_name": printer["name"],
|
||||||
@@ -215,13 +383,33 @@ def sync_history(conn, cfg):
|
|||||||
"started_at": unix_to_dt(job.get("start_time")),
|
"started_at": unix_to_dt(job.get("start_time")),
|
||||||
"ended_at": unix_to_dt(job.get("end_time")),
|
"ended_at": unix_to_dt(job.get("end_time")),
|
||||||
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0),
|
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0),
|
||||||
"weight_g": weight,
|
"weight_g": planned,
|
||||||
|
"weight_planned_g": planned,
|
||||||
|
"weight_used_g": used_g,
|
||||||
|
"weight_source": source,
|
||||||
|
"filament_used_mm": used_mm,
|
||||||
|
"filament_total_mm": total_mm,
|
||||||
|
"progress_last": progress,
|
||||||
|
"estimated_s": estimated,
|
||||||
"energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]),
|
"energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]),
|
||||||
"moonraker_job_id": job_id,
|
"moonraker_job_id": job_id,
|
||||||
"needs_spool": bool(terminal and weight > 0 and not spool),
|
"needs_spool": bool(terminal and used_g > 0 and not spool),
|
||||||
"material_type": material,
|
"material_type": material,
|
||||||
|
"message": (job.get("message") or "") if state == "error" else "",
|
||||||
|
"objects": objects,
|
||||||
})
|
})
|
||||||
if apply_deduction(conn, row, cfg["energy"]):
|
# Live sofort auf History hängen — auch solange noch in_progress (eine Protokoll-Zeile)
|
||||||
|
store.adopt_live_run(conn, printer["host"], filename, row["id"])
|
||||||
|
row = store.get_run_row(conn, row["id"])
|
||||||
|
if terminal:
|
||||||
|
# Nachzug nicht während eines neuen Live-Drucks — sonst „Platte belegt“ zu Unrecht
|
||||||
|
active = store.find_live_run(conn, printer["host"])
|
||||||
|
if not active:
|
||||||
|
ended = row.get("ended_at") or unix_to_dt(job.get("end_time"))
|
||||||
|
store.ensure_bed_after_print(
|
||||||
|
conn, printer["host"], ended_at=ended, message=filename or state
|
||||||
|
)
|
||||||
|
if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
|
||||||
deducted += 1
|
deducted += 1
|
||||||
return {"gelesen": len(collected), "abgezogen": deducted}
|
return {"gelesen": len(collected), "abgezogen": deducted}
|
||||||
|
|
||||||
@@ -339,7 +527,7 @@ def send_file(
|
|||||||
progress("pruefen", 15, "Drucker prüfen…")
|
progress("pruefen", 15, "Drucker prüfen…")
|
||||||
try:
|
try:
|
||||||
live = moonraker.query(host)
|
live = moonraker.query(host)
|
||||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as exc:
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError, ValueError) as exc:
|
||||||
raise GrottiError("Der Drucker ist nicht erreichbar.", "offline") from exc
|
raise GrottiError("Der Drucker ist nicht erreichbar.", "offline") from exc
|
||||||
if live.get("state") in ("printing", "paused"):
|
if live.get("state") in ("printing", "paused"):
|
||||||
raise GrottiError("Der Drucker druckt schon.", "belegt", 409)
|
raise GrottiError("Der Drucker druckt schon.", "belegt", 409)
|
||||||
@@ -373,13 +561,35 @@ def send_file(
|
|||||||
filaments = [{"type": "PLA", "color": ""}]
|
filaments = [{"type": "PLA", "color": ""}]
|
||||||
|
|
||||||
progress("vorbereiten", 30, "Datei vorbereiten…")
|
progress("vorbereiten", 30, "Datei vorbereiten…")
|
||||||
filename, content, plate = library.printer_file(row)
|
filename, content, plate = library.printer_file(
|
||||||
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
row, conn=conn, data_dir=(cfg or {}).get("data_dir")
|
||||||
|
)
|
||||||
|
# CJK/Unicode im Namen → Rinkhals 10115 not found (Upload ≠ Start-Name)
|
||||||
|
filename = moonraker.safe_upload_name(filename)
|
||||||
if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"):
|
if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"):
|
||||||
|
if slots and mapping:
|
||||||
|
# Immer an G-code-Tn ausrichten (auch wenn UI fälschlich 0/1 schickt)
|
||||||
|
content, mapping, filaments, remap = acm.align_tools_for_ace(
|
||||||
|
content, mapping, filaments
|
||||||
|
)
|
||||||
|
if remap:
|
||||||
|
progress(
|
||||||
|
"ace",
|
||||||
|
32,
|
||||||
|
"Slicer-T verdichtet (%s)…"
|
||||||
|
% ", ".join("T%s→T%s" % pair for pair in sorted(remap.items())),
|
||||||
|
)
|
||||||
|
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
||||||
content = prepare_rinkhals_gcode(content, tools=ace_tools)
|
content = prepare_rinkhals_gcode(content, tools=ace_tools)
|
||||||
if keep_motors:
|
if keep_motors:
|
||||||
# Slice-Ende `M84` → sofort unhomed; Abräumen braucht die Stepper.
|
# Slice-Ende `M84` → sofort unhomed; Abräumen braucht die Stepper.
|
||||||
content = eject.strip_end_motor_disable(content)
|
content = eject.strip_end_motor_disable(content)
|
||||||
|
else:
|
||||||
|
if slots and mapping:
|
||||||
|
_, mapping, filaments, _ = acm.align_tools_for_ace(
|
||||||
|
b"", mapping, filaments
|
||||||
|
)
|
||||||
|
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
||||||
size_mb = len(content) / (1024 * 1024)
|
size_mb = len(content) / (1024 * 1024)
|
||||||
|
|
||||||
def upload_progress(sent, total):
|
def upload_progress(sent, total):
|
||||||
@@ -484,6 +694,33 @@ def steuern(conn, host, action, params=None):
|
|||||||
printer = store.printer_by_host(conn, host)
|
printer = store.printer_by_host(conn, host)
|
||||||
if not printer:
|
if not printer:
|
||||||
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
||||||
|
params = dict(params) if isinstance(params, dict) else {}
|
||||||
|
if action == "slot_setzen":
|
||||||
|
try:
|
||||||
|
ace_slot = int(params.get("ace_slot"))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise GrottiError("ACE-Platz muss 1–8 sein.", "platz") from exc
|
||||||
|
spool = None
|
||||||
|
for row in store.spools_for_printer(conn, host):
|
||||||
|
raw_slot = row.get("ace_slot")
|
||||||
|
if raw_slot in (None, ""):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if int(raw_slot) == ace_slot:
|
||||||
|
spool = row
|
||||||
|
break
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if not spool:
|
||||||
|
raise GrottiError("Auf diesem Platz liegt keine Spule in Grotti.", "spule")
|
||||||
|
material = catalog.ace_material_type(spool.get("material_type"))
|
||||||
|
if not material:
|
||||||
|
raise GrottiError("Die Spule braucht eine Sorte.", "material")
|
||||||
|
color = moonraker.spool_rgb(spool)
|
||||||
|
if not color:
|
||||||
|
raise GrottiError("Die Spule braucht eine Farbe.", "farbe")
|
||||||
|
hub_id, index = moonraker.ace_hub_index(ace_slot)
|
||||||
|
params = {"id": hub_id, "index": index, "type": material, "color": color}
|
||||||
try:
|
try:
|
||||||
moonraker.steuern(host, action, params)
|
moonraker.steuern(host, action, params)
|
||||||
except moonraker.PrinterError as exc:
|
except moonraker.PrinterError as exc:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Watch-Ordner, Upload und Vorschaubild."""
|
"""Watch-Ordner, Upload und Vorschaubild."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -10,6 +12,8 @@ from grotti.parse import embedded_png, inspect_file, safe_zip_name
|
|||||||
|
|
||||||
_LAST_WATCH_SYNC = 0.0
|
_LAST_WATCH_SYNC = 0.0
|
||||||
_WATCH_MIN_INTERVAL_S = 20
|
_WATCH_MIN_INTERVAL_S = 20
|
||||||
|
_FOLDER_PART = re.compile(r"^[A-Za-z0-9._\- äöüÄÖÜß]+$")
|
||||||
|
_MAX_FOLDER_DEPTH = 6
|
||||||
|
|
||||||
|
|
||||||
def skip_path(path, data_dir):
|
def skip_path(path, data_dir):
|
||||||
@@ -36,7 +40,7 @@ def sync_watch(conn, cfg, force=False):
|
|||||||
global _LAST_WATCH_SYNC
|
global _LAST_WATCH_SYNC
|
||||||
folder = cfg["watch_dir"]
|
folder = cfg["watch_dir"]
|
||||||
if folder is None:
|
if folder is None:
|
||||||
return {"gelesen": 0, "hinweis": "In config.json watch_dir auf den Slice-Ordner setzen."}
|
return {"gelesen": 0, "hinweis": "Unter Einstellungen den Slice-Ordner setzen."}
|
||||||
if not folder.exists():
|
if not folder.exists():
|
||||||
return {"gelesen": 0, "hinweis": "Der Watch-Ordner fehlt: %s" % folder}
|
return {"gelesen": 0, "hinweis": "Der Watch-Ordner fehlt: %s" % folder}
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
@@ -55,10 +59,55 @@ def sync_watch(conn, cfg, force=False):
|
|||||||
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)
|
||||||
_LAST_WATCH_SYNC = now
|
_LAST_WATCH_SYNC = now
|
||||||
return {"gelesen": len(seen), "hinweis": ""}
|
return {"gelesen": len(seen), "hinweis": ""}
|
||||||
|
|
||||||
|
|
||||||
|
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 {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
"""Metadaten bestehender Dateien neu lesen (z. B. paint_info → filaments)."""
|
"""Metadaten bestehender Dateien neu lesen (z. B. paint_info → filaments)."""
|
||||||
if not force:
|
if not force:
|
||||||
@@ -76,27 +125,17 @@ 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
|
||||||
|
|
||||||
|
|
||||||
def safe_filename(name):
|
def safe_filename(name):
|
||||||
base = Path(name).name.replace("\\", "_").replace("/", "_").replace('"', "")
|
base = Path(name).name.replace("\\", "_").replace("/", "_").replace('"', "")
|
||||||
return base or "datei.gcode"
|
base = base.strip().strip(".")
|
||||||
|
if not base or base in (".", ".."):
|
||||||
|
return "datei.gcode"
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
def unique_path(directory, filename):
|
def unique_path(directory, filename):
|
||||||
@@ -113,6 +152,394 @@ def unique_path(directory, filename):
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def require_watch_dir(cfg):
|
||||||
|
folder = cfg.get("watch_dir")
|
||||||
|
if not folder:
|
||||||
|
raise GrottiError("Kein Slice-Ordner gesetzt — unter Einstellungen festlegen.", "watch")
|
||||||
|
root = Path(folder).resolve()
|
||||||
|
if not root.is_dir():
|
||||||
|
raise GrottiError("Der Slice-Ordner fehlt: %s" % root, "watch")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_folder(rel):
|
||||||
|
"""Relativpfad unter watch_dir; leer = Wurzel. Kein .., begrenzte Tiefe."""
|
||||||
|
text = str(rel or "").strip().replace("\\", "/").strip("/")
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
parts = []
|
||||||
|
for part in text.split("/"):
|
||||||
|
part = part.strip()
|
||||||
|
if not part or part in (".", ".."):
|
||||||
|
raise GrottiError("Ungültiger Ordnername.", "ordner")
|
||||||
|
if not _FOLDER_PART.match(part):
|
||||||
|
raise GrottiError(
|
||||||
|
"Ordnername nur Buchstaben, Ziffern, Leerzeichen, . _ -.",
|
||||||
|
"ordner",
|
||||||
|
)
|
||||||
|
parts.append(part)
|
||||||
|
if len(parts) > _MAX_FOLDER_DEPTH:
|
||||||
|
raise GrottiError("Ordner zu tief verschachtelt (max. %s)." % _MAX_FOLDER_DEPTH, "ordner")
|
||||||
|
return "/".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def path_under_watch(watch_dir, rel_folder, name=None):
|
||||||
|
"""Absoluter Pfad unter watch_dir; wirft bei Escape."""
|
||||||
|
root = Path(watch_dir).resolve()
|
||||||
|
folder = normalize_folder(rel_folder)
|
||||||
|
target = root if not folder else (root / folder)
|
||||||
|
if name:
|
||||||
|
target = target / safe_filename(name)
|
||||||
|
resolved = target.resolve()
|
||||||
|
if resolved != root and root not in resolved.parents:
|
||||||
|
raise GrottiError("Pfad außerhalb des Slice-Ordners.", "ordner")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def list_folders(cfg):
|
||||||
|
"""Unterordner unter watch_dir (auch leer), relativ, sortiert."""
|
||||||
|
try:
|
||||||
|
root = require_watch_dir(cfg)
|
||||||
|
except GrottiError:
|
||||||
|
return []
|
||||||
|
data_dir = cfg.get("data_dir")
|
||||||
|
found = set()
|
||||||
|
for path in root.rglob("*"):
|
||||||
|
if not path.is_dir():
|
||||||
|
continue
|
||||||
|
if data_dir is not None and skip_path(path, data_dir):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rel = path.resolve().relative_to(root).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if rel and rel != ".":
|
||||||
|
found.add(rel)
|
||||||
|
return sorted(found, key=lambda s: s.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def create_folder(cfg, rel):
|
||||||
|
root = require_watch_dir(cfg)
|
||||||
|
folder = normalize_folder(rel)
|
||||||
|
if not folder:
|
||||||
|
raise GrottiError("Ordnername fehlt.", "ordner")
|
||||||
|
target = path_under_watch(root, folder)
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
def delete_folder(cfg, rel):
|
||||||
|
root = require_watch_dir(cfg)
|
||||||
|
folder = normalize_folder(rel)
|
||||||
|
if not folder:
|
||||||
|
raise GrottiError("Die Wurzel lässt sich nicht löschen.", "ordner")
|
||||||
|
target = path_under_watch(root, folder)
|
||||||
|
if not target.is_dir():
|
||||||
|
raise GrottiError("Der Ordner fehlt.", "ordner", 404)
|
||||||
|
try:
|
||||||
|
next(target.iterdir())
|
||||||
|
raise GrottiError("Ordner ist nicht leer.", "ordner")
|
||||||
|
except StopIteration:
|
||||||
|
pass
|
||||||
|
target.rmdir()
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
def _link_file_to_project_folder(conn, file_id, stored_path, watch_dir):
|
||||||
|
"""Wenn Pfad unter einem Projekt-Ordner liegt → project_id setzen."""
|
||||||
|
folder = store.file_folder(stored_path, "watch", watch_dir)
|
||||||
|
if not folder:
|
||||||
|
return
|
||||||
|
for row in store.list_project_folders(conn):
|
||||||
|
base = (row.get("folder") or "").strip().replace("\\", "/").strip("/")
|
||||||
|
if not base:
|
||||||
|
continue
|
||||||
|
if folder == base or folder.startswith(base + "/"):
|
||||||
|
store.set_file_project(conn, file_id, row["id"])
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def move_file(conn, cfg, file_id, folder):
|
||||||
|
"""Watch- oder Upload-Datei in Slice-Unterordner (oder Wurzel); Upload wird Watch."""
|
||||||
|
root = require_watch_dir(cfg)
|
||||||
|
folder = normalize_folder(folder)
|
||||||
|
row = store.get_file_row(conn, file_id)
|
||||||
|
if not row:
|
||||||
|
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
||||||
|
source = row.get("source") or ""
|
||||||
|
if source not in ("watch", "upload"):
|
||||||
|
raise GrottiError("Diese Datei lässt sich nicht verschieben.", "datei")
|
||||||
|
src = Path(row.get("stored_path") or "")
|
||||||
|
if not src.is_file():
|
||||||
|
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
||||||
|
dest_dir = path_under_watch(root, folder)
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = dest_dir / src.name
|
||||||
|
if dest.resolve() == src.resolve() and source == "watch":
|
||||||
|
return store.file_out(row, watch_dir=root)
|
||||||
|
if dest.exists() and dest.resolve() != src.resolve():
|
||||||
|
dest = unique_path(dest_dir, src.name)
|
||||||
|
try:
|
||||||
|
shutil.move(str(src), str(dest))
|
||||||
|
except OSError as exc:
|
||||||
|
raise GrottiError("Verschieben fehlgeschlagen: %s" % exc, "ordner") from exc
|
||||||
|
stat = dest.stat()
|
||||||
|
store.update_file_location(
|
||||||
|
conn,
|
||||||
|
file_id,
|
||||||
|
str(dest.resolve()),
|
||||||
|
name=dest.name,
|
||||||
|
size_bytes=stat.st_size,
|
||||||
|
mtime=stat.st_mtime,
|
||||||
|
source="watch",
|
||||||
|
)
|
||||||
|
_link_file_to_project_folder(conn, file_id, str(dest.resolve()), root)
|
||||||
|
row = store.get_file_row(conn, file_id)
|
||||||
|
return store.file_out(row, watch_dir=root)
|
||||||
|
|
||||||
|
|
||||||
|
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"))
|
||||||
|
if dest.exists():
|
||||||
|
raise GrottiError("Unter diesem Namen liegt schon eine Datei.", "datei")
|
||||||
|
try:
|
||||||
|
src.rename(dest)
|
||||||
|
except OSError as exc:
|
||||||
|
raise GrottiError("Umbenennen fehlgeschlagen: %s" % exc, "datei") from exc
|
||||||
|
stat = dest.stat()
|
||||||
|
store.update_file_location(
|
||||||
|
conn,
|
||||||
|
file_id,
|
||||||
|
str(dest.resolve()),
|
||||||
|
name=dest.name,
|
||||||
|
size_bytes=stat.st_size,
|
||||||
|
mtime=stat.st_mtime,
|
||||||
|
)
|
||||||
|
row = store.get_file_row(conn, file_id)
|
||||||
|
return store.file_out(row, watch_dir=cfg.get("watch_dir"))
|
||||||
|
|
||||||
|
|
||||||
|
_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):
|
||||||
directory = cfg["data_dir"] / "eingang"
|
directory = cfg["data_dir"] / "eingang"
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -142,8 +569,30 @@ def ensure_library_gcode(conn, cfg, host, filename):
|
|||||||
return save_upload(conn, name, content, cfg)
|
return save_upload(conn, name, content, cfg)
|
||||||
|
|
||||||
|
|
||||||
def printer_file(row):
|
def resolve_stored_path(conn, row, data_dir=None):
|
||||||
path = Path(row["stored_path"])
|
"""Gespeicherter Pfad, ggf. nach Umzug von data_dir unter eingang/<name> wiederfinden."""
|
||||||
|
stored = (row or {}).get("stored_path") or ""
|
||||||
|
path = Path(stored) if stored else None
|
||||||
|
if path and path.is_file():
|
||||||
|
return path
|
||||||
|
name = (row or {}).get("name") or (path.name if path else "")
|
||||||
|
if not name or data_dir is None:
|
||||||
|
return None
|
||||||
|
candidate = Path(data_dir) / "eingang" / name
|
||||||
|
if not candidate.is_file():
|
||||||
|
return None
|
||||||
|
resolved = str(candidate.resolve())
|
||||||
|
file_id = (row or {}).get("id")
|
||||||
|
if file_id and conn is not None:
|
||||||
|
conn.execute("UPDATE files SET stored_path = %s WHERE id = %s", (resolved, file_id))
|
||||||
|
row["stored_path"] = resolved
|
||||||
|
return candidate.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def printer_file(row, conn=None, data_dir=None):
|
||||||
|
path = resolve_stored_path(conn, row, data_dir) if (conn is not None or data_dir is not None) else Path(row["stored_path"])
|
||||||
|
if path is None:
|
||||||
|
path = Path(row.get("stored_path") or "")
|
||||||
if not path.is_file():
|
if not path.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)
|
||||||
if row["kind"] == "gcode":
|
if row["kind"] == "gcode":
|
||||||
@@ -153,9 +602,9 @@ def printer_file(row):
|
|||||||
return path.name, path.read_bytes(), row["plate"] or ""
|
return path.name, path.read_bytes(), row["plate"] or ""
|
||||||
|
|
||||||
|
|
||||||
def preview_png(row):
|
def preview_png(row, conn=None, data_dir=None):
|
||||||
path = Path(row["stored_path"])
|
path = resolve_stored_path(conn, row, data_dir) if (conn is not None or data_dir is not None) else Path(row["stored_path"])
|
||||||
if not path.is_file():
|
if path is None or not path.is_file():
|
||||||
return None
|
return None
|
||||||
entry = row["thumb_entry"]
|
entry = row["thumb_entry"]
|
||||||
if row["kind"] == "gcode":
|
if row["kind"] == "gcode":
|
||||||
|
|||||||
@@ -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,156 @@
|
|||||||
|
"""Wartungs-Teile und Laufzähler aus dem Protokoll."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
def _baseline(conn, host, part_key):
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT hours_at, meters_at FROM maintenance_log
|
||||||
|
WHERE printer_host = %s AND part_key = %s AND event IN ('install', 'reset')
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(host, part_key),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return 0.0, 0.0
|
||||||
|
return float(row.get("hours_at") or 0), float(row.get("meters_at") or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def counters_for_host(conn, host):
|
||||||
|
"""Summe Druckzeit, Filament und Anzahl Läufe für einen Host."""
|
||||||
|
host = (host or "").strip()
|
||||||
|
if not host:
|
||||||
|
return {"hours": 0.0, "meters": 0.0, "runs": 0}
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(duration_s), 0) AS duration_s,
|
||||||
|
COALESCE(SUM(filament_used_mm), 0) AS meters,
|
||||||
|
COUNT(*) AS runs
|
||||||
|
FROM runs
|
||||||
|
WHERE printer_host = %s AND state = 'completed'
|
||||||
|
""",
|
||||||
|
(host,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"hours": 0.0, "meters": 0.0, "runs": 0}
|
||||||
|
return {
|
||||||
|
"hours": float(row.get("duration_s") or 0) / 3600.0,
|
||||||
|
"meters": float(row.get("meters") or 0),
|
||||||
|
"runs": int(row.get("runs") or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def part_out(row, counters=None, conn=None):
|
||||||
|
counters = counters or {}
|
||||||
|
life_h = float(row.get("life_hours") or 0)
|
||||||
|
life_m = float(row.get("life_meters") or 0)
|
||||||
|
hours_total = float(counters.get("hours") or 0)
|
||||||
|
meters_total = float(counters.get("meters") or 0)
|
||||||
|
host = row["printer_host"]
|
||||||
|
part_key = row["part_key"]
|
||||||
|
base_h, base_m = (0.0, 0.0)
|
||||||
|
if conn is not None:
|
||||||
|
base_h, base_m = _baseline(conn, host, part_key)
|
||||||
|
hours_since = max(0.0, hours_total - base_h)
|
||||||
|
meters_since = max(0.0, meters_total - base_m)
|
||||||
|
installed = row.get("installed_at")
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"printer_host": host,
|
||||||
|
"part_key": part_key,
|
||||||
|
"label": row.get("label") or "",
|
||||||
|
"installed_at": installed.isoformat() if hasattr(installed, "isoformat") else str(installed or ""),
|
||||||
|
"life_hours": life_h,
|
||||||
|
"life_meters": life_m,
|
||||||
|
"note": row.get("note") or "",
|
||||||
|
"hours_since": hours_since,
|
||||||
|
"meters_since": meters_since,
|
||||||
|
"overdue_hours": life_h > 0 and hours_since >= life_h,
|
||||||
|
"overdue_meters": life_m > 0 and meters_since >= life_m,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_for_api(conn, printers):
|
||||||
|
out = []
|
||||||
|
for printer in printers or []:
|
||||||
|
host = printer.get("host") or ""
|
||||||
|
counters = counters_for_host(conn, host)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM maintenance_parts WHERE printer_host = %s ORDER BY label, part_key, id",
|
||||||
|
(host,),
|
||||||
|
).fetchall()
|
||||||
|
out.append({
|
||||||
|
"host": host,
|
||||||
|
"name": printer.get("name") or host,
|
||||||
|
"counters": counters,
|
||||||
|
"parts": [part_out(r, counters, conn=conn) for r in rows],
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def create_part(conn, printer_host, part_key, label="", life_hours=0, life_meters=0, note=""):
|
||||||
|
printer_host = (printer_host or "").strip()
|
||||||
|
part_key = (part_key or "").strip()
|
||||||
|
if not printer_host or not part_key:
|
||||||
|
raise ValueError("Drucker und part_key nötig.")
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO maintenance_parts (printer_host, part_key, label, life_hours, life_meters, note)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
printer_host,
|
||||||
|
part_key,
|
||||||
|
(label or part_key).strip(),
|
||||||
|
float(life_hours or 0),
|
||||||
|
float(life_meters or 0),
|
||||||
|
(note or "").strip(),
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
counters = counters_for_host(conn, printer_host)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO maintenance_log (printer_host, part_key, event, hours_at, meters_at, note)
|
||||||
|
VALUES (%s, %s, 'install', %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(printer_host, part_key, counters["hours"], counters["meters"], (note or "").strip()),
|
||||||
|
)
|
||||||
|
return part_out(row, counters, conn=conn)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_part(conn, part_id):
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM maintenance_parts WHERE id = %s",
|
||||||
|
(int(part_id),),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
host = row["printer_host"]
|
||||||
|
counters = counters_for_host(conn, host)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO maintenance_log (printer_host, part_key, event, hours_at, meters_at, note)
|
||||||
|
VALUES (%s, %s, 'reset', %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
host,
|
||||||
|
row["part_key"],
|
||||||
|
counters["hours"],
|
||||||
|
counters["meters"],
|
||||||
|
row.get("note") or "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
updated = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE maintenance_parts
|
||||||
|
SET installed_at = now()
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(int(part_id),),
|
||||||
|
).fetchone()
|
||||||
|
fresh_counters = counters_for_host(conn, host)
|
||||||
|
return part_out(updated, fresh_counters, conn=conn)
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""MCP-Server für Grotti — spricht nur die HTTP-API mit einem API-Token.
|
||||||
|
|
||||||
|
Umgebung:
|
||||||
|
GROTTI_URL Basis-URL (Standard http://127.0.0.1:8770)
|
||||||
|
GROTTI_TOKEN Bearer-Token (grt_…)
|
||||||
|
GROTTI_MCP_READ_ONLY=1 nur Lese-Tools
|
||||||
|
|
||||||
|
Start:
|
||||||
|
python -m grotti.mcp_server
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
READ_ONLY = os.environ.get("GROTTI_MCP_READ_ONLY", "").strip() in ("1", "true", "yes")
|
||||||
|
BASE = (os.environ.get("GROTTI_URL") or "http://127.0.0.1:8770").rstrip("/")
|
||||||
|
TOKEN = (os.environ.get("GROTTI_TOKEN") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _api(method: str, path: str, body: dict | None = None):
|
||||||
|
if not TOKEN:
|
||||||
|
raise RuntimeError("GROTTI_TOKEN fehlt.")
|
||||||
|
url = BASE + "/api/v1" + path
|
||||||
|
data = None
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Bearer " + TOKEN,
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
payload = json.loads(detail)
|
||||||
|
message = payload.get("error") or detail
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
message = detail or str(exc)
|
||||||
|
raise RuntimeError(message) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _write_guard():
|
||||||
|
if READ_ONLY:
|
||||||
|
raise RuntimeError("MCP läuft im Read-only-Modus (GROTTI_MCP_READ_ONLY).")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
except ImportError:
|
||||||
|
print(
|
||||||
|
"Paket „mcp“ fehlt. Installieren: pip install mcp",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
server = FastMCP(
|
||||||
|
"grotti",
|
||||||
|
instructions=(
|
||||||
|
"Grotti steuert die 3D-Drucker-Farm. Destruktive Aktionen brauchen "
|
||||||
|
"confirm=true. Kein freier G-code, kein Firmware-Restart."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def list_printers(live: bool = True) -> str:
|
||||||
|
"""Druckerliste mit optionalem Live-Zustand."""
|
||||||
|
q = "?live=1" if live else "?live=0"
|
||||||
|
return json.dumps(_api("GET", "/drucker" + q), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def get_printer(host: str) -> str:
|
||||||
|
"""Einen Drucker anhand Host/IP aus der Live-Liste."""
|
||||||
|
rows = _api("GET", "/drucker?live=1") or []
|
||||||
|
for row in rows:
|
||||||
|
if row.get("host") == host:
|
||||||
|
return json.dumps(row, ensure_ascii=False, indent=2)
|
||||||
|
raise RuntimeError("Drucker nicht gefunden: %s" % host)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def list_running() -> str:
|
||||||
|
"""Laufende Drucke und Abläufe."""
|
||||||
|
return json.dumps(_api("GET", "/laeuft"), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def list_queue(limit: int = 40) -> str:
|
||||||
|
"""Druckwarteschlange."""
|
||||||
|
return json.dumps(
|
||||||
|
_api("GET", "/druck-queue?limit=%d" % max(1, min(limit, 200))),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def list_events(limit: int = 30) -> str:
|
||||||
|
"""Letzte Ereignisse / Aktivität."""
|
||||||
|
return json.dumps(
|
||||||
|
_api("GET", "/ereignisse?limit=%d" % max(1, min(limit, 200))),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def list_files(limit: int = 50) -> str:
|
||||||
|
"""Dateien in der Bibliothek."""
|
||||||
|
rows = _api("GET", "/dateien") or []
|
||||||
|
return json.dumps(rows[: max(1, min(limit, 200))], ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def list_spools() -> str:
|
||||||
|
"""Spulen / Materialbestand."""
|
||||||
|
return json.dumps(_api("GET", "/spulen"), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def auftraege_lesen(limit: int = 30, status: str = "") -> str:
|
||||||
|
"""Aufträge (optional nach Status filtern)."""
|
||||||
|
st = (status or "").strip().lower()
|
||||||
|
q = "?status=" + urllib.parse.quote(st) if st else ""
|
||||||
|
rows = _api("GET", "/auftraege" + q) or []
|
||||||
|
cap = max(1, min(limit, 200))
|
||||||
|
if len(rows) > cap:
|
||||||
|
rows = rows[:cap]
|
||||||
|
return json.dumps(rows, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def bedarf_lesen() -> str:
|
||||||
|
"""Filament-Bedarf (Queue vs. Spulenrest)."""
|
||||||
|
return json.dumps(_api("GET", "/bedarf"), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def wartung_lesen() -> str:
|
||||||
|
"""Wartungsteile und Zählerstände."""
|
||||||
|
return json.dumps(_api("GET", "/wartung"), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def get_version() -> str:
|
||||||
|
"""Grotti-Version."""
|
||||||
|
return json.dumps(_api("GET", "/version"), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def pause_printer(host: str) -> str:
|
||||||
|
"""Druck pausieren."""
|
||||||
|
_write_guard()
|
||||||
|
return json.dumps(
|
||||||
|
_api("POST", "/drucker/%s/pause" % urllib.parse.quote(host)),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def resume_printer(host: str) -> str:
|
||||||
|
"""Druck fortsetzen."""
|
||||||
|
_write_guard()
|
||||||
|
return json.dumps(
|
||||||
|
_api("POST", "/drucker/%s/fortsetzen" % urllib.parse.quote(host)),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def cancel_printer(host: str, confirm: bool = False) -> str:
|
||||||
|
"""Druck abbrechen. confirm muss true sein."""
|
||||||
|
_write_guard()
|
||||||
|
if not confirm:
|
||||||
|
raise RuntimeError("Abbruch braucht confirm=true.")
|
||||||
|
return json.dumps(
|
||||||
|
_api("POST", "/drucker/%s/abbrechen" % urllib.parse.quote(host)),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def clear_bed(host: str, confirm: bool = False) -> str:
|
||||||
|
"""Platte als geräumt quittieren (kein physisches Schieben)."""
|
||||||
|
_write_guard()
|
||||||
|
if not confirm:
|
||||||
|
raise RuntimeError("Quittierung braucht confirm=true.")
|
||||||
|
return json.dumps(
|
||||||
|
_api(
|
||||||
|
"POST",
|
||||||
|
"/drucker/%s/abraeumen" % urllib.parse.quote(host),
|
||||||
|
{"confirm": True},
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def enqueue_when_free(
|
||||||
|
file_id: int,
|
||||||
|
host: str = "",
|
||||||
|
confirm: bool = False,
|
||||||
|
bed_leveling: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Datei in die Warteschlange („wenn frei“). confirm muss true sein."""
|
||||||
|
_write_guard()
|
||||||
|
if not confirm:
|
||||||
|
raise RuntimeError("Einreihen braucht confirm=true.")
|
||||||
|
payload = {
|
||||||
|
"file_id": file_id,
|
||||||
|
"host": host,
|
||||||
|
"confirm": True,
|
||||||
|
"bed_leveling": bool(bed_leveling),
|
||||||
|
}
|
||||||
|
return json.dumps(_api("POST", "/druck-queue", payload), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def enqueue_print(
|
||||||
|
file_id: int,
|
||||||
|
host: str,
|
||||||
|
confirm: bool = False,
|
||||||
|
bed_leveling: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Sofort senden und starten. confirm muss true sein."""
|
||||||
|
_write_guard()
|
||||||
|
if not confirm:
|
||||||
|
raise RuntimeError("Start braucht confirm=true.")
|
||||||
|
if not host:
|
||||||
|
raise RuntimeError("host fehlt.")
|
||||||
|
payload = {
|
||||||
|
"file_id": file_id,
|
||||||
|
"host": host,
|
||||||
|
"confirm": True,
|
||||||
|
"bed_leveling": bool(bed_leveling),
|
||||||
|
}
|
||||||
|
return json.dumps(_api("POST", "/senden", payload), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def steer_temp(host: str, nozzle: float | None = None, bed: float | None = None) -> str:
|
||||||
|
"""Zieltemperaturen setzen (Whitelist)."""
|
||||||
|
_write_guard()
|
||||||
|
out = []
|
||||||
|
if nozzle is not None:
|
||||||
|
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||||
|
"action": "nozzle", "temp": nozzle,
|
||||||
|
}))
|
||||||
|
if bed is not None:
|
||||||
|
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||||
|
"action": "bed", "temp": bed,
|
||||||
|
}))
|
||||||
|
if not out:
|
||||||
|
raise RuntimeError("nozzle und/oder bed angeben.")
|
||||||
|
return json.dumps(out, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def steer_speed(host: str, speed: float) -> str:
|
||||||
|
"""Geschwindigkeitsfaktor in Prozent."""
|
||||||
|
_write_guard()
|
||||||
|
return json.dumps(
|
||||||
|
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||||
|
"action": "speed", "percent": speed,
|
||||||
|
}),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def steer_fan(host: str, fan: float) -> str:
|
||||||
|
"""Teilekühlung 0–100."""
|
||||||
|
_write_guard()
|
||||||
|
return json.dumps(
|
||||||
|
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||||
|
"action": "fan", "percent": fan,
|
||||||
|
}),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.resource("grotti://version")
|
||||||
|
def resource_version() -> str:
|
||||||
|
return json.dumps(_api("GET", "/version"), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
server.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
notes text NOT NULL DEFAULT '',
|
||||||
|
status text NOT NULL DEFAULT 'idee',
|
||||||
|
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spools (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
brand text NOT NULL DEFAULT '',
|
||||||
|
material_type text NOT NULL DEFAULT '',
|
||||||
|
color text NOT NULL DEFAULT '',
|
||||||
|
price_per_kg double precision NOT NULL DEFAULT 0,
|
||||||
|
density double precision NOT NULL DEFAULT 1.24,
|
||||||
|
volumetric double precision NOT NULL DEFAULT 12,
|
||||||
|
remaining_g double precision NOT NULL DEFAULT 1000,
|
||||||
|
printer_host text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
|
||||||
|
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
stored_path text NOT NULL,
|
||||||
|
source text NOT NULL,
|
||||||
|
kind text NOT NULL,
|
||||||
|
printer_family text NOT NULL DEFAULT '',
|
||||||
|
filaments jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
weight_g double precision NOT NULL DEFAULT 0,
|
||||||
|
estimated_s double precision NOT NULL DEFAULT 0,
|
||||||
|
thumb_entry text NOT NULL DEFAULT '',
|
||||||
|
plate text NOT NULL DEFAULT '',
|
||||||
|
size_bytes bigint NOT NULL DEFAULT 0,
|
||||||
|
mtime double precision NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (source, stored_path)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE files ADD COLUMN IF NOT EXISTS slice_meta jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sends (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
printer_host text NOT NULL,
|
||||||
|
filename text NOT NULL,
|
||||||
|
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
|
||||||
|
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
|
||||||
|
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE sends ADD COLUMN IF NOT EXISTS ace_map jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
ALTER TABLE sends ADD COLUMN IF NOT EXISTS bed_leveling boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS runs (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
printer_host text NOT NULL,
|
||||||
|
printer_name text NOT NULL DEFAULT '',
|
||||||
|
filename text NOT NULL DEFAULT '',
|
||||||
|
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
|
||||||
|
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
|
||||||
|
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
|
||||||
|
state text NOT NULL DEFAULT '',
|
||||||
|
started_at timestamptz,
|
||||||
|
ended_at timestamptz,
|
||||||
|
duration_s double precision NOT NULL DEFAULT 0,
|
||||||
|
weight_g double precision NOT NULL DEFAULT 0,
|
||||||
|
filament_cost double precision NOT NULL DEFAULT 0,
|
||||||
|
energy_cost double precision NOT NULL DEFAULT 0,
|
||||||
|
moonraker_job_id text NOT NULL,
|
||||||
|
deducted boolean NOT NULL DEFAULT false,
|
||||||
|
needs_spool boolean NOT NULL DEFAULT false,
|
||||||
|
verdict text NOT NULL DEFAULT '',
|
||||||
|
material_type text NOT NULL DEFAULT '',
|
||||||
|
UNIQUE (printer_host, moonraker_job_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS files_project ON files (project_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS runs_started ON runs (started_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS printers (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
host text NOT NULL UNIQUE,
|
||||||
|
family text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE printers ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ideas (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
title text NOT NULL,
|
||||||
|
body text NOT NULL DEFAULT '',
|
||||||
|
status text NOT NULL DEFAULT 'offen',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS materials (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
brand text NOT NULL DEFAULT '',
|
||||||
|
material_type text NOT NULL DEFAULT '',
|
||||||
|
density double precision NOT NULL,
|
||||||
|
volumetric double precision NOT NULL,
|
||||||
|
volumetric_s1 double precision,
|
||||||
|
machines text NOT NULL DEFAULT 'beide',
|
||||||
|
price_per_kg double precision NOT NULL DEFAULT 0,
|
||||||
|
note text NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE spools ADD COLUMN IF NOT EXISTS colors jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
ALTER TABLE spools ADD COLUMN IF NOT EXISTS ace_slot smallint;
|
||||||
|
ALTER TABLE spools ADD COLUMN IF NOT EXISTS material_id bigint REFERENCES materials (id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE spools ADD COLUMN IF NOT EXISTS chargen_nr text;
|
||||||
|
ALTER TABLE spools ADD COLUMN IF NOT EXISTS hersteller_charge text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS spools_chargen_nr ON spools (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS spools_code ON spools (code) WHERE code IS NOT NULL AND code <> '';
|
||||||
|
-- Ein ACE-Platz gehört höchstens einer Spule.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS spools_host_slot
|
||||||
|
ON spools (printer_host, ace_slot)
|
||||||
|
WHERE printer_host <> '' AND ace_slot IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
host text NOT NULL DEFAULT '',
|
||||||
|
printer text NOT NULL DEFAULT '',
|
||||||
|
kind text NOT NULL,
|
||||||
|
message text NOT NULL DEFAULT '',
|
||||||
|
filename text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
read_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS events_unread ON events (read_at, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS print_queue (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
file_id bigint NOT NULL REFERENCES files (id) ON DELETE CASCADE,
|
||||||
|
host text NOT NULL DEFAULT '',
|
||||||
|
family text NOT NULL DEFAULT '',
|
||||||
|
priority integer NOT NULL DEFAULT 100,
|
||||||
|
status text NOT NULL DEFAULT 'waiting',
|
||||||
|
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
|
||||||
|
ace_map jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
bed_leveling boolean NOT NULL DEFAULT false,
|
||||||
|
override_machine boolean NOT NULL DEFAULT false,
|
||||||
|
message text NOT NULL DEFAULT '',
|
||||||
|
steps jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
step_index integer NOT NULL DEFAULT 0,
|
||||||
|
repeat_total integer NOT NULL DEFAULT 1,
|
||||||
|
repeat_done integer NOT NULL DEFAULT 0,
|
||||||
|
phase text NOT NULL DEFAULT '',
|
||||||
|
phase_started_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS steps jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS step_index integer NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_total integer NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_done integer NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase_started_at timestamptz;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS print_queue_waiting ON print_queue (status, priority, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS print_queue_running_host ON print_queue (host, status) WHERE status = 'running';
|
||||||
|
|
||||||
|
-- Nach dem Druck: Platte manuell quittieren, bevor „wenn frei“ den nächsten Job startet.
|
||||||
|
CREATE TABLE IF NOT EXISTS bed_status (
|
||||||
|
host text PRIMARY KEY,
|
||||||
|
needs_clear boolean NOT NULL DEFAULT false,
|
||||||
|
cleared_at timestamptz,
|
||||||
|
message text NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
username text NOT NULL UNIQUE,
|
||||||
|
password_hash text NOT NULL,
|
||||||
|
role text NOT NULL DEFAULT 'operator',
|
||||||
|
active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_login timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL DEFAULT '',
|
||||||
|
token_hash text NOT NULL UNIQUE,
|
||||||
|
token_prefix text NOT NULL DEFAULT '',
|
||||||
|
role text NOT NULL DEFAULT 'operator',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_used timestamptz,
|
||||||
|
revoked_at timestamptz,
|
||||||
|
expires_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS api_tokens_user ON api_tokens (user_id) WHERE revoked_at IS NULL;
|
||||||
|
|
||||||
|
-- OIDC später: Identitäten an lokale User binden.
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_identities (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
provider text NOT NULL,
|
||||||
|
subject text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (provider, subject)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Druckbericht: Soll/Ist, Charge, Live-Fortschritt
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_planned_g double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_used_g double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_source text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_used_mm double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_total_mm double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS progress_last double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS estimated_s double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS chargen_nr text;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS message text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS objects jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS runs_chargen_nr
|
||||||
|
ON runs (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
|
||||||
|
|
||||||
|
ALTER TABLE events ADD COLUMN IF NOT EXISTS run_id bigint REFERENCES runs (id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS events_run ON events (run_id) WHERE run_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Abzug je Spule (Messwert oder Schätzwert)
|
||||||
|
CREATE TABLE IF NOT EXISTS run_spools (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
|
||||||
|
ace_slot smallint,
|
||||||
|
weight_g double precision NOT NULL DEFAULT 0,
|
||||||
|
weight_planned_g double precision NOT NULL DEFAULT 0,
|
||||||
|
cost double precision NOT NULL DEFAULT 0,
|
||||||
|
is_estimate boolean NOT NULL DEFAULT false,
|
||||||
|
UNIQUE (run_id, spool_id, ace_slot)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS run_spools_spool ON run_spools (spool_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS run_spools_run ON run_spools (run_id);
|
||||||
|
|
||||||
|
-- Abschnitte bei Rollenwechsel mitten im Druck
|
||||||
|
CREATE TABLE IF NOT EXISTS run_segments (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
|
||||||
|
ace_slot smallint,
|
||||||
|
mm_start double precision NOT NULL DEFAULT 0,
|
||||||
|
mm_end double precision,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
closed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS run_segments_run ON run_segments (run_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS run_segments_open
|
||||||
|
ON run_segments (run_id) WHERE mm_end IS NULL;
|
||||||
|
|
||||||
|
-- Temperatur / Fortschritt während des Drucks
|
||||||
|
CREATE TABLE IF NOT EXISTS run_samples (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
sampled_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
nozzle double precision,
|
||||||
|
nozzle_target double precision,
|
||||||
|
bed double precision,
|
||||||
|
bed_target double precision,
|
||||||
|
chamber double precision,
|
||||||
|
fan double precision,
|
||||||
|
speed_factor double precision,
|
||||||
|
extrude_factor double precision,
|
||||||
|
layer integer,
|
||||||
|
progress double precision,
|
||||||
|
filament_used_mm double precision,
|
||||||
|
mmu_tool smallint,
|
||||||
|
nozzle_power double precision,
|
||||||
|
bed_power double precision
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at);
|
||||||
|
|
||||||
|
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS nozzle_power double precision;
|
||||||
|
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS bed_power double precision;
|
||||||
|
|
||||||
|
-- Objekt-Chargen für Etiketten
|
||||||
|
CREATE TABLE IF NOT EXISTS run_labels (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
object_name text NOT NULL DEFAULT '',
|
||||||
|
chargen_nr text NOT NULL,
|
||||||
|
excluded boolean NOT NULL DEFAULT false,
|
||||||
|
UNIQUE (run_id, object_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS run_labels_chargen ON run_labels (chargen_nr);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Heiße Abfragepfade für Sends, Läufe, Dateien und Ereignisse.
|
||||||
|
CREATE INDEX IF NOT EXISTS sends_host_filename ON sends (printer_host, filename);
|
||||||
|
CREATE INDEX IF NOT EXISTS sends_host_file ON sends (printer_host, file_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS runs_host_state ON runs (printer_host, state);
|
||||||
|
CREATE INDEX IF NOT EXISTS files_name ON files (name);
|
||||||
|
CREATE INDEX IF NOT EXISTS events_host_kind_created ON events (host, kind, created_at DESC);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS verdict_reason text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_ok int NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_bad int NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS maintenance_parts (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
printer_host text NOT NULL,
|
||||||
|
part_key text NOT NULL,
|
||||||
|
label text NOT NULL DEFAULT '',
|
||||||
|
installed_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
life_hours double precision NOT NULL DEFAULT 0,
|
||||||
|
life_meters double precision NOT NULL DEFAULT 0,
|
||||||
|
note text NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS maintenance_parts_host ON maintenance_parts (printer_host);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS maintenance_log (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
printer_host text NOT NULL,
|
||||||
|
part_key text NOT NULL,
|
||||||
|
event text NOT NULL DEFAULT '',
|
||||||
|
hours_at double precision NOT NULL DEFAULT 0,
|
||||||
|
meters_at double precision NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
note text NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS maintenance_log_host ON maintenance_log (printer_host, created_at DESC);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE printers ADD COLUMN IF NOT EXISTS start_window jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
email text NOT NULL DEFAULT '',
|
||||||
|
notes text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
customer_id bigint REFERENCES customers (id) ON DELETE SET NULL,
|
||||||
|
title text NOT NULL,
|
||||||
|
status text NOT NULL DEFAULT 'idee',
|
||||||
|
due_at date,
|
||||||
|
notes text NOT NULL DEFAULT '',
|
||||||
|
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS order_items (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
order_id bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
|
||||||
|
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
|
||||||
|
qty int NOT NULL DEFAULT 1,
|
||||||
|
unit_price double precision NOT NULL DEFAULT 0,
|
||||||
|
material_type text NOT NULL DEFAULT '',
|
||||||
|
color text NOT NULL DEFAULT '',
|
||||||
|
done_ok int NOT NULL DEFAULT 0,
|
||||||
|
done_bad int NOT NULL DEFAULT 0,
|
||||||
|
note text NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
INSERT INTO orders (title, status, notes, quote, created_at)
|
||||||
|
SELECT
|
||||||
|
p.name,
|
||||||
|
CASE p.status
|
||||||
|
WHEN 'bereit' THEN 'angebot'
|
||||||
|
WHEN 'druckt' THEN 'druckt'
|
||||||
|
WHEN 'fertig' THEN 'fertig'
|
||||||
|
ELSE 'idee'
|
||||||
|
END,
|
||||||
|
p.notes,
|
||||||
|
p.quote,
|
||||||
|
p.created_at
|
||||||
|
FROM projects p
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.title = p.name AND o.created_at = p.created_at);
|
||||||
|
|
||||||
|
INSERT INTO orders (title, status, notes, created_at)
|
||||||
|
SELECT
|
||||||
|
i.title,
|
||||||
|
CASE i.status WHEN 'erledigt' THEN 'fertig' ELSE 'idee' END,
|
||||||
|
i.body,
|
||||||
|
i.created_at
|
||||||
|
FROM ideas i
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.title = i.title AND o.created_at = i.created_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS orders_status ON orders (status, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS order_items_order ON order_items (order_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS runs_order_item ON runs (order_item_id) WHERE order_item_id IS NOT NULL;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Auto-Retry und Routing-Hinweise in der Warteschlange.
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS retry_count integer NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS wait_reason text NOT NULL DEFAULT '';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- PA12-CF bleibt in Grotti die echte Sorte. Der ACE kennt nur PA6-CF.
|
||||||
|
UPDATE materials
|
||||||
|
SET material_type = 'PA12-CF'
|
||||||
|
WHERE name = 'Sunlu PA12-CF' AND material_type = 'PA';
|
||||||
|
|
||||||
|
UPDATE spools
|
||||||
|
SET material_type = 'PA12-CF'
|
||||||
|
WHERE name = 'Sunlu PA12-CF' AND material_type = 'PA';
|
||||||
@@ -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 '';
|
||||||
@@ -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 = {}
|
||||||
@@ -17,6 +20,16 @@ SLOT_CACHE = {}
|
|||||||
MESH_CACHE = {}
|
MESH_CACHE = {}
|
||||||
OBJECT_CACHE = {}
|
OBJECT_CACHE = {}
|
||||||
THUMB_RE_LIMIT = 180_000
|
THUMB_RE_LIMIT = 180_000
|
||||||
|
_CACHE_MAX = 128
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_cache(cache, max_items=_CACHE_MAX):
|
||||||
|
"""Einfache Größenbegrenzung (älteste Keys zuerst bei dict-Reihenfolge)."""
|
||||||
|
while len(cache) > max_items:
|
||||||
|
try:
|
||||||
|
cache.pop(next(iter(cache)))
|
||||||
|
except StopIteration:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
def matrix_rows(points):
|
def matrix_rows(points):
|
||||||
@@ -50,31 +63,6 @@ def _raw_matrix(points):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def fetch_bed_mesh(host):
|
|
||||||
"""Roh-Bettnetz von Moonraker (ohne UI-Spiegelung)."""
|
|
||||||
url = "http://%s/printer/objects/query?bed_mesh" % host
|
|
||||||
payload = fetch_json(url, timeout=10)
|
|
||||||
status = (payload.get("result") or {}).get("status") or {}
|
|
||||||
mesh = status.get("bed_mesh") or {}
|
|
||||||
matrix = _raw_matrix(mesh.get("probed_matrix"))
|
|
||||||
if not matrix:
|
|
||||||
matrix = _raw_matrix(mesh.get("mesh_matrix"))
|
|
||||||
mesh_min = mesh.get("mesh_min")
|
|
||||||
mesh_max = mesh.get("mesh_max")
|
|
||||||
if not (isinstance(mesh_min, (list, tuple)) and len(mesh_min) >= 2):
|
|
||||||
mesh_min = None
|
|
||||||
if not (isinstance(mesh_max, (list, tuple)) and len(mesh_max) >= 2):
|
|
||||||
mesh_max = None
|
|
||||||
if not matrix or mesh_min is None or mesh_max is None:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"matrix": matrix,
|
|
||||||
"mesh_min": [float(mesh_min[0]), float(mesh_min[1])],
|
|
||||||
"mesh_max": [float(mesh_max[0]), float(mesh_max[1])],
|
|
||||||
"profile": mesh.get("profile_name") or "",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def store_mesh(host, shown, profile=""):
|
def store_mesh(host, shown, profile=""):
|
||||||
if not shown:
|
if not shown:
|
||||||
return
|
return
|
||||||
@@ -118,11 +106,6 @@ class PrinterError(Exception):
|
|||||||
self.message = message
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
def is_timeout(exc):
|
|
||||||
text = (getattr(exc, "message", None) or str(exc) or "").lower()
|
|
||||||
return "zu lange nicht geantwortet" in text or "timed out" in text
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_json(url, timeout=4):
|
def fetch_json(url, timeout=4):
|
||||||
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
@@ -165,18 +148,53 @@ def post_json(host, path, payload, timeout=20):
|
|||||||
raise PrinterError("Der Drucker ist nicht erreichbar.") from exc
|
raise PrinterError("Der Drucker ist nicht erreichbar.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def safe_upload_name(filename):
|
||||||
|
"""Dateiname für Moonraker/Rinkhals/gklib — nur ASCII.
|
||||||
|
|
||||||
|
`filename` vs. `filename*` mit CJK führte zu Upload unter `_` und Start
|
||||||
|
unter dem Originalnamen → Fehler 10115 „gcode … not found“.
|
||||||
|
"""
|
||||||
|
raw = (filename or "").replace("\\", "/")
|
||||||
|
parts = [part for part in raw.split("/") if part]
|
||||||
|
base = parts[-1] if parts else "job.gcode"
|
||||||
|
out = []
|
||||||
|
for ch in base:
|
||||||
|
if ch.isascii() and ch not in '"\\':
|
||||||
|
out.append(ch if ch not in " \t" else "_")
|
||||||
|
else:
|
||||||
|
out.append("_")
|
||||||
|
text = "".join(out)
|
||||||
|
while "__" in text:
|
||||||
|
text = text.replace("__", "_")
|
||||||
|
while "_-" in text or "-_" in text:
|
||||||
|
text = text.replace("_-", "-").replace("-_", "-")
|
||||||
|
text = text.strip("._-") or "job"
|
||||||
|
lower = text.lower()
|
||||||
|
if not (lower.endswith(".gcode") or lower.endswith(".gcode.3mf") or lower.endswith(".3mf") or lower.endswith(".acm")):
|
||||||
|
# Suffix vom Original behalten, falls nur der Stamm zerstört wurde
|
||||||
|
if base.lower().endswith(".gcode.3mf"):
|
||||||
|
text += ".gcode.3mf"
|
||||||
|
elif base.lower().endswith(".gcode"):
|
||||||
|
text += ".gcode"
|
||||||
|
elif base.lower().endswith(".acm"):
|
||||||
|
text += ".acm"
|
||||||
|
if parts:
|
||||||
|
parts[-1] = text
|
||||||
|
return "/".join(parts)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def multipart(filename, content):
|
def multipart(filename, content):
|
||||||
boundary = "----GrottiBoundary7f3a"
|
boundary = "----GrottiBoundary7f3a"
|
||||||
ascii_name = "".join(ch if ch.isascii() and ch not in '"\\' else "_" for ch in filename) or "job.gcode"
|
name = safe_upload_name(filename)
|
||||||
quoted = urllib.parse.quote(filename)
|
|
||||||
fields = (
|
fields = (
|
||||||
"--%s\r\nContent-Disposition: form-data; name=\"root\"\r\n\r\ngcodes\r\n" % boundary
|
"--%s\r\nContent-Disposition: form-data; name=\"root\"\r\n\r\ngcodes\r\n" % boundary
|
||||||
).encode("ascii")
|
).encode("ascii")
|
||||||
head = (
|
head = (
|
||||||
"--%s\r\n"
|
"--%s\r\n"
|
||||||
"Content-Disposition: form-data; name=\"file\"; filename=\"%s\"; filename*=UTF-8''%s\r\n"
|
"Content-Disposition: form-data; name=\"file\"; filename=\"%s\"\r\n"
|
||||||
"Content-Type: application/octet-stream\r\n\r\n"
|
"Content-Type: application/octet-stream\r\n\r\n"
|
||||||
) % (boundary, ascii_name, quoted)
|
) % (boundary, name)
|
||||||
tail = ("\r\n--%s--\r\n" % boundary).encode("ascii")
|
tail = ("\r\n--%s--\r\n" % boundary).encode("ascii")
|
||||||
return boundary, fields + head.encode("ascii") + content + tail
|
return boundary, fields + head.encode("ascii") + content + tail
|
||||||
|
|
||||||
@@ -217,6 +235,7 @@ def upload_timeout(size):
|
|||||||
|
|
||||||
|
|
||||||
def upload(host, filename, content, on_progress=None):
|
def upload(host, filename, content, on_progress=None):
|
||||||
|
filename = safe_upload_name(filename)
|
||||||
boundary, body = multipart(filename, content)
|
boundary, body = multipart(filename, content)
|
||||||
stream = _UploadBody(body, on_progress=on_progress)
|
stream = _UploadBody(body, on_progress=on_progress)
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
@@ -236,7 +255,8 @@ def upload(host, filename, content, on_progress=None):
|
|||||||
except (urllib.error.URLError, TimeoutError) as exc:
|
except (urllib.error.URLError, TimeoutError) as exc:
|
||||||
raise PrinterError("Hochladen ist fehlgeschlagen.") from exc
|
raise PrinterError("Hochladen ist fehlgeschlagen.") from exc
|
||||||
item = (payload.get("result") or {}).get("item") or {}
|
item = (payload.get("result") or {}).get("item") or {}
|
||||||
return item.get("path") or filename
|
# Immer denselben ASCII-Namen nutzen, den wir hochgeladen haben
|
||||||
|
return safe_upload_name(item.get("path") or filename)
|
||||||
|
|
||||||
|
|
||||||
def start_print(host, filename, bed_leveling=False, ams_settings=None):
|
def start_print(host, filename, bed_leveling=False, ams_settings=None):
|
||||||
@@ -246,21 +266,29 @@ def start_print(host, filename, bed_leveling=False, ams_settings=None):
|
|||||||
MQTT selbst (auto_leveling aus App-Property; ams_settings aus .acm /
|
MQTT selbst (auto_leveling aus App-Property; ams_settings aus .acm /
|
||||||
Identity-Fallback). Bei bed_leveling oder gesetzter Grotti-ams_settings
|
Identity-Fallback). Bei bed_leveling oder gesetzter Grotti-ams_settings
|
||||||
startet Grotti per MQTTS :9883 und legt die Map explizit in die Payload.
|
startet Grotti per MQTTS :9883 und legt die Map explizit in die Payload.
|
||||||
|
|
||||||
|
Vor dem reinen Moonraker-Pfad: best-effort ensure_task_free — Rinkhals
|
||||||
|
wandelt Start oft intern zu MQTT um; hängende gkapi-Tasks (10101) blocken.
|
||||||
"""
|
"""
|
||||||
use_mqtt = bool(bed_leveling) or bool(ams_settings)
|
use_mqtt = bool(bed_leveling) or bool(ams_settings)
|
||||||
if not use_mqtt:
|
|
||||||
return post_json(host, "/printer/print/start", {"filename": filename})
|
|
||||||
from grotti import anycubic_lan
|
from grotti import anycubic_lan
|
||||||
|
|
||||||
|
if use_mqtt:
|
||||||
|
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
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return anycubic_lan.start_print(
|
anycubic_lan.ensure_task_free(host)
|
||||||
host,
|
except Exception:
|
||||||
filename,
|
pass
|
||||||
auto_leveling=bool(bed_leveling),
|
return post_json(host, "/printer/print/start", {"filename": filename})
|
||||||
ams_settings=ams_settings,
|
|
||||||
)
|
|
||||||
except anycubic_lan.LanError as exc:
|
|
||||||
raise PrinterError(exc.message) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def command(host, action):
|
def command(host, action):
|
||||||
@@ -328,6 +356,11 @@ def steuern(host, action, params=None):
|
|||||||
"""Whitelist-Steuerung ohne freie Konsole."""
|
"""Whitelist-Steuerung ohne freie Konsole."""
|
||||||
params = params if isinstance(params, dict) else {}
|
params = params if isinstance(params, dict) else {}
|
||||||
action = (action or "").strip()
|
action = (action or "").strip()
|
||||||
|
# MCP und ältere Clients schicken "value" statt temp/percent.
|
||||||
|
if "temp" not in params and params.get("value") is not None and action in ("nozzle", "bed", "dryer_start"):
|
||||||
|
params = {**params, "temp": params["value"]}
|
||||||
|
if "percent" not in params and params.get("value") is not None and action in ("speed", "flow", "fan"):
|
||||||
|
params = {**params, "percent": params["value"]}
|
||||||
|
|
||||||
def pct(key, default=100, lo=0, hi=200):
|
def pct(key, default=100, lo=0, hi=200):
|
||||||
try:
|
try:
|
||||||
@@ -373,6 +406,64 @@ def steuern(host, action, params=None):
|
|||||||
if not re.fullmatch(r"[A-Za-z0-9._+\-]+", name):
|
if not re.fullmatch(r"[A-Za-z0-9._+\-]+", name):
|
||||||
raise PrinterError("Ungültiger Objektname.")
|
raise PrinterError("Ungültiger Objektname.")
|
||||||
return gcode_script(host, "EXCLUDE_OBJECT NAME=%s" % name, timeout=20)
|
return gcode_script(host, "EXCLUDE_OBJECT NAME=%s" % name, timeout=20)
|
||||||
|
if action == "dryer_start":
|
||||||
|
try:
|
||||||
|
unit = int(params.get("id"))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
|
||||||
|
if unit not in (0, 1):
|
||||||
|
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
|
||||||
|
try:
|
||||||
|
temp = int(round(float(params.get("temp"))))
|
||||||
|
duration = int(round(float(params.get("duration"))))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PrinterError("Temperatur und Dauer müssen Zahlen sein.") from exc
|
||||||
|
if temp < 35 or temp > 65:
|
||||||
|
raise PrinterError("Trockner-Temperatur außerhalb 35–65 °C.")
|
||||||
|
if duration < 1 or duration > 720:
|
||||||
|
raise PrinterError("Trockner-Dauer außerhalb 1–720 Minuten.")
|
||||||
|
return post_json(
|
||||||
|
host,
|
||||||
|
"/server/filament_hub/start_drying",
|
||||||
|
{"id": unit, "temp": temp, "duration": duration, "fan_speed": 0},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if action == "dryer_stop":
|
||||||
|
try:
|
||||||
|
unit = int(params.get("id"))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
|
||||||
|
if unit not in (0, 1):
|
||||||
|
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
|
||||||
|
return post_json(host, "/server/filament_hub/stop_drying", {"id": unit}, timeout=15)
|
||||||
|
if action == "slot_setzen":
|
||||||
|
hub_id = _int_or_none(params.get("id"))
|
||||||
|
index = _int_or_none(params.get("index"))
|
||||||
|
if hub_id not in (0, 1) or index is None or index < 0 or index > 3:
|
||||||
|
raise PrinterError("ACE-Platz ungültig.")
|
||||||
|
material = str(params.get("type") or "").strip()
|
||||||
|
if not material:
|
||||||
|
raise PrinterError("Die Spule braucht eine Sorte.")
|
||||||
|
color = params.get("color")
|
||||||
|
if not isinstance(color, dict):
|
||||||
|
raise PrinterError("Die Spule braucht eine Farbe.")
|
||||||
|
try:
|
||||||
|
red = max(0, min(255, int(color.get("R"))))
|
||||||
|
green = max(0, min(255, int(color.get("G"))))
|
||||||
|
blue = max(0, min(255, int(color.get("B"))))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PrinterError("Die Spule braucht eine Farbe.") from exc
|
||||||
|
return post_json(
|
||||||
|
host,
|
||||||
|
"/printer/filament_hub/set_filament_info",
|
||||||
|
{
|
||||||
|
"id": hub_id,
|
||||||
|
"index": index,
|
||||||
|
"type": material,
|
||||||
|
"color": {"R": red, "G": green, "B": blue},
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
if action == "firmware_restart":
|
if action == "firmware_restart":
|
||||||
# GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32).
|
# GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32).
|
||||||
raise PrinterError(
|
raise PrinterError(
|
||||||
@@ -419,6 +510,7 @@ def metadata(host, path):
|
|||||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
||||||
data = {}
|
data = {}
|
||||||
META_CACHE[key] = (now, data)
|
META_CACHE[key] = (now, data)
|
||||||
|
_trim_cache(META_CACHE)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -445,9 +537,11 @@ def embedded_preview(host, path):
|
|||||||
text = response.read().decode("utf-8", "replace")
|
text = response.read().decode("utf-8", "replace")
|
||||||
except (urllib.error.URLError, TimeoutError):
|
except (urllib.error.URLError, TimeoutError):
|
||||||
EMBEDDED[key] = None
|
EMBEDDED[key] = None
|
||||||
|
_trim_cache(EMBEDDED)
|
||||||
return None
|
return None
|
||||||
png = embedded_png(text)
|
png = embedded_png(text)
|
||||||
EMBEDDED[key] = png
|
EMBEDDED[key] = png
|
||||||
|
_trim_cache(EMBEDDED)
|
||||||
return png
|
return png
|
||||||
|
|
||||||
|
|
||||||
@@ -555,6 +649,80 @@ def extract_slots(status):
|
|||||||
return found[:8]
|
return found[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def _int_or_none(value):
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
return None
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_dryers(status):
|
||||||
|
"""Echte ACE-Boxen aus mmu_machine (num_gates > 0), Dummy-Unit bleibt weg."""
|
||||||
|
machine = (status or {}).get("mmu_machine")
|
||||||
|
if not isinstance(machine, dict):
|
||||||
|
return []
|
||||||
|
found = []
|
||||||
|
for unit_id in (0, 1):
|
||||||
|
unit = machine.get("unit_%d" % unit_id)
|
||||||
|
if not isinstance(unit, dict):
|
||||||
|
continue
|
||||||
|
gates = _int_or_none(unit.get("num_gates"))
|
||||||
|
if gates is None or gates < 1:
|
||||||
|
continue
|
||||||
|
found.append({
|
||||||
|
"id": unit_id,
|
||||||
|
"status": str(unit.get("dryer_status") or "stop"),
|
||||||
|
"temp": _int_or_none(unit.get("dryer_temp")) or 0,
|
||||||
|
"target": _int_or_none(unit.get("dryer_target_temp")) or 0,
|
||||||
|
"remain": _int_or_none(unit.get("dryer_remaining")) or 0,
|
||||||
|
"humidity": _int_or_none(unit.get("dryer_humidity")) or 0,
|
||||||
|
"gates": gates,
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def ace_hub_index(ace_slot):
|
||||||
|
"""Grotti-Platz 1–8 → Moonraker id (0/1) und index in der Box (0–3)."""
|
||||||
|
try:
|
||||||
|
slot = int(ace_slot)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PrinterError("ACE-Platz muss 1–8 sein.") from exc
|
||||||
|
if slot < 1 or slot > 8:
|
||||||
|
raise PrinterError("ACE-Platz muss 1–8 sein.")
|
||||||
|
return (slot - 1) // 4, (slot - 1) % 4
|
||||||
|
|
||||||
|
|
||||||
|
def spool_rgb(spool):
|
||||||
|
"""Erste Farbe der Spule als RGB-Dict für set_filament_info."""
|
||||||
|
colors = (spool or {}).get("colors") or []
|
||||||
|
if isinstance(colors, str):
|
||||||
|
try:
|
||||||
|
colors = json.loads(colors)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
colors = []
|
||||||
|
hex_value = ""
|
||||||
|
if isinstance(colors, list):
|
||||||
|
for item in colors:
|
||||||
|
if isinstance(item, dict) and item.get("hex"):
|
||||||
|
hex_value = str(item["hex"]).strip()
|
||||||
|
break
|
||||||
|
if isinstance(item, str) and item.strip():
|
||||||
|
hex_value = item.strip()
|
||||||
|
break
|
||||||
|
if not hex_value:
|
||||||
|
hex_value = str((spool or {}).get("color") or "").strip()
|
||||||
|
if hex_value.startswith("#"):
|
||||||
|
hex_value = hex_value[1:]
|
||||||
|
if len(hex_value) == 8:
|
||||||
|
hex_value = hex_value[:6]
|
||||||
|
if len(hex_value) != 6 or any(ch not in "0123456789abcdefABCDEF" for ch in hex_value):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"R": int(hex_value[0:2], 16),
|
||||||
|
"G": int(hex_value[2:4], 16),
|
||||||
|
"B": int(hex_value[4:6], 16),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def slots(host, status=None):
|
def slots(host, status=None):
|
||||||
if status is not None:
|
if status is not None:
|
||||||
found = extract_slots(status)
|
found = extract_slots(status)
|
||||||
@@ -599,6 +767,16 @@ def fan_values(status):
|
|||||||
return speed, rpm
|
return speed, rpm
|
||||||
|
|
||||||
|
|
||||||
|
def _heater_power(entry):
|
||||||
|
"""Klipper-Einschaltdauer 0…1 (ED), sonst None."""
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return None
|
||||||
|
power = entry.get("power")
|
||||||
|
if isinstance(power, (int, float)) and not isinstance(power, bool):
|
||||||
|
return max(0.0, min(1.0, float(power)))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _object_names(host):
|
def _object_names(host):
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cached = OBJECT_CACHE.get(host)
|
cached = OBJECT_CACHE.get(host)
|
||||||
@@ -714,6 +892,99 @@ def enrich_error_message(host, stats, status):
|
|||||||
return " · ".join(bits) if bits else (raw or "Unbekannter Fehler — in Mainsail prüfen")
|
return " · ".join(bits) if bits else (raw or "Unbekannter Fehler — in Mainsail prüfen")
|
||||||
|
|
||||||
|
|
||||||
|
_FILAMENT_HINT_RE = re.compile(
|
||||||
|
r"filament|runout|out of fil|no fil|fil.?empty|10107|"
|
||||||
|
r"material.?leer|spule.?leer|filament.?gerissen|filament.?fehlt|"
|
||||||
|
r"filament.?sensor|out of material",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def last_filament_gcode_hint(host, limit=40):
|
||||||
|
"""Letzte G-code-Zeile, die auf Filament-Ende / Runout hindeutet."""
|
||||||
|
try:
|
||||||
|
payload = fetch_json("http://%s/server/gcode_store?count=%d" % (host, int(limit)), timeout=3)
|
||||||
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError):
|
||||||
|
return ""
|
||||||
|
items = ((payload.get("result") or {}).get("gcode_store") or [])
|
||||||
|
for item in reversed(items):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
text = (item.get("message") or "").strip()
|
||||||
|
if not text or not _FILAMENT_HINT_RE.search(text):
|
||||||
|
continue
|
||||||
|
low = text.lower()
|
||||||
|
if "10107" in low:
|
||||||
|
return "Filament gerissen oder leer (10107)"
|
||||||
|
return text[:160]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _mmu_tool_index(mmu):
|
||||||
|
"""Aktives ACE/MMU-Tool, sonst None. −1 / fehlend = kein Tool (Anycubic-Normalfall)."""
|
||||||
|
if not isinstance(mmu, dict):
|
||||||
|
return None
|
||||||
|
tool = mmu.get("tool")
|
||||||
|
if not isinstance(tool, (int, float)) or isinstance(tool, bool):
|
||||||
|
tool = mmu.get("gate")
|
||||||
|
if not isinstance(tool, (int, float)) or isinstance(tool, bool):
|
||||||
|
return None
|
||||||
|
tool_i = int(tool)
|
||||||
|
return tool_i if tool_i >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def filament_out_hint(mmu, message="", host=None):
|
||||||
|
"""Vermutetes Material-Ende bei Pause: (code, Text) oder (None, None).
|
||||||
|
|
||||||
|
Quellen: print_stats.message, mmu.reason_for_pause, gate_status bei aktivem Tool,
|
||||||
|
active_filament.empty nur mit aktivem Tool, optional G-code-Store (host).
|
||||||
|
|
||||||
|
Nicht: active_filament.empty allein — Anycubic ACE meldet empty=true auch
|
||||||
|
beim normalen Druck und bei manueller Pause (tool/gate = −1).
|
||||||
|
"""
|
||||||
|
text = (message or "").strip()
|
||||||
|
if text and _FILAMENT_HINT_RE.search(text):
|
||||||
|
if "10107" in text.lower():
|
||||||
|
return "message", "Filament gerissen oder leer (10107)"
|
||||||
|
return "message", text[:160]
|
||||||
|
|
||||||
|
if isinstance(mmu, dict) and mmu:
|
||||||
|
rfp = (mmu.get("reason_for_pause") or "").strip()
|
||||||
|
if rfp and _FILAMENT_HINT_RE.search(rfp):
|
||||||
|
if "10107" in rfp.lower():
|
||||||
|
return "reason", "Filament gerissen oder leer (10107)"
|
||||||
|
return "reason", rfp[:160]
|
||||||
|
|
||||||
|
tool_i = _mmu_tool_index(mmu)
|
||||||
|
states = mmu.get("gate_status")
|
||||||
|
# gate_status==0 nur, wenn Status differenziert (nicht alle 0 = Anycubic-Unknown)
|
||||||
|
if (
|
||||||
|
tool_i is not None
|
||||||
|
and isinstance(states, list)
|
||||||
|
and tool_i < len(states)
|
||||||
|
and int(states[tool_i] or 0) == 0
|
||||||
|
and any(int(s or 0) != 0 for s in states)
|
||||||
|
):
|
||||||
|
return "gate_empty", "ACE-Platz %d leer" % (tool_i + 1)
|
||||||
|
|
||||||
|
active = mmu.get("active_filament")
|
||||||
|
if (
|
||||||
|
tool_i is not None
|
||||||
|
and isinstance(active, dict)
|
||||||
|
and (
|
||||||
|
active.get("empty") is True
|
||||||
|
or str(active.get("empty") or "").lower() in ("1", "true")
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return "active_empty", "Aktives Filament leer (ACE)"
|
||||||
|
|
||||||
|
if host:
|
||||||
|
hint = last_filament_gcode_hint(host)
|
||||||
|
if hint:
|
||||||
|
return "gcode", hint
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def exclude_status(host):
|
def exclude_status(host):
|
||||||
"""Klipper [exclude_object] — leer, wenn Modul fehlt oder Query scheitert."""
|
"""Klipper [exclude_object] — leer, wenn Modul fehlt oder Query scheitert."""
|
||||||
empty = {"objects": [], "excluded": [], "current": None}
|
empty = {"objects": [], "excluded": [], "current": None}
|
||||||
@@ -823,6 +1094,8 @@ def query(host):
|
|||||||
extras = []
|
extras = []
|
||||||
if "probe" in names:
|
if "probe" in names:
|
||||||
extras.append("probe")
|
extras.append("probe")
|
||||||
|
if "mmu_machine" in names:
|
||||||
|
extras.append("mmu_machine")
|
||||||
url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras))
|
url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras))
|
||||||
payload = fetch_json(url)
|
payload = fetch_json(url)
|
||||||
status = payload["result"]["status"]
|
status = payload["result"]["status"]
|
||||||
@@ -830,6 +1103,21 @@ def query(host):
|
|||||||
card = status.get("virtual_sdcard") or {}
|
card = status.get("virtual_sdcard") or {}
|
||||||
extruder = status.get("extruder") or {}
|
extruder = status.get("extruder") or {}
|
||||||
bed = status.get("heater_bed") or {}
|
bed = status.get("heater_bed") or {}
|
||||||
|
mmu = status.get("mmu") or {}
|
||||||
|
mmu_tool = mmu.get("tool")
|
||||||
|
if not isinstance(mmu_tool, (int, float)) or isinstance(mmu_tool, bool):
|
||||||
|
mmu_tool = mmu.get("gate")
|
||||||
|
if isinstance(mmu_tool, (int, float)) and not isinstance(mmu_tool, bool):
|
||||||
|
mmu_tool = int(mmu_tool)
|
||||||
|
if mmu_tool < 0:
|
||||||
|
mmu_tool = None
|
||||||
|
else:
|
||||||
|
mmu_tool = None
|
||||||
|
filament_used = stats.get("filament_used")
|
||||||
|
if not isinstance(filament_used, (int, float)) or isinstance(filament_used, bool):
|
||||||
|
filament_used = None
|
||||||
|
else:
|
||||||
|
filament_used = float(filament_used)
|
||||||
raw_name = stats.get("filename") or ""
|
raw_name = stats.get("filename") or ""
|
||||||
filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else ""
|
filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else ""
|
||||||
meta = metadata(host, raw_name) if raw_name else {}
|
meta = metadata(host, raw_name) if raw_name else {}
|
||||||
@@ -863,6 +1151,15 @@ def query(host):
|
|||||||
message = stats.get("message") or ""
|
message = stats.get("message") or ""
|
||||||
if (state or "").lower() == "error":
|
if (state or "").lower() == "error":
|
||||||
message = enrich_error_message(host, stats, status)
|
message = enrich_error_message(host, stats, status)
|
||||||
|
pause_code, pause_reason = None, ""
|
||||||
|
filament_empty = False
|
||||||
|
if (state or "").lower() == "paused":
|
||||||
|
# G-code-Store nur bei Pause abfragen — sonst unnötige Last.
|
||||||
|
pause_code, pause_reason = filament_out_hint(mmu, message, host=host)
|
||||||
|
filament_empty = bool(pause_code)
|
||||||
|
if filament_empty and pause_reason:
|
||||||
|
if not message or message.lower() in ("paused", "pause", "pausiert"):
|
||||||
|
message = pause_reason
|
||||||
toolhead = status.get("toolhead") or {}
|
toolhead = status.get("toolhead") or {}
|
||||||
axis_max = toolhead.get("axis_maximum") or []
|
axis_max = toolhead.get("axis_maximum") or []
|
||||||
z_max = None
|
z_max = None
|
||||||
@@ -872,6 +1169,8 @@ def query(host):
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"state": state,
|
"state": state,
|
||||||
"message": message,
|
"message": message,
|
||||||
|
"filament_empty": filament_empty,
|
||||||
|
"pause_reason": pause_reason or "",
|
||||||
"homed_axes": toolhead.get("homed_axes") or "",
|
"homed_axes": toolhead.get("homed_axes") or "",
|
||||||
"position": toolhead.get("position") or [],
|
"position": toolhead.get("position") or [],
|
||||||
"z_max": z_max,
|
"z_max": z_max,
|
||||||
@@ -884,12 +1183,21 @@ def query(host):
|
|||||||
"elapsed": stats.get("print_duration") or 0,
|
"elapsed": stats.get("print_duration") or 0,
|
||||||
"nozzle": extruder.get("temperature"),
|
"nozzle": extruder.get("temperature"),
|
||||||
"nozzle_target": extruder.get("target"),
|
"nozzle_target": extruder.get("target"),
|
||||||
|
"nozzle_power": _heater_power(extruder),
|
||||||
"bed": bed.get("temperature"),
|
"bed": bed.get("temperature"),
|
||||||
"bed_target": bed.get("target"),
|
"bed_target": bed.get("target"),
|
||||||
|
"bed_power": _heater_power(bed),
|
||||||
"chamber": chamber,
|
"chamber": chamber,
|
||||||
"chamber_target": chamber_target,
|
"chamber_target": chamber_target,
|
||||||
|
"filament_used": filament_used,
|
||||||
|
"filament_total": meta.get("filament_total") or 0,
|
||||||
|
"mmu_tool": mmu_tool,
|
||||||
"material": card.get("filament_type") or meta.get("filament_type") or "",
|
"material": card.get("filament_type") or meta.get("filament_type") or "",
|
||||||
"weight": meta.get("filament_weight_total") or 0,
|
"weight": (
|
||||||
|
sum(float(x or 0) for x in meta["filament_weight_total"])
|
||||||
|
if isinstance(meta.get("filament_weight_total"), list)
|
||||||
|
else (meta.get("filament_weight_total") or 0)
|
||||||
|
),
|
||||||
"height": meta.get("object_height") or 0,
|
"height": meta.get("object_height") or 0,
|
||||||
"layer_height": meta.get("layer_height") or card.get("layer_height") or 0,
|
"layer_height": meta.get("layer_height") or card.get("layer_height") or 0,
|
||||||
"estimated": meta.get("estimated_time") or 0,
|
"estimated": meta.get("estimated_time") or 0,
|
||||||
@@ -899,7 +1207,7 @@ def query(host):
|
|||||||
"/api/v1/vorschau?host=%s&embedded=%s" % (quoted_host, urllib.parse.quote(raw_name))
|
"/api/v1/vorschau?host=%s&embedded=%s" % (quoted_host, urllib.parse.quote(raw_name))
|
||||||
if raw_name and EMBEDDED.get((host, raw_name)) else ""
|
if raw_name and EMBEDDED.get((host, raw_name)) else ""
|
||||||
),
|
),
|
||||||
"camera": "/api/v1/kamera?host=%s" % quoted_host,
|
**_camera_fields(host, quoted_host),
|
||||||
**mesh_fields(host, shown, mesh_profile),
|
**mesh_fields(host, shown, mesh_profile),
|
||||||
"fan": fan_speed,
|
"fan": fan_speed,
|
||||||
"fan_rpm": fan_rpm,
|
"fan_rpm": fan_rpm,
|
||||||
@@ -907,10 +1215,22 @@ def query(host):
|
|||||||
"speed_factor": speed_factor,
|
"speed_factor": speed_factor,
|
||||||
"extrude_factor": extrude_factor,
|
"extrude_factor": extrude_factor,
|
||||||
"slots": slots(host, status),
|
"slots": slots(host, status),
|
||||||
|
"dryers": extract_dryers(status),
|
||||||
"exclude": exclude_status(host),
|
"exclude": exclude_status(host),
|
||||||
|
"anycubic_task": _anycubic_task_field(host),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _anycubic_task_field(host):
|
||||||
|
"""gkapi-Task aus print_info (TTL-Cache in anycubic_lan), best-effort."""
|
||||||
|
try:
|
||||||
|
from grotti import anycubic_lan
|
||||||
|
|
||||||
|
return anycubic_lan.anycubic_task(host)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def snapshot(printers):
|
def snapshot(printers):
|
||||||
def one(printer):
|
def one(printer):
|
||||||
card = {
|
card = {
|
||||||
@@ -922,11 +1242,17 @@ def snapshot(printers):
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
card.update(query(printer["host"]))
|
card.update(query(printer["host"]))
|
||||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as error:
|
except Exception as error:
|
||||||
|
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
|
||||||
|
log.warning("snapshot %s: %s", printer.get("host"), error)
|
||||||
card.update({
|
card.update({
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"state": "offline",
|
"state": "offline",
|
||||||
"message": str(error),
|
"message": str(error),
|
||||||
|
"filament_empty": False,
|
||||||
|
"pause_reason": "",
|
||||||
|
"camera": "",
|
||||||
|
"has_camera": False,
|
||||||
"fan": None,
|
"fan": None,
|
||||||
"fan_rpm": None,
|
"fan_rpm": None,
|
||||||
"fans": {"part": None, "chamber": None, "aux": None},
|
"fans": {"part": None, "chamber": None, "aux": None},
|
||||||
@@ -935,6 +1261,8 @@ def snapshot(printers):
|
|||||||
"speed_factor": None,
|
"speed_factor": None,
|
||||||
"extrude_factor": None,
|
"extrude_factor": None,
|
||||||
"slots": [],
|
"slots": [],
|
||||||
|
"dryers": [],
|
||||||
|
"anycubic_task": None,
|
||||||
})
|
})
|
||||||
card.update(mesh_fields(printer["host"]))
|
card.update(mesh_fields(printer["host"]))
|
||||||
return card
|
return card
|
||||||
@@ -966,5 +1294,24 @@ def camera_image(host):
|
|||||||
return CAMERA_CACHE[host][2]
|
return CAMERA_CACHE[host][2]
|
||||||
|
|
||||||
|
|
||||||
|
def open_camera_stream(host):
|
||||||
|
"""Öffnet den MJPEG-Stream am Drucker. Aufrufer schließt die Response."""
|
||||||
|
url = "http://%s/webcam/?action=stream" % host
|
||||||
|
request = urllib.request.Request(url)
|
||||||
|
return urllib.request.urlopen(request, timeout=30)
|
||||||
|
|
||||||
|
|
||||||
|
def _camera_fields(host, quoted_host):
|
||||||
|
"""Kamera-URL nur, wenn nicht als tot bekannt (Cache-Miss = erst versuchen)."""
|
||||||
|
snap = "/api/v1/kamera?host=%s" % quoted_host
|
||||||
|
stream = "/api/v1/kamera/stream?host=%s" % quoted_host
|
||||||
|
cached = CAMERA_CACHE.get(host)
|
||||||
|
if cached is not None and cached[2] is None:
|
||||||
|
return {"camera": "", "camera_stream": "", "has_camera": False}
|
||||||
|
if cached is not None and cached[2] is not None:
|
||||||
|
return {"camera": snap, "camera_stream": stream, "has_camera": True}
|
||||||
|
return {"camera": snap, "camera_stream": stream, "has_camera": None}
|
||||||
|
|
||||||
|
|
||||||
def embedded_bytes(host, name):
|
def embedded_bytes(host, name):
|
||||||
return EMBEDDED.get((host, name))
|
return EMBEDDED.get((host, name))
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Telegram-Benachrichtigungen (Bot API)."""
|
"""Telegram- und ntfy-Benachrichtigungen."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -33,7 +34,12 @@ def send_telegram(cfg, text):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
ALERT_GROUP = {"abbruch": "fehler"}
|
ALERT_GROUP = {
|
||||||
|
"abbruch": "fehler",
|
||||||
|
"bedarf": "queue",
|
||||||
|
"wartung": "queue",
|
||||||
|
"auftrag": "queue",
|
||||||
|
}
|
||||||
DEFAULT_OFF = {"ablauf", "start"}
|
DEFAULT_OFF = {"ablauf", "start"}
|
||||||
|
|
||||||
|
|
||||||
@@ -43,11 +49,52 @@ def alert_enabled(cfg, kind):
|
|||||||
return bool(alerts.get(key, key not in DEFAULT_OFF))
|
return bool(alerts.get(key, key not in DEFAULT_OFF))
|
||||||
|
|
||||||
|
|
||||||
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60):
|
def ntfy_configured(cfg):
|
||||||
"""Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster."""
|
ntfy = (cfg or {}).get("ntfy") or {}
|
||||||
|
return bool(ntfy.get("enabled") and ntfy.get("topic"))
|
||||||
|
|
||||||
|
|
||||||
|
def send_ntfy(cfg, text):
|
||||||
|
if not ntfy_configured(cfg):
|
||||||
|
return False
|
||||||
|
ntfy = cfg["ntfy"]
|
||||||
|
server = str(ntfy.get("server") or "https://ntfy.sh").rstrip("/")
|
||||||
|
topic = str(ntfy.get("topic") or "").strip()
|
||||||
|
if not topic:
|
||||||
|
return False
|
||||||
|
url = "%s/%s" % (server, urllib.parse.quote(topic, safe=""))
|
||||||
|
headers = {"Title": "Grotti", "Priority": "default"}
|
||||||
|
token = str(ntfy.get("token") or "").strip()
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = "Bearer %s" % token
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=text[:4000].encode("utf-8"),
|
||||||
|
method="POST",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=12) as response:
|
||||||
|
return 200 <= response.status < 300
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _push_alerts(cfg, message):
|
||||||
|
send_telegram(cfg, message)
|
||||||
|
send_ntfy(cfg, message)
|
||||||
|
|
||||||
|
|
||||||
|
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60, run_id=None):
|
||||||
|
"""Ereignis immer speichern; Telegram/ntfy nur einmal pro Art und Drucker im Zeitfenster."""
|
||||||
from grotti import store
|
from grotti import store
|
||||||
|
|
||||||
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s)
|
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s)
|
||||||
store.add_event(conn, host, printer, kind, message, filename or "")
|
store.add_event(conn, host, printer, kind, message, filename or "", run_id=run_id)
|
||||||
if not recent and alert_enabled(cfg, kind):
|
if not recent and alert_enabled(cfg, kind):
|
||||||
send_telegram(cfg, message)
|
threading.Thread(
|
||||||
|
target=_push_alerts,
|
||||||
|
args=(cfg, message),
|
||||||
|
name="grotti-notify",
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
"""Aufträge, Kunden und Positionen."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from psycopg.types.json import Json
|
||||||
|
|
||||||
|
from grotti import store
|
||||||
|
|
||||||
|
ORDER_STATUSES = frozenset({
|
||||||
|
"idee",
|
||||||
|
"angebot",
|
||||||
|
"bestaetigt",
|
||||||
|
"druckt",
|
||||||
|
"fertig",
|
||||||
|
"geliefert",
|
||||||
|
"bezahlt",
|
||||||
|
})
|
||||||
|
|
||||||
|
PROJECT_TO_ORDER = {
|
||||||
|
"idee": "idee",
|
||||||
|
"bereit": "angebot",
|
||||||
|
"druckt": "druckt",
|
||||||
|
"fertig": "fertig",
|
||||||
|
}
|
||||||
|
|
||||||
|
IDEA_TO_ORDER = {
|
||||||
|
"offen": "idee",
|
||||||
|
"spaeter": "idee",
|
||||||
|
"erledigt": "fertig",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_status(value):
|
||||||
|
s = (value or "idee").strip().lower()
|
||||||
|
if s not in ORDER_STATUSES:
|
||||||
|
raise ValueError("Unbekannter Auftragsstatus.")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def customer_out(row):
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"email": row.get("email") or "",
|
||||||
|
"notes": row.get("notes") or "",
|
||||||
|
"created_at": store.iso(row["created_at"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def order_item_out(row, file_name=None):
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"order_id": row["order_id"],
|
||||||
|
"file_id": row.get("file_id"),
|
||||||
|
"file_name": file_name or "",
|
||||||
|
"qty": int(row.get("qty") or 1),
|
||||||
|
"unit_price": float(row.get("unit_price") or 0),
|
||||||
|
"material_type": row.get("material_type") or "",
|
||||||
|
"color": row.get("color") or "",
|
||||||
|
"done_ok": int(row.get("done_ok") or 0),
|
||||||
|
"done_bad": int(row.get("done_bad") or 0),
|
||||||
|
"note": row.get("note") or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def order_out(row, customer=None, items=None, progress=None):
|
||||||
|
data = {
|
||||||
|
"id": row["id"],
|
||||||
|
"customer_id": row.get("customer_id"),
|
||||||
|
"title": row["title"],
|
||||||
|
"status": row["status"],
|
||||||
|
"due_at": row["due_at"].isoformat() if row.get("due_at") else None,
|
||||||
|
"notes": row.get("notes") or "",
|
||||||
|
"quote": row.get("quote") or {},
|
||||||
|
"created_at": store.iso(row["created_at"]),
|
||||||
|
"updated_at": store.iso(row.get("updated_at") or row["created_at"]),
|
||||||
|
}
|
||||||
|
if customer is not None:
|
||||||
|
data["customer"] = customer
|
||||||
|
if items is not None:
|
||||||
|
data["items"] = items
|
||||||
|
if progress is not None:
|
||||||
|
data["progress"] = progress
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def order_progress(conn, order_id):
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(qty), 0) AS target,
|
||||||
|
COALESCE(SUM(LEAST(done_ok, qty)), 0) AS done_ok,
|
||||||
|
COALESCE(SUM(done_bad), 0) AS done_bad
|
||||||
|
FROM order_items
|
||||||
|
WHERE order_id = %s
|
||||||
|
""",
|
||||||
|
(order_id,),
|
||||||
|
).fetchone()
|
||||||
|
target = int(row["target"] or 0)
|
||||||
|
done = int(row["done_ok"] or 0)
|
||||||
|
return {
|
||||||
|
"target": target,
|
||||||
|
"done": done,
|
||||||
|
"done_bad": int(row["done_bad"] or 0),
|
||||||
|
"label": "%d von %d" % (done, target) if target else "0 von 0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_customers(conn):
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM customers ORDER BY name, id"
|
||||||
|
).fetchall()
|
||||||
|
return [customer_out(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_customer(conn, customer_id):
|
||||||
|
row = conn.execute("SELECT * FROM customers WHERE id = %s", (customer_id,)).fetchone()
|
||||||
|
return customer_out(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_customer(conn, name, email="", notes=""):
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO customers (name, email, notes)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(name.strip(), (email or "").strip(), (notes or "").strip()),
|
||||||
|
).fetchone()
|
||||||
|
return customer_out(row)
|
||||||
|
|
||||||
|
|
||||||
|
def update_customer(conn, customer_id, fields):
|
||||||
|
sets = []
|
||||||
|
values = []
|
||||||
|
for key in ("name", "email", "notes"):
|
||||||
|
if key in fields:
|
||||||
|
sets.append("%s = %%s" % key)
|
||||||
|
values.append(fields[key])
|
||||||
|
if not sets:
|
||||||
|
return get_customer(conn, customer_id)
|
||||||
|
values.append(customer_id)
|
||||||
|
row = conn.execute(
|
||||||
|
"UPDATE customers SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
|
||||||
|
values,
|
||||||
|
).fetchone()
|
||||||
|
return customer_out(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_customer(conn, customer_id):
|
||||||
|
row = conn.execute(
|
||||||
|
"DELETE FROM customers WHERE id = %s RETURNING id",
|
||||||
|
(customer_id,),
|
||||||
|
).fetchone()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def list_orders(conn, status=None):
|
||||||
|
where = ""
|
||||||
|
params = []
|
||||||
|
if status:
|
||||||
|
where = "WHERE o.status = %s"
|
||||||
|
params.append(status)
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT o.*, c.name AS customer_name
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN customers c ON c.id = o.customer_id
|
||||||
|
%s
|
||||||
|
ORDER BY o.updated_at DESC, o.id DESC
|
||||||
|
""" % where,
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
prog = order_progress(conn, row["id"])
|
||||||
|
customer = None
|
||||||
|
if row.get("customer_id"):
|
||||||
|
customer = {"id": row["customer_id"], "name": row.get("customer_name") or ""}
|
||||||
|
out.append(order_out(row, customer=customer, progress=prog))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_order(conn, order_id):
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT o.*, c.name AS customer_name, c.email AS customer_email
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN customers c ON c.id = o.customer_id
|
||||||
|
WHERE o.id = %s
|
||||||
|
""",
|
||||||
|
(order_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
items_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT oi.*, f.name AS file_name
|
||||||
|
FROM order_items oi
|
||||||
|
LEFT JOIN files f ON f.id = oi.file_id
|
||||||
|
WHERE oi.order_id = %s
|
||||||
|
ORDER BY oi.id
|
||||||
|
""",
|
||||||
|
(order_id,),
|
||||||
|
).fetchall()
|
||||||
|
items = [order_item_out(r, r.get("file_name")) for r in items_rows]
|
||||||
|
customer = None
|
||||||
|
if row.get("customer_id"):
|
||||||
|
customer = {
|
||||||
|
"id": row["customer_id"],
|
||||||
|
"name": row.get("customer_name") or "",
|
||||||
|
"email": row.get("customer_email") or "",
|
||||||
|
}
|
||||||
|
return order_out(
|
||||||
|
row,
|
||||||
|
customer=customer,
|
||||||
|
items=items,
|
||||||
|
progress=order_progress(conn, order_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_order(conn, title, status="idee", customer_id=None, due_at=None, notes="", quote=None):
|
||||||
|
status = normalize_status(status)
|
||||||
|
due = None
|
||||||
|
if due_at:
|
||||||
|
if isinstance(due_at, date):
|
||||||
|
due = due_at
|
||||||
|
else:
|
||||||
|
due = date.fromisoformat(str(due_at)[:10])
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO orders (customer_id, title, status, due_at, notes, quote)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
customer_id,
|
||||||
|
title.strip(),
|
||||||
|
status,
|
||||||
|
due,
|
||||||
|
(notes or "").strip(),
|
||||||
|
Json(quote if isinstance(quote, dict) else {}),
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return get_order(conn, row["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def update_order(conn, order_id, fields):
|
||||||
|
sets = ["updated_at = now()"]
|
||||||
|
values = []
|
||||||
|
if "title" in fields:
|
||||||
|
sets.append("title = %s")
|
||||||
|
values.append(fields["title"])
|
||||||
|
if "status" in fields:
|
||||||
|
sets.append("status = %s")
|
||||||
|
values.append(normalize_status(fields["status"]))
|
||||||
|
if "customer_id" in fields:
|
||||||
|
sets.append("customer_id = %s")
|
||||||
|
values.append(fields["customer_id"])
|
||||||
|
if "due_at" in fields:
|
||||||
|
sets.append("due_at = %s")
|
||||||
|
due = fields["due_at"]
|
||||||
|
if due in ("", None):
|
||||||
|
values.append(None)
|
||||||
|
elif isinstance(due, date):
|
||||||
|
values.append(due)
|
||||||
|
else:
|
||||||
|
values.append(date.fromisoformat(str(due)[:10]))
|
||||||
|
if "notes" in fields:
|
||||||
|
sets.append("notes = %s")
|
||||||
|
values.append(fields["notes"] or "")
|
||||||
|
if "quote" in fields:
|
||||||
|
sets.append("quote = %s")
|
||||||
|
values.append(Json(fields["quote"] if isinstance(fields["quote"], dict) else {}))
|
||||||
|
if len(sets) <= 1:
|
||||||
|
return get_order(conn, order_id)
|
||||||
|
values.append(order_id)
|
||||||
|
row = conn.execute(
|
||||||
|
"UPDATE orders SET %s WHERE id = %%s RETURNING id" % ", ".join(sets),
|
||||||
|
values,
|
||||||
|
).fetchone()
|
||||||
|
return get_order(conn, row["id"]) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_order(conn, order_id):
|
||||||
|
row = conn.execute("DELETE FROM orders WHERE id = %s RETURNING id", (order_id,)).fetchone()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def create_order_item(conn, order_id, file_id=None, qty=1, unit_price=0, material_type="", color="", note=""):
|
||||||
|
if not conn.execute("SELECT 1 FROM orders WHERE id = %s", (order_id,)).fetchone():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
qty_n = max(1, int(qty))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
qty_n = 1
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO order_items (
|
||||||
|
order_id, file_id, qty, unit_price, material_type, color, note
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
order_id,
|
||||||
|
file_id,
|
||||||
|
qty_n,
|
||||||
|
float(unit_price or 0),
|
||||||
|
(material_type or "").strip(),
|
||||||
|
(color or "").strip(),
|
||||||
|
(note or "").strip(),
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (order_id,))
|
||||||
|
file_name = ""
|
||||||
|
if file_id:
|
||||||
|
f = store.get_file_row(conn, file_id)
|
||||||
|
file_name = (f or {}).get("name") or ""
|
||||||
|
return order_item_out(row, file_name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_order_item(conn, item_id):
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT oi.*, f.name AS file_name
|
||||||
|
FROM order_items oi
|
||||||
|
LEFT JOIN files f ON f.id = oi.file_id
|
||||||
|
WHERE oi.id = %s
|
||||||
|
""",
|
||||||
|
(item_id,),
|
||||||
|
).fetchone()
|
||||||
|
return order_item_out(row, row.get("file_name")) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_order_item(conn, item_id, fields):
|
||||||
|
sets = []
|
||||||
|
values = []
|
||||||
|
for key in ("file_id", "material_type", "color", "note"):
|
||||||
|
if key in fields:
|
||||||
|
sets.append("%s = %%s" % key)
|
||||||
|
values.append(fields[key])
|
||||||
|
if "qty" in fields:
|
||||||
|
try:
|
||||||
|
qty_n = max(1, int(fields["qty"]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
qty_n = 1
|
||||||
|
sets.append("qty = %s")
|
||||||
|
values.append(qty_n)
|
||||||
|
if "unit_price" in fields:
|
||||||
|
sets.append("unit_price = %s")
|
||||||
|
values.append(float(fields["unit_price"] or 0))
|
||||||
|
if not sets:
|
||||||
|
return get_order_item(conn, item_id)
|
||||||
|
values.append(item_id)
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE order_items SET %s
|
||||||
|
WHERE id = %%s
|
||||||
|
RETURNING order_id
|
||||||
|
""" % ", ".join(sets),
|
||||||
|
values,
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (row["order_id"],))
|
||||||
|
return get_order_item(conn, item_id)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_order_item(conn, item_id):
|
||||||
|
row = conn.execute(
|
||||||
|
"DELETE FROM order_items WHERE id = %s RETURNING order_id",
|
||||||
|
(item_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (row["order_id"],))
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_verdict_delta(conn, order_item_id, old_ok, old_bad, new_ok, new_bad):
|
||||||
|
"""Erhöht done_ok/done_bad an der Position — nur Delta zum vorherigen Lauf-Stand."""
|
||||||
|
if not order_item_id:
|
||||||
|
return
|
||||||
|
d_ok = int(new_ok or 0) - int(old_ok or 0)
|
||||||
|
d_bad = int(new_bad or 0) - int(old_bad or 0)
|
||||||
|
if d_ok == 0 and d_bad == 0:
|
||||||
|
return
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE order_items
|
||||||
|
SET done_ok = done_ok + %s, done_bad = done_bad + %s
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(d_ok, d_bad, order_item_id),
|
||||||
|
)
|
||||||
|
item = conn.execute(
|
||||||
|
"SELECT order_id FROM order_items WHERE id = %s",
|
||||||
|
(order_item_id,),
|
||||||
|
).fetchone()
|
||||||
|
if item:
|
||||||
|
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (item["order_id"],))
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_order_item(
|
||||||
|
conn,
|
||||||
|
order_id,
|
||||||
|
item_id,
|
||||||
|
host="",
|
||||||
|
family="",
|
||||||
|
priority=100,
|
||||||
|
spool_id=None,
|
||||||
|
ace_map=None,
|
||||||
|
bed_leveling=False,
|
||||||
|
override=False,
|
||||||
|
):
|
||||||
|
item = conn.execute(
|
||||||
|
"SELECT * FROM order_items WHERE id = %s AND order_id = %s",
|
||||||
|
(item_id, order_id),
|
||||||
|
).fetchone()
|
||||||
|
if not item:
|
||||||
|
raise ValueError("Position fehlt.")
|
||||||
|
file_id = item.get("file_id")
|
||||||
|
if not file_id:
|
||||||
|
raise ValueError("Position ohne Datei.")
|
||||||
|
if not store.get_file_row(conn, file_id):
|
||||||
|
raise ValueError("Datei fehlt.")
|
||||||
|
row = store.enqueue_print(
|
||||||
|
conn,
|
||||||
|
file_id,
|
||||||
|
host or "",
|
||||||
|
family or "",
|
||||||
|
priority,
|
||||||
|
spool_id,
|
||||||
|
ace_map if isinstance(ace_map, list) else [],
|
||||||
|
bool(bed_leveling),
|
||||||
|
bool(override),
|
||||||
|
order_item_id=item["id"],
|
||||||
|
)
|
||||||
|
conn.execute("UPDATE orders SET updated_at = now(), status = CASE WHEN status IN ('idee', 'angebot', 'bestaetigt') THEN 'druckt' ELSE status END WHERE id = %s", (order_id,))
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def attach_order_item_to_live_run(conn, host, filename, order_item_id):
|
||||||
|
"""Verknüpft gestarteten Lauf mit Auftragsposition."""
|
||||||
|
if not order_item_id or not host:
|
||||||
|
return
|
||||||
|
base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE runs
|
||||||
|
SET order_item_id = COALESCE(order_item_id, %s)
|
||||||
|
WHERE printer_host = %s
|
||||||
|
AND (filename = %s OR filename LIKE %s)
|
||||||
|
AND state IN ('printing', 'paused', 'in_progress', 'completed')
|
||||||
|
AND ended_at IS NULL
|
||||||
|
""",
|
||||||
|
(order_item_id, host, base, "%/" + base),
|
||||||
|
)
|
||||||
@@ -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"):
|
||||||
|
|||||||
@@ -10,9 +10,19 @@ 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.db import connect
|
from grotti.db import connect
|
||||||
from grotti.errors import GrottiError
|
from grotti.errors import GrottiError
|
||||||
|
from grotti import time_window
|
||||||
|
|
||||||
|
WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…"
|
||||||
|
from grotti.events_bus import publish as publish_live
|
||||||
|
|
||||||
|
log = logutil.get("grotti.queue")
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_queue():
|
||||||
|
publish_live("queue", {})
|
||||||
|
|
||||||
FREE = {"standby", "complete", "cancelled"}
|
FREE = {"standby", "complete", "cancelled"}
|
||||||
BUSY = {"printing", "paused", "heating", "error"}
|
BUSY = {"printing", "paused", "heating", "error"}
|
||||||
@@ -20,6 +30,8 @@ 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)
|
||||||
|
SIMPLE_START_STUCK_S = 45
|
||||||
|
|
||||||
|
|
||||||
def _ablauf_needs_motors(item):
|
def _ablauf_needs_motors(item):
|
||||||
@@ -101,7 +113,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,6 +132,73 @@ class PrintQueueDispatcher:
|
|||||||
thread = self._workers.get(item_id)
|
thread = self._workers.get(item_id)
|
||||||
return bool(thread and thread.is_alive())
|
return bool(thread and thread.is_alive())
|
||||||
|
|
||||||
|
def _item_phase_age_s(self, item):
|
||||||
|
"""Alter von phase_started_at, sonst updated_at (für hängende Starts nach Neustart)."""
|
||||||
|
started = item.get("phase_started_at")
|
||||||
|
if started:
|
||||||
|
if getattr(started, "tzinfo", None) is None:
|
||||||
|
started = started.replace(tzinfo=timezone.utc)
|
||||||
|
return (datetime.now(timezone.utc) - started).total_seconds()
|
||||||
|
updated = item.get("updated_at")
|
||||||
|
if updated:
|
||||||
|
if getattr(updated, "tzinfo", None) is None:
|
||||||
|
updated = updated.replace(tzinfo=timezone.utc)
|
||||||
|
return (datetime.now(timezone.utc) - updated).total_seconds()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _auto_retry_enabled(self):
|
||||||
|
return bool((self.cfg or {}).get("auto_retry", True))
|
||||||
|
|
||||||
|
def _try_auto_retry(self, conn, item, message):
|
||||||
|
"""Ein automatischer Neuversuch; sonst False."""
|
||||||
|
if not self._auto_retry_enabled():
|
||||||
|
return False
|
||||||
|
retries = int(item.get("retry_count") or 0)
|
||||||
|
if retries >= 1:
|
||||||
|
return False
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "waiting",
|
||||||
|
"message": "Nochmal (%s)" % (message or "Fehler"),
|
||||||
|
"retry_count": retries + 1,
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
"host": item.get("host") or "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _fail_stuck_simple_start(self, conn, item, printer):
|
||||||
|
host = printer["host"]
|
||||||
|
msg = "Start abgebrochen (Neustart)"
|
||||||
|
if self._try_auto_retry(conn, item, msg):
|
||||||
|
self._event(
|
||||||
|
conn, host, printer["name"], "queue",
|
||||||
|
"%s: Nochmal — %s" % (printer["name"], msg),
|
||||||
|
item.get("filename") or "",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": msg,
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
conn,
|
||||||
|
host,
|
||||||
|
printer["name"],
|
||||||
|
"queue",
|
||||||
|
"%s: Start abgebrochen (Neustart)" % printer["name"],
|
||||||
|
item.get("filename") or "",
|
||||||
|
)
|
||||||
|
|
||||||
def _spawn_worker(self, item_id, target, *args):
|
def _spawn_worker(self, item_id, target, *args):
|
||||||
with self._worker_lock:
|
with self._worker_lock:
|
||||||
existing = self._workers.get(item_id)
|
existing = self._workers.get(item_id)
|
||||||
@@ -129,7 +208,10 @@ class PrintQueueDispatcher:
|
|||||||
def run():
|
def run():
|
||||||
try:
|
try:
|
||||||
target(*args)
|
target(*args)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Queue-Worker %s: %s", item_id, exc)
|
||||||
finally:
|
finally:
|
||||||
|
_notify_queue()
|
||||||
with self._worker_lock:
|
with self._worker_lock:
|
||||||
self._workers.pop(item_id, None)
|
self._workers.pop(item_id, None)
|
||||||
|
|
||||||
@@ -143,6 +225,12 @@ class PrintQueueDispatcher:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def tick(self):
|
def tick(self):
|
||||||
|
try:
|
||||||
|
self._tick_once()
|
||||||
|
finally:
|
||||||
|
_notify_queue()
|
||||||
|
|
||||||
|
def _tick_once(self):
|
||||||
with connect(self.database_url) as conn:
|
with connect(self.database_url) as conn:
|
||||||
printers = store.list_printers(conn)
|
printers = store.list_printers(conn)
|
||||||
if not printers:
|
if not printers:
|
||||||
@@ -190,13 +278,43 @@ class PrintQueueDispatcher:
|
|||||||
continue
|
continue
|
||||||
if state not in FREE:
|
if state not in FREE:
|
||||||
continue
|
continue
|
||||||
if store.running_print_on_host(conn, host):
|
running = store.running_print_on_host(conn, host)
|
||||||
|
if running:
|
||||||
|
if not _has_ablauf(running):
|
||||||
|
if (
|
||||||
|
(running.get("phase") or "").lower() == "starting"
|
||||||
|
and not self._worker_busy(running["id"])
|
||||||
|
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
|
||||||
|
):
|
||||||
|
self._fail_stuck_simple_start(conn, running, printer)
|
||||||
|
else:
|
||||||
|
self._advance_simple(conn, running, printer, live)
|
||||||
|
conn.commit()
|
||||||
continue
|
continue
|
||||||
if store.bed_needs_clear(conn, host):
|
if store.bed_needs_clear(conn, host):
|
||||||
continue
|
continue
|
||||||
waiting = store.next_waiting_print(conn, host=host, family=printer.get("family") or "")
|
waiting = store.next_waiting_print(conn, host=host, family=printer.get("family") or "")
|
||||||
if not waiting:
|
if not waiting:
|
||||||
continue
|
continue
|
||||||
|
sw = printer.get("start_window") or {}
|
||||||
|
if sw and not time_window.allowed_now(sw):
|
||||||
|
if (waiting.get("message") or "") != WINDOW_WAIT_MSG:
|
||||||
|
store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG})
|
||||||
|
continue
|
||||||
|
est_s = 0
|
||||||
|
if waiting.get("file_id"):
|
||||||
|
file_row = store.get_file_row(conn, waiting["file_id"])
|
||||||
|
if file_row:
|
||||||
|
est_s = float(file_row.get("estimated_s") or 0)
|
||||||
|
if sw and not time_window.job_fits_overnight(sw, est_s):
|
||||||
|
if (waiting.get("message") or "") != WINDOW_WAIT_MSG:
|
||||||
|
store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG})
|
||||||
|
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
|
||||||
@@ -208,6 +326,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"]
|
||||||
@@ -255,15 +416,22 @@ class PrintQueueDispatcher:
|
|||||||
bed_leveling=bed_leveling,
|
bed_leveling=bed_leveling,
|
||||||
)
|
)
|
||||||
name = (result or {}).get("filename") or ""
|
name = (result or {}).get("filename") or ""
|
||||||
|
oid = item.get("order_item_id")
|
||||||
|
if oid:
|
||||||
|
from grotti import orders
|
||||||
|
|
||||||
|
orders.attach_order_item_to_live_run(wconn, host, name, oid)
|
||||||
|
# Nicht sofort „done“: sonst greift der nächste Wartende, während
|
||||||
|
# der Drucker noch druckt (Live-Cache kann kurz FREE bleiben).
|
||||||
store.update_print_queue(
|
store.update_print_queue(
|
||||||
wconn,
|
wconn,
|
||||||
item_id,
|
item_id,
|
||||||
{
|
{
|
||||||
"status": "done",
|
"status": "running",
|
||||||
"message": "Gestartet auf %s: %s" % (printer_copy["name"], name),
|
"message": "Druckt auf %s: %s" % (printer_copy["name"], name),
|
||||||
"host": host,
|
"host": host,
|
||||||
"phase": "",
|
"phase": "print_active",
|
||||||
"phase_started_at": None,
|
"phase_started_at": "now",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self._event(
|
self._event(
|
||||||
@@ -276,6 +444,7 @@ class PrintQueueDispatcher:
|
|||||||
)
|
)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except GrottiError as exc:
|
except GrottiError as exc:
|
||||||
|
wconn.rollback()
|
||||||
store.update_print_queue(
|
store.update_print_queue(
|
||||||
wconn,
|
wconn,
|
||||||
item_id,
|
item_id,
|
||||||
@@ -291,6 +460,7 @@ class PrintQueueDispatcher:
|
|||||||
)
|
)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
wconn.rollback()
|
||||||
store.update_print_queue(
|
store.update_print_queue(
|
||||||
wconn,
|
wconn,
|
||||||
item_id,
|
item_id,
|
||||||
@@ -313,6 +483,58 @@ 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 == "starting":
|
||||||
|
if state in ("printing", "paused", "heating") or live.get("print_active"):
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"phase": "print_active",
|
||||||
|
"phase_started_at": "now",
|
||||||
|
"message": "Druckt…",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if phase != "print_active":
|
||||||
|
return
|
||||||
|
if state in ("printing", "paused", "heating"):
|
||||||
|
return
|
||||||
|
if state == "error":
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": (live.get("message") or "Druckerfehler").strip()[:200],
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if state in FREE or state in ("complete", "cancelled"):
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "done",
|
||||||
|
"message": "Fertig.",
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
conn,
|
||||||
|
printer["host"],
|
||||||
|
printer["name"],
|
||||||
|
"fertig",
|
||||||
|
"%s fertig: %s" % (printer["name"], item.get("filename") or "Druck"),
|
||||||
|
item.get("filename") or "",
|
||||||
|
)
|
||||||
|
|
||||||
def _begin_ablauf(self, conn, item, printer, live):
|
def _begin_ablauf(self, conn, item, printer, live):
|
||||||
try:
|
try:
|
||||||
eject.require_kobra_3(printer)
|
eject.require_kobra_3(printer)
|
||||||
@@ -684,10 +906,12 @@ class PrintQueueDispatcher:
|
|||||||
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:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, exc.message)
|
self._fail_ablauf(wconn, row, printer_copy, exc.message)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, str(exc) or "Druckstart fehlgeschlagen.")
|
self._fail_ablauf(wconn, row, printer_copy, str(exc) or "Druckstart fehlgeschlagen.")
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
@@ -742,42 +966,64 @@ class PrintQueueDispatcher:
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
def _start_cool_step(self, conn, item, printer, step):
|
def _start_cool_step(self, conn, item, printer, step):
|
||||||
|
# Park (bis 60 s G-code) läuft im Worker, damit tick() nicht blockiert.
|
||||||
host = printer["host"]
|
host = printer["host"]
|
||||||
cool = eject.normalize_cool(step)
|
cool = eject.normalize_cool(step)
|
||||||
try:
|
item_id = item["id"]
|
||||||
moonraker.gcode_script(host, eject.cool_script(cool), timeout=30)
|
printer_copy = dict(printer)
|
||||||
# Idle zuerst verlängern, dann Park (sonst Stepper aus → must home).
|
item_copy = dict(item)
|
||||||
try:
|
|
||||||
moonraker.gcode_script(host, eject.idle_timeout_script(cool), timeout=15)
|
def work():
|
||||||
except Exception:
|
with connect(self.database_url) as wconn:
|
||||||
pass
|
try:
|
||||||
park = self._try_cool_park(host, item["id"], force=True)
|
moonraker.gcode_script(host, eject.cool_script(cool), timeout=30)
|
||||||
self._keepalive[item["id"]] = time.monotonic()
|
try:
|
||||||
park_note = ""
|
moonraker.gcode_script(host, eject.idle_timeout_script(cool), timeout=15)
|
||||||
if not park["ok"] and park["error"]:
|
except Exception:
|
||||||
park_note = " — Mitte fehlgeschlagen: %s" % park["error"]
|
pass
|
||||||
elif park["ok"]:
|
park = self._try_cool_park(host, item_id, force=True)
|
||||||
park_note = " · Düse Mitte"
|
self._keepalive[item_id] = time.monotonic()
|
||||||
store.update_print_queue(
|
park_note = ""
|
||||||
conn,
|
if not park["ok"] and park["error"]:
|
||||||
item["id"],
|
park_note = " — Mitte fehlgeschlagen: %s" % park["error"]
|
||||||
{
|
elif park["ok"]:
|
||||||
"phase": "cooling",
|
park_note = " · Düse Mitte"
|
||||||
"phase_started_at": "now",
|
row = store.get_print_queue_row(wconn, item_id) or item_copy
|
||||||
"message": self._ablauf_message(
|
store.update_print_queue(
|
||||||
item,
|
wconn,
|
||||||
"kühlt ab (Bett ≤ %.0f °C, Düse ≤ %.0f °C)%s"
|
item_id,
|
||||||
% (cool["bed_max"], cool["nozzle_max"], park_note),
|
{
|
||||||
),
|
"message": self._ablauf_message(row, "kühlt%s" % park_note),
|
||||||
},
|
"phase": "cooling",
|
||||||
)
|
"phase_started_at": datetime.now(timezone.utc),
|
||||||
self._step_event(
|
},
|
||||||
conn, item, printer,
|
)
|
||||||
"kühlt ab bis Bett ≤ %.0f °C, Düse ≤ %.0f °C%s"
|
wconn.commit()
|
||||||
% (cool["bed_max"], cool["nozzle_max"], park_note),
|
except GrottiError as exc:
|
||||||
)
|
self._fail_ablauf(wconn, item_copy, printer_copy, "Abkühlen: %s" % exc.message)
|
||||||
except Exception as exc:
|
wconn.commit()
|
||||||
self._fail_ablauf(conn, item, printer, "Abkühlen: %s" % exc)
|
except Exception as exc:
|
||||||
|
self._fail_ablauf(wconn, item_copy, printer_copy, "Abkühlen: %s" % exc)
|
||||||
|
wconn.commit()
|
||||||
|
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item_id,
|
||||||
|
{
|
||||||
|
"message": self._ablauf_message(
|
||||||
|
item,
|
||||||
|
"kühlt ab (Bett ≤ %.0f °C, Düse ≤ %.0f °C)"
|
||||||
|
% (cool["bed_max"], cool["nozzle_max"]),
|
||||||
|
),
|
||||||
|
"phase": "cooling",
|
||||||
|
"phase_started_at": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._step_event(
|
||||||
|
conn, item, printer,
|
||||||
|
"kühlt ab bis Bett ≤ %.0f °C, Düse ≤ %.0f °C" % (cool["bed_max"], cool["nozzle_max"]),
|
||||||
|
)
|
||||||
|
self._spawn_worker(item_id, work)
|
||||||
|
|
||||||
def _poll_cool(self, conn, item, printer, live, step):
|
def _poll_cool(self, conn, item, printer, live, step):
|
||||||
cool = eject.normalize_cool(step)
|
cool = eject.normalize_cool(step)
|
||||||
@@ -908,14 +1154,17 @@ class PrintQueueDispatcher:
|
|||||||
self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True})
|
self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True})
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except GrottiError as exc:
|
except GrottiError as exc:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, exc.message)
|
self._fail_ablauf(wconn, row, printer_copy, exc.message)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except moonraker.PrinterError as exc:
|
except moonraker.PrinterError as exc:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message))
|
self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message))
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, "Schieben: %s" % exc)
|
self._fail_ablauf(wconn, row, printer_copy, "Schieben: %s" % exc)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
@@ -1013,14 +1262,17 @@ class PrintQueueDispatcher:
|
|||||||
self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True})
|
self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True})
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except GrottiError as exc:
|
except GrottiError as exc:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, exc.message)
|
self._fail_ablauf(wconn, row, printer_copy, exc.message)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except moonraker.PrinterError as exc:
|
except moonraker.PrinterError as exc:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message))
|
self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message))
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
wconn.rollback()
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
self._fail_ablauf(wconn, row, printer_copy, "Tasten: %s" % exc)
|
self._fail_ablauf(wconn, row, printer_copy, "Tasten: %s" % exc)
|
||||||
wconn.commit()
|
wconn.commit()
|
||||||
@@ -1121,6 +1373,16 @@ class PrintQueueDispatcher:
|
|||||||
self._fail_ablauf(conn, item, printer, "Unbekannter Schritt: %s" % kind)
|
self._fail_ablauf(conn, item, printer, "Unbekannter Schritt: %s" % kind)
|
||||||
|
|
||||||
def _fail_ablauf(self, conn, item, printer, message):
|
def _fail_ablauf(self, conn, item, printer, message):
|
||||||
|
if self._try_auto_retry(conn, item, message):
|
||||||
|
self._event(
|
||||||
|
conn,
|
||||||
|
printer["host"],
|
||||||
|
printer["name"],
|
||||||
|
"queue",
|
||||||
|
"%s Nochmal — %s" % (printer["name"], message),
|
||||||
|
item.get("filename") or "",
|
||||||
|
)
|
||||||
|
return
|
||||||
store.update_print_queue(
|
store.update_print_queue(
|
||||||
conn,
|
conn,
|
||||||
item["id"],
|
item["id"],
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""Die Drucker: Familie aus dem Namen, die Liste selbst liegt in PostgreSQL."""
|
"""Die Drucker: Familie aus dem Namen, die Liste selbst liegt in PostgreSQL."""
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
FAMILY_LABELS = {"kobra_s1": "Kobra S1", "kobra_3": "Kobra 3"}
|
FAMILY_LABELS = {"kobra_s1": "Kobra S1", "kobra_3": "Kobra 3"}
|
||||||
|
|
||||||
|
|
||||||
@@ -21,24 +19,3 @@ def family_from_text(text):
|
|||||||
if "kobra 3" in low or "kobra3" in low:
|
if "kobra 3" in low or "kobra3" in low:
|
||||||
return "kobra_3"
|
return "kobra_3"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def load_printers(path):
|
|
||||||
rows = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
printers = []
|
|
||||||
for row in rows:
|
|
||||||
family = row.get("family") or family_from_name(row["name"])
|
|
||||||
printers.append({
|
|
||||||
"name": row["name"],
|
|
||||||
"host": row["host"],
|
|
||||||
"family": family,
|
|
||||||
"family_label": FAMILY_LABELS.get(family, family),
|
|
||||||
})
|
|
||||||
return printers
|
|
||||||
|
|
||||||
|
|
||||||
def printer_by_host(path, host):
|
|
||||||
for printer in load_printers(path):
|
|
||||||
if printer["host"] == host:
|
|
||||||
return printer
|
|
||||||
return None
|
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
|
-- Referenz-Schema. Laufende Migrationen liegen in grotti/migrations/*.sql
|
||||||
|
-- (001_base.sql entspricht diesem Stand, danach nummerierte Änderungen).
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
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,
|
||||||
@@ -120,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 <> '';
|
||||||
@@ -169,6 +176,8 @@ ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_total integer NOT NULL D
|
|||||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_done integer NOT NULL DEFAULT 0;
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_done integer NOT NULL DEFAULT 0;
|
||||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase text NOT NULL DEFAULT '';
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase text NOT NULL DEFAULT '';
|
||||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase_started_at timestamptz;
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase_started_at timestamptz;
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS retry_count integer NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS wait_reason text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS print_queue_waiting ON print_queue (status, priority, id);
|
CREATE INDEX IF NOT EXISTS print_queue_waiting ON print_queue (status, priority, id);
|
||||||
CREATE INDEX IF NOT EXISTS print_queue_running_host ON print_queue (host, status) WHERE status = 'running';
|
CREATE INDEX IF NOT EXISTS print_queue_running_host ON print_queue (host, status) WHERE status = 'running';
|
||||||
@@ -180,3 +189,190 @@ CREATE TABLE IF NOT EXISTS bed_status (
|
|||||||
cleared_at timestamptz,
|
cleared_at timestamptz,
|
||||||
message text NOT NULL DEFAULT ''
|
message text NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
username text NOT NULL UNIQUE,
|
||||||
|
password_hash text NOT NULL,
|
||||||
|
role text NOT NULL DEFAULT 'operator',
|
||||||
|
active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_login timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL DEFAULT '',
|
||||||
|
token_hash text NOT NULL UNIQUE,
|
||||||
|
token_prefix text NOT NULL DEFAULT '',
|
||||||
|
role text NOT NULL DEFAULT 'operator',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_used timestamptz,
|
||||||
|
revoked_at timestamptz,
|
||||||
|
expires_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS api_tokens_user ON api_tokens (user_id) WHERE revoked_at IS NULL;
|
||||||
|
|
||||||
|
-- OIDC später: Identitäten an lokale User binden.
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_identities (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
provider text NOT NULL,
|
||||||
|
subject text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (provider, subject)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Druckbericht: Soll/Ist, Charge, Live-Fortschritt
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_planned_g double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_used_g double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_source text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_used_mm double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_total_mm double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS progress_last double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS estimated_s double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS chargen_nr text;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS message text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS objects jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS runs_chargen_nr
|
||||||
|
ON runs (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
|
||||||
|
|
||||||
|
ALTER TABLE events ADD COLUMN IF NOT EXISTS run_id bigint REFERENCES runs (id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS events_run ON events (run_id) WHERE run_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Abzug je Spule (Messwert oder Schätzwert)
|
||||||
|
CREATE TABLE IF NOT EXISTS run_spools (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
|
||||||
|
ace_slot smallint,
|
||||||
|
weight_g double precision NOT NULL DEFAULT 0,
|
||||||
|
weight_planned_g double precision NOT NULL DEFAULT 0,
|
||||||
|
cost double precision NOT NULL DEFAULT 0,
|
||||||
|
is_estimate boolean NOT NULL DEFAULT false,
|
||||||
|
UNIQUE (run_id, spool_id, ace_slot)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS run_spools_spool ON run_spools (spool_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS run_spools_run ON run_spools (run_id);
|
||||||
|
|
||||||
|
-- Abschnitte bei Rollenwechsel mitten im Druck
|
||||||
|
CREATE TABLE IF NOT EXISTS run_segments (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
|
||||||
|
ace_slot smallint,
|
||||||
|
mm_start double precision NOT NULL DEFAULT 0,
|
||||||
|
mm_end double precision,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
closed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS run_segments_run ON run_segments (run_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS run_segments_open
|
||||||
|
ON run_segments (run_id) WHERE mm_end IS NULL;
|
||||||
|
|
||||||
|
-- Temperatur / Fortschritt während des Drucks
|
||||||
|
CREATE TABLE IF NOT EXISTS run_samples (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
sampled_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
nozzle double precision,
|
||||||
|
nozzle_target double precision,
|
||||||
|
bed double precision,
|
||||||
|
bed_target double precision,
|
||||||
|
chamber double precision,
|
||||||
|
fan double precision,
|
||||||
|
speed_factor double precision,
|
||||||
|
extrude_factor double precision,
|
||||||
|
layer integer,
|
||||||
|
progress double precision,
|
||||||
|
filament_used_mm double precision,
|
||||||
|
mmu_tool smallint,
|
||||||
|
nozzle_power double precision,
|
||||||
|
bed_power double precision
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at);
|
||||||
|
|
||||||
|
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS nozzle_power double precision;
|
||||||
|
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS bed_power double precision;
|
||||||
|
|
||||||
|
-- Objekt-Chargen für Etiketten
|
||||||
|
CREATE TABLE IF NOT EXISTS run_labels (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
object_name text NOT NULL DEFAULT '',
|
||||||
|
chargen_nr text NOT NULL,
|
||||||
|
excluded boolean NOT NULL DEFAULT false,
|
||||||
|
UNIQUE (run_id, object_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS run_labels_chargen ON run_labels (chargen_nr);
|
||||||
|
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS verdict_reason text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_ok int NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_bad int NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS maintenance_parts (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
printer_host text NOT NULL,
|
||||||
|
part_key text NOT NULL,
|
||||||
|
label text NOT NULL DEFAULT '',
|
||||||
|
installed_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
life_hours double precision NOT NULL DEFAULT 0,
|
||||||
|
life_meters double precision NOT NULL DEFAULT 0,
|
||||||
|
note text NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS maintenance_log (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
printer_host text NOT NULL,
|
||||||
|
part_key text NOT NULL,
|
||||||
|
event text NOT NULL DEFAULT '',
|
||||||
|
hours_at double precision NOT NULL DEFAULT 0,
|
||||||
|
meters_at double precision NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
note text NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE printers ADD COLUMN IF NOT EXISTS start_window jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
email text NOT NULL DEFAULT '',
|
||||||
|
notes text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
customer_id bigint REFERENCES customers (id) ON DELETE SET NULL,
|
||||||
|
title text NOT NULL,
|
||||||
|
status text NOT NULL DEFAULT 'idee',
|
||||||
|
due_at date,
|
||||||
|
notes text NOT NULL DEFAULT '',
|
||||||
|
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS order_items (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
order_id bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
|
||||||
|
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
|
||||||
|
qty int NOT NULL DEFAULT 1,
|
||||||
|
unit_price double precision NOT NULL DEFAULT 0,
|
||||||
|
material_type text NOT NULL DEFAULT '',
|
||||||
|
color text NOT NULL DEFAULT '',
|
||||||
|
done_ok int NOT NULL DEFAULT 0,
|
||||||
|
done_bad int NOT NULL DEFAULT 0,
|
||||||
|
note text NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
|
||||||
@@ -11,18 +11,26 @@ from queue import Empty, Queue
|
|||||||
from grotti import jobs
|
from grotti import jobs
|
||||||
from grotti.db import connect
|
from grotti.db import connect
|
||||||
from grotti.errors import GrottiError
|
from grotti.errors import GrottiError
|
||||||
|
from grotti.events_bus import publish as publish_live
|
||||||
|
|
||||||
|
|
||||||
class SendQueue:
|
class SendQueue:
|
||||||
def __init__(self, database_url, cfg, workers=4):
|
def __init__(self, database_url, cfg, workers=4):
|
||||||
self.database_url = database_url
|
self.database_url = database_url
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
|
self._workers_n = max(1, int(workers))
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._pending = Queue()
|
self._pending = Queue()
|
||||||
self._jobs = {}
|
self._jobs = {}
|
||||||
self._order = deque(maxlen=60)
|
self._order = deque(maxlen=60)
|
||||||
self._threads = []
|
self._threads = []
|
||||||
for index in range(max(1, int(workers))):
|
self._stop = threading.Event()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._threads:
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
for index in range(self._workers_n):
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=self._worker,
|
target=self._worker,
|
||||||
name="grotti-send-queue-%s" % (index + 1),
|
name="grotti-send-queue-%s" % (index + 1),
|
||||||
@@ -31,6 +39,14 @@ class SendQueue:
|
|||||||
thread.start()
|
thread.start()
|
||||||
self._threads.append(thread)
|
self._threads.append(thread)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop.set()
|
||||||
|
for _ in self._threads:
|
||||||
|
try:
|
||||||
|
self._pending.put_nowait(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def enqueue(
|
def enqueue(
|
||||||
self,
|
self,
|
||||||
conn,
|
conn,
|
||||||
@@ -82,9 +98,14 @@ class SendQueue:
|
|||||||
want_level = bool(bed_leveling)
|
want_level = bool(bed_leveling)
|
||||||
created = []
|
created = []
|
||||||
for host in unique:
|
for host in unique:
|
||||||
printer, row, chosen = jobs.prepare_send(conn, file_id, host, True, override, spool_id)
|
# Mapping zuerst, damit Materialprüfung die ACE-Spulen sieht.
|
||||||
|
mapping = ace_maps.get(host) or []
|
||||||
|
printer, row, chosen = jobs.prepare_send(
|
||||||
|
conn, file_id, host, True, override, spool_id, ace_map=mapping
|
||||||
|
)
|
||||||
|
if not mapping:
|
||||||
|
mapping = ace_maps.get(printer["name"]) or []
|
||||||
job_id = uuid.uuid4().hex[:12]
|
job_id = uuid.uuid4().hex[:12]
|
||||||
mapping = ace_maps.get(host) or ace_maps.get(printer["name"]) or []
|
|
||||||
job = {
|
job = {
|
||||||
"id": job_id,
|
"id": job_id,
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
@@ -141,13 +162,16 @@ class SendQueue:
|
|||||||
return
|
return
|
||||||
job.update(fields)
|
job.update(fields)
|
||||||
job["updated_at"] = time.time()
|
job["updated_at"] = time.time()
|
||||||
|
publish_live("senden", {})
|
||||||
|
|
||||||
def _worker(self):
|
def _worker(self):
|
||||||
while True:
|
while not self._stop.is_set():
|
||||||
try:
|
try:
|
||||||
item = self._pending.get(timeout=1)
|
item = self._pending.get(timeout=1)
|
||||||
except Empty:
|
except Empty:
|
||||||
continue
|
continue
|
||||||
|
if item is None:
|
||||||
|
break
|
||||||
self._run(item)
|
self._run(item)
|
||||||
|
|
||||||
def _run(self, item):
|
def _run(self, item):
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Start-Zeitfenster pro Drucker (Nachtdruck)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, time, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hm(text):
|
||||||
|
text = str(text or "").strip()
|
||||||
|
if not text or ":" not in text:
|
||||||
|
return None
|
||||||
|
parts = text.split(":", 1)
|
||||||
|
try:
|
||||||
|
h, m = int(parts[0]), int(parts[1])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not (0 <= h <= 23 and 0 <= m <= 59):
|
||||||
|
return None
|
||||||
|
return time(h, m)
|
||||||
|
|
||||||
|
|
||||||
|
def _in_segment(now_t, start_t, end_t):
|
||||||
|
if start_t <= end_t:
|
||||||
|
return start_t <= now_t <= end_t
|
||||||
|
return now_t >= start_t or now_t <= end_t
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_now(start_window, now=None):
|
||||||
|
"""True wenn kein Fenster gesetzt oder die aktuelle Uhrzeit erlaubt ist."""
|
||||||
|
window = start_window or {}
|
||||||
|
if not isinstance(window, dict):
|
||||||
|
return True
|
||||||
|
allowed = window.get("allowed")
|
||||||
|
if not allowed:
|
||||||
|
return True
|
||||||
|
if not isinstance(allowed, list):
|
||||||
|
return True
|
||||||
|
now_dt = now if isinstance(now, datetime) else datetime.now().astimezone()
|
||||||
|
now_t = now_dt.timetz().replace(tzinfo=None)
|
||||||
|
for entry in allowed:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
start_t = _parse_hm(entry.get("from"))
|
||||||
|
end_t = _parse_hm(entry.get("to"))
|
||||||
|
if start_t is None or end_t is None:
|
||||||
|
continue
|
||||||
|
if _in_segment(now_t, start_t, end_t):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def overnight_max_hours(start_window):
|
||||||
|
window = start_window or {}
|
||||||
|
if not isinstance(window, dict):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
val = float(window.get("max_hours_overnight") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return val if val > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def job_fits_overnight(start_window, estimated_s, now=None):
|
||||||
|
"""Optional: Job darf max_hours_overnight nicht überschreiten (nur wenn gesetzt)."""
|
||||||
|
max_h = overnight_max_hours(start_window)
|
||||||
|
if max_h is None:
|
||||||
|
return True
|
||||||
|
if not estimated_s or float(estimated_s) <= 0:
|
||||||
|
return True
|
||||||
|
if allowed_now(start_window, now=now):
|
||||||
|
return float(estimated_s) <= max_h * 3600
|
||||||
|
return True
|
||||||
@@ -8,8 +8,8 @@ MOVE = re.compile(r"^(?:G0|G00|G1|G01)\b(.*)$", re.IGNORECASE)
|
|||||||
# Auch E.39 / Z.6 ohne führende Null (Anycubic/Prusa-Stil)
|
# Auch E.39 / Z.6 ohne führende Null (Anycubic/Prusa-Stil)
|
||||||
AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
|
AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
|
||||||
|
|
||||||
# Cache-/Viewer-Version: Segment-IDs, kein Sehnen-Thinning
|
# Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben)
|
||||||
TOOLPATH_VERSION = 5
|
TOOLPATH_VERSION = 8
|
||||||
|
|
||||||
|
|
||||||
def _gcode_text(path: Path) -> str:
|
def _gcode_text(path: Path) -> str:
|
||||||
@@ -28,48 +28,89 @@ def _gcode_text(path: Path) -> str:
|
|||||||
return path.read_text(encoding="utf-8", errors="replace")
|
return path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _z_key(z):
|
||||||
|
return round(float(z), 2)
|
||||||
|
|
||||||
|
|
||||||
def _thin_points(points, layer_breaks, max_points):
|
def _thin_points(points, layer_breaks, max_points):
|
||||||
"""Extrusions-Segmente stichprobenartig behalten — nie Endpunkte einer Bahn
|
"""Pfad kürzen, ohne Wände zu sprenkeln.
|
||||||
weglassen und die Nachbarn verbinden (sonst Sehnen statt Infill)."""
|
|
||||||
|
Strategie: ganze Z-Höhen (Druckschichten) vollständig behalten und nur
|
||||||
|
ganze Höhen weglassen. Stichproben entlang der Bahn erzeugen Lücken.
|
||||||
|
"""
|
||||||
if len(points) <= max_points:
|
if len(points) <= max_points:
|
||||||
return points, layer_breaks
|
return points, layer_breaks
|
||||||
|
|
||||||
extrude_ends = [i for i in range(1, len(points)) if points[i][3]]
|
# Nur Z-Höhen mit Extrusion — Z-Hops allein zählen nicht
|
||||||
keep = {0, len(points) - 1}
|
z_order = []
|
||||||
for break_i in layer_breaks:
|
seen = set()
|
||||||
keep.add(max(0, min(len(points) - 1, break_i)))
|
for point in points:
|
||||||
if break_i > 0:
|
if not point[3]:
|
||||||
keep.add(break_i - 1)
|
continue
|
||||||
|
key = _z_key(point[2])
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
z_order.append(key)
|
||||||
|
if not z_order:
|
||||||
|
return points[:max_points], [0]
|
||||||
|
|
||||||
# Budget für Segment-Endpunkte (jedes Segment braucht 2 Indizes, viele teilen sich)
|
def keep_for_zs(keep_zs):
|
||||||
target_segs = max(2000, max_points // 2)
|
keep = {0, len(points) - 1}
|
||||||
step = max(1, (len(extrude_ends) + target_segs - 1) // target_segs)
|
for i, point in enumerate(points):
|
||||||
for j, end_i in enumerate(extrude_ends):
|
if _z_key(point[2]) in keep_zs:
|
||||||
if j % step == 0:
|
keep.add(i)
|
||||||
|
if i > 0:
|
||||||
|
keep.add(i - 1)
|
||||||
|
return sorted(keep)
|
||||||
|
|
||||||
|
# So viele Höhen wie ins Budget passen (Schätzung über mittlere Punkte/Höhe)
|
||||||
|
per_z = max(1, len(points) // max(1, len(z_order)))
|
||||||
|
max_zs = max(2, min(len(z_order), max_points // per_z))
|
||||||
|
stride = max(1, (len(z_order) + max_zs - 1) // max_zs)
|
||||||
|
|
||||||
|
def pick_zs(step):
|
||||||
|
chosen = {z_order[0], z_order[-1]}
|
||||||
|
for i in range(0, len(z_order), step):
|
||||||
|
chosen.add(z_order[i])
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
step = stride
|
||||||
|
ordered = keep_for_zs(pick_zs(step))
|
||||||
|
while len(ordered) > max_points and step < len(z_order):
|
||||||
|
step += 1
|
||||||
|
ordered = keep_for_zs(pick_zs(step))
|
||||||
|
|
||||||
|
# Immer noch zu groß: innerhalb behaltener Höhen jedes n-te Extrusionssegment
|
||||||
|
if len(ordered) > max_points:
|
||||||
|
keep_zs = pick_zs(step)
|
||||||
|
segs = [
|
||||||
|
i
|
||||||
|
for i in range(1, len(points))
|
||||||
|
if points[i][3] and _z_key(points[i][2]) in keep_zs
|
||||||
|
]
|
||||||
|
budget = max(1, (max_points - 4) // 2)
|
||||||
|
seg_step = max(1, (len(segs) + budget - 1) // budget)
|
||||||
|
keep = {0, len(points) - 1}
|
||||||
|
for end_i in segs[::seg_step]:
|
||||||
keep.add(end_i - 1)
|
keep.add(end_i - 1)
|
||||||
keep.add(end_i)
|
keep.add(end_i)
|
||||||
|
ordered = sorted(keep)
|
||||||
ordered = sorted(keep)
|
|
||||||
# Falls immer noch zu groß: Segmente grober
|
|
||||||
if len(ordered) > max_points:
|
|
||||||
step2 = max(1, (len(ordered) + max_points - 1) // max_points)
|
|
||||||
ordered = ordered[::step2]
|
|
||||||
if ordered[-1] != len(points) - 1:
|
|
||||||
ordered.append(len(points) - 1)
|
|
||||||
|
|
||||||
index_map = {old: new for new, old in enumerate(ordered)}
|
index_map = {old: new for new, old in enumerate(ordered)}
|
||||||
kept = [points[i] for i in ordered]
|
kept = [points[i] for i in ordered]
|
||||||
new_breaks = sorted({
|
new_breaks = []
|
||||||
index_map[b]
|
last_z = None
|
||||||
for b in layer_breaks
|
for new_i, point in enumerate(kept):
|
||||||
if b in index_map
|
z = _z_key(point[2])
|
||||||
})
|
if last_z is None or abs(z - last_z) > 0.01:
|
||||||
|
new_breaks.append(new_i)
|
||||||
|
last_z = z
|
||||||
if not new_breaks:
|
if not new_breaks:
|
||||||
new_breaks = [0]
|
new_breaks = [0]
|
||||||
return kept, new_breaks
|
return kept, new_breaks
|
||||||
|
|
||||||
|
|
||||||
def build_toolpath(path: Path, max_points=120000):
|
def build_toolpath(path: Path, max_points=220000):
|
||||||
text = _gcode_text(path)
|
text = _gcode_text(path)
|
||||||
if not text:
|
if not text:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Ist-Gramm aus Moonraker-Filamentzählern und Spulenanteilen."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def as_float(value, default=0.0):
|
||||||
|
if value is None or value is False:
|
||||||
|
return float(default)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return float(default)
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return float(value.replace(",", ".").strip())
|
||||||
|
except ValueError:
|
||||||
|
return float(default)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return sum(as_float(item) for item in value)
|
||||||
|
return float(default)
|
||||||
|
|
||||||
|
|
||||||
|
def weight_list(metadata):
|
||||||
|
"""Gramm je Farbe aus filament_weight_total (Zahl oder Liste)."""
|
||||||
|
raw = (metadata or {}).get("filament_weight_total")
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return [as_float(item) for item in raw]
|
||||||
|
total = as_float(raw)
|
||||||
|
return [total] if total > 0 else []
|
||||||
|
|
||||||
|
|
||||||
|
def weight_sum(metadata):
|
||||||
|
return sum(weight_list(metadata))
|
||||||
|
|
||||||
|
|
||||||
|
def filament_total_mm(metadata, job=None):
|
||||||
|
raw = (metadata or {}).get("filament_total")
|
||||||
|
if raw in (None, "", [], 0, 0.0) and isinstance(job, dict):
|
||||||
|
raw = job.get("filament_total")
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return sum(as_float(item) for item in raw)
|
||||||
|
return as_float(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def filament_used_mm(job):
|
||||||
|
if not isinstance(job, dict):
|
||||||
|
return 0.0
|
||||||
|
return as_float(job.get("filament_used"))
|
||||||
|
|
||||||
|
|
||||||
|
def planned_shares(metadata, ace_map, target_count):
|
||||||
|
"""Geplante Gramm je Ziel-Spule. Index entspricht targets-Reihenfolge."""
|
||||||
|
weights = weight_list(metadata)
|
||||||
|
mapping = ace_map if isinstance(ace_map, list) else []
|
||||||
|
if weights and mapping and len(weights) >= len(mapping) and target_count == len(mapping):
|
||||||
|
paints = []
|
||||||
|
for item in mapping:
|
||||||
|
try:
|
||||||
|
paints.append(int(item.get("paint_index")))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
paints.append(-1)
|
||||||
|
# Nur bei dichter 0…n−1-Map paint_index = Gewichtsindex; sonst Map-Reihenfolge
|
||||||
|
# (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:
|
||||||
|
shares.append(0.0)
|
||||||
|
if sum(shares) > 0:
|
||||||
|
return shares
|
||||||
|
if weights and target_count == len(weights):
|
||||||
|
return list(weights)
|
||||||
|
total = sum(weights) if weights else 0.0
|
||||||
|
if target_count <= 0:
|
||||||
|
return []
|
||||||
|
if total <= 0:
|
||||||
|
return [0.0] * target_count
|
||||||
|
each = total / target_count
|
||||||
|
return [each] * target_count
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_used_grams(planned_g, used_mm, total_mm, progress, state, duration_s):
|
||||||
|
"""
|
||||||
|
Ist-Gramm und Herkunft.
|
||||||
|
measured = filament_used, estimate = Fortschritt oder Soll, none = nichts.
|
||||||
|
"""
|
||||||
|
planned = max(0.0, as_float(planned_g))
|
||||||
|
used = max(0.0, as_float(used_mm))
|
||||||
|
total = max(0.0, as_float(total_mm))
|
||||||
|
prog = max(0.0, min(1.0, as_float(progress)))
|
||||||
|
duration = max(0.0, as_float(duration_s))
|
||||||
|
terminal_abort = state in ("cancelled", "error", "interrupted")
|
||||||
|
|
||||||
|
if used > 0.5 and total > 0:
|
||||||
|
grams = planned * (used / total) if planned > 0 else 0.0
|
||||||
|
if grams <= 0 and used > 0.5:
|
||||||
|
# Länge ohne Gewicht: grob 1.24 g/cm³ · 1.75 mm → ~3 g/m
|
||||||
|
grams = used * 0.003
|
||||||
|
return max(0.0, grams), "measured"
|
||||||
|
|
||||||
|
if used > 0.5 and planned > 0 and total <= 0:
|
||||||
|
# Millimeter da, Gesamtlänge fehlt — Fortschritt oder Dauer helfen nicht
|
||||||
|
return used * 0.003, "estimate"
|
||||||
|
|
||||||
|
if terminal_abort and used <= 0.5:
|
||||||
|
if prog > 0.005 and planned > 0:
|
||||||
|
return planned * prog, "estimate"
|
||||||
|
return 0.0, "none"
|
||||||
|
|
||||||
|
if planned > 0:
|
||||||
|
if prog > 0.005 and prog < 0.995 and state != "completed":
|
||||||
|
return planned * prog, "estimate"
|
||||||
|
return planned, "estimate" if state == "completed" else "none"
|
||||||
|
|
||||||
|
return 0.0, "none"
|
||||||
|
|
||||||
|
|
||||||
|
def split_used(used_g, planned_shares_list, multi):
|
||||||
|
"""
|
||||||
|
Ist-Gramm auf Spulen verteilen.
|
||||||
|
multi=True → Anteile sind Schätzwerte, Summe bleibt used_g.
|
||||||
|
"""
|
||||||
|
used = max(0.0, as_float(used_g))
|
||||||
|
shares = [max(0.0, as_float(s)) for s in (planned_shares_list or [])]
|
||||||
|
if not shares:
|
||||||
|
return [], False
|
||||||
|
total_plan = sum(shares)
|
||||||
|
if total_plan <= 0:
|
||||||
|
each = used / len(shares)
|
||||||
|
parts = [each] * len(shares)
|
||||||
|
else:
|
||||||
|
parts = [used * (share / total_plan) for share in shares]
|
||||||
|
is_estimate = bool(multi) and len(shares) > 1
|
||||||
|
return parts, is_estimate
|
||||||
|
|
||||||
|
|
||||||
|
def segment_deltas(segments, final_mm):
|
||||||
|
"""
|
||||||
|
Abschnitte mit mm_start/mm_end → Delta-mm je (spool_id, ace_slot).
|
||||||
|
Offene Abschnitte schließen auf final_mm.
|
||||||
|
"""
|
||||||
|
end = max(0.0, as_float(final_mm))
|
||||||
|
totals = {}
|
||||||
|
order = []
|
||||||
|
for seg in segments or []:
|
||||||
|
start = max(0.0, as_float(seg.get("mm_start")))
|
||||||
|
raw_end = seg.get("mm_end")
|
||||||
|
stop = end if raw_end is None else max(0.0, as_float(raw_end))
|
||||||
|
delta = max(0.0, stop - start)
|
||||||
|
key = (seg.get("spool_id"), seg.get("ace_slot"))
|
||||||
|
if key not in totals:
|
||||||
|
totals[key] = 0.0
|
||||||
|
order.append(key)
|
||||||
|
totals[key] += delta
|
||||||
|
return [(key[0], key[1], totals[key]) for key in order]
|
||||||
|
|
||||||
|
|
||||||
|
def grams_from_mm(delta_mm, total_mm, planned_g):
|
||||||
|
delta = max(0.0, as_float(delta_mm))
|
||||||
|
total = max(0.0, as_float(total_mm))
|
||||||
|
planned = max(0.0, as_float(planned_g))
|
||||||
|
if delta <= 0:
|
||||||
|
return 0.0
|
||||||
|
if total > 0 and planned > 0:
|
||||||
|
return planned * (delta / total)
|
||||||
|
return delta * 0.003
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Urteil und Ausschuss-Gründe beim Abräumen."""
|
||||||
|
|
||||||
|
VERDICT_REASONS = frozenset({
|
||||||
|
"haftung",
|
||||||
|
"warping",
|
||||||
|
"spaghetti",
|
||||||
|
"layer_shift",
|
||||||
|
"verstopfung",
|
||||||
|
"farbe",
|
||||||
|
"sonstiges",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_abraeumen_verdict(verdict, verdict_reason, pieces_ok, pieces_bad):
|
||||||
|
v = (verdict or "").strip().lower()
|
||||||
|
if not v:
|
||||||
|
v = "gut"
|
||||||
|
if v not in ("gut", "ausschuss"):
|
||||||
|
raise ValueError("Urteil muss gut oder ausschuss sein.")
|
||||||
|
reason = (verdict_reason or "").strip().lower()
|
||||||
|
if v == "gut":
|
||||||
|
reason = ""
|
||||||
|
pieces_bad = 0
|
||||||
|
elif reason and reason not in VERDICT_REASONS:
|
||||||
|
raise ValueError("Unbekannter Ausschuss-Grund.")
|
||||||
|
try:
|
||||||
|
ok_n = max(0, int(pieces_ok or 0))
|
||||||
|
bad_n = max(0, int(pieces_bad or 0))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("Stückzahlen müssen ganze Zahlen sein.") from exc
|
||||||
|
if v == "ausschuss" and bad_n <= 0 and ok_n <= 0:
|
||||||
|
bad_n = 1
|
||||||
|
return v, reason, ok_n, bad_n
|
||||||
@@ -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__ = "2.0.0"
|
__version__ = "3.5.0"
|
||||||
@@ -4,10 +4,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from grotti import moonraker, notify, store
|
from grotti import jobs, logutil, moonraker, notify, store, usage
|
||||||
|
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_HEAT_S = 10
|
||||||
|
|
||||||
|
|
||||||
class StateWatcher:
|
class StateWatcher:
|
||||||
def __init__(self, database_url, cfg, interval=10):
|
def __init__(self, database_url, cfg, interval=10):
|
||||||
@@ -21,6 +28,7 @@ class StateWatcher:
|
|||||||
self._updated_at = 0.0
|
self._updated_at = 0.0
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self._thread = None
|
self._thread = None
|
||||||
|
self._last_sample = {}
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
if self._thread and self._thread.is_alive():
|
if self._thread and self._thread.is_alive():
|
||||||
@@ -53,22 +61,25 @@ 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):
|
||||||
with connect(self.database_url) as conn:
|
try:
|
||||||
printers = store.list_printers(conn)
|
with connect(self.database_url) as conn:
|
||||||
if not printers:
|
printers = store.list_printers(conn)
|
||||||
|
if not printers:
|
||||||
|
conn.commit()
|
||||||
|
return
|
||||||
|
lives = moonraker.snapshot(printers)
|
||||||
|
with self._lock:
|
||||||
|
self.latest = lives
|
||||||
|
self._updated_at = time.monotonic()
|
||||||
|
for live in lives:
|
||||||
|
self._one(conn, live)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return
|
finally:
|
||||||
lives = moonraker.snapshot(printers)
|
publish_live("drucker", {"at": datetime.now(timezone.utc).isoformat()})
|
||||||
with self._lock:
|
|
||||||
self.latest = lives
|
|
||||||
self._updated_at = time.monotonic()
|
|
||||||
for live in lives:
|
|
||||||
self._one(conn, live)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def _one(self, conn, live):
|
def _one(self, conn, live):
|
||||||
host = live.get("host") or ""
|
host = live.get("host") or ""
|
||||||
@@ -79,25 +90,96 @@ class StateWatcher:
|
|||||||
prev = self._prev.get(host) or {}
|
prev = self._prev.get(host) or {}
|
||||||
prev_state = (prev.get("state") or "").lower()
|
prev_state = (prev.get("state") or "").lower()
|
||||||
prev_offline = bool(prev.get("offline"))
|
prev_offline = bool(prev.get("offline"))
|
||||||
|
run_id = None
|
||||||
|
|
||||||
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:
|
if hits >= 2 and 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)
|
||||||
|
# Nach Crash/Shutdown (503/gklib) bleibt sonst „druckt“ im Protokoll.
|
||||||
|
if hits >= 2:
|
||||||
|
dangling = store.find_live_run(conn, host)
|
||||||
|
if dangling:
|
||||||
|
closed = store.complete_open_run(
|
||||||
|
conn, dangling["id"], state="interrupted"
|
||||||
|
)
|
||||||
|
if closed:
|
||||||
|
label = dangling.get("filename") or filename or "offline"
|
||||||
|
self._emit(
|
||||||
|
conn,
|
||||||
|
host,
|
||||||
|
name,
|
||||||
|
"abbruch",
|
||||||
|
"%s: Druck unterbrochen (Drucker offline)." % name,
|
||||||
|
label,
|
||||||
|
closed["id"],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from grotti import jobs
|
||||||
|
|
||||||
|
jobs.apply_deduction(conn, closed, self.cfg["energy"])
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Watcher Abzug offline %s: %s", host, exc)
|
||||||
self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
|
self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
|
||||||
return
|
return
|
||||||
|
|
||||||
self._offline_hits[host] = 0
|
self._offline_hits[host] = 0
|
||||||
|
starting = (
|
||||||
|
bool(prev_state)
|
||||||
|
and prev_state not in ("printing", "paused")
|
||||||
|
and state == "printing"
|
||||||
|
)
|
||||||
|
if starting:
|
||||||
|
# Aufheiz-Proben behalten: Live-Lauf gleicher Datei nicht verwerfen
|
||||||
|
keep = None
|
||||||
|
existing = store.find_live_run(conn, host)
|
||||||
|
if existing:
|
||||||
|
same = (
|
||||||
|
not filename
|
||||||
|
or not existing.get("filename")
|
||||||
|
or existing.get("filename") == filename
|
||||||
|
)
|
||||||
|
if same:
|
||||||
|
keep = existing["id"]
|
||||||
|
if filename and existing.get("filename") != filename:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE runs SET filename = %s WHERE id = %s",
|
||||||
|
(filename, keep),
|
||||||
|
)
|
||||||
|
store.abandon_live_runs(conn, host, keep_id=keep)
|
||||||
|
sample_now = self._should_sample(conn, host, live, state)
|
||||||
|
heating = sample_now and state not in ("printing", "paused")
|
||||||
|
if sample_now:
|
||||||
|
force = starting or (heating and not prev.get("heating"))
|
||||||
|
run = self._track_print(conn, host, name, filename, live, state, force_sample=force)
|
||||||
|
run_id = run["id"] if run else None
|
||||||
|
|
||||||
if prev_offline:
|
if prev_offline:
|
||||||
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename)
|
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename, run_id)
|
||||||
elif prev_state and prev_state not in ("printing", "paused") and state == "printing":
|
elif starting:
|
||||||
self._emit(conn, host, name, "start", "%s druckt %s" % (name, filename or "einen Auftrag"), filename)
|
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 prev_state == "printing" and state == "paused":
|
elif prev_state == "printing" and state == "paused":
|
||||||
self._emit(conn, host, name, "start", "%s pausiert." % name, filename)
|
reason = (live.get("pause_reason") or "").strip()
|
||||||
|
if live.get("filament_empty") or reason:
|
||||||
|
detail = reason or "Filament leer oder gerissen"
|
||||||
|
self._emit(
|
||||||
|
conn, host, name, "spule",
|
||||||
|
"%s pausiert — Material nachlegen (%s)." % (name, detail),
|
||||||
|
filename, run_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
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)
|
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, 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,
|
||||||
@@ -106,27 +188,56 @@ class StateWatcher:
|
|||||||
"fertig",
|
"fertig",
|
||||||
"%s fertig: %s" % (name, filename or "Druck"),
|
"%s fertig: %s" % (name, filename or "Druck"),
|
||||||
filename,
|
filename,
|
||||||
|
run_id or (prev.get("run_id")),
|
||||||
)
|
)
|
||||||
# Protokoll und Spulenabzug nicht erst nach manuellem Knopf
|
|
||||||
try:
|
try:
|
||||||
from grotti import jobs
|
from grotti import jobs
|
||||||
|
|
||||||
jobs.sync_history(conn, self.cfg)
|
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)
|
||||||
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"):
|
# History oft ohne Endzeit (interrupted) — Live trotzdem schließen.
|
||||||
store.set_bed_needs_clear(conn, host, True, filename or state)
|
dangling = store.find_live_run(conn, host)
|
||||||
self._emit(
|
if dangling:
|
||||||
conn,
|
closed = store.complete_open_run(conn, dangling["id"], state="completed")
|
||||||
host,
|
if closed:
|
||||||
name,
|
try:
|
||||||
"abbruch",
|
from grotti import jobs
|
||||||
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"),
|
|
||||||
filename,
|
jobs.apply_deduction(conn, closed, self.cfg["energy"])
|
||||||
)
|
except Exception as exc:
|
||||||
|
log.warning("Watcher Abzug %s: %s", host, exc)
|
||||||
|
elif state in ("cancelled", "standby"):
|
||||||
|
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
|
||||||
|
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
|
||||||
|
dangling = store.find_live_run(conn, host)
|
||||||
|
from_active = prev_state in ("printing", "paused")
|
||||||
|
recover = bool(dangling) and (prev_offline or not prev_state)
|
||||||
|
if from_active or recover:
|
||||||
|
file_label = filename or (dangling or {}).get("filename") or state
|
||||||
|
store.set_bed_needs_clear(conn, host, True, file_label)
|
||||||
|
store.abandon_live_runs(conn, host)
|
||||||
|
self._emit(
|
||||||
|
conn,
|
||||||
|
host,
|
||||||
|
name,
|
||||||
|
"abbruch",
|
||||||
|
"%s: Druck abgebrochen (%s)" % (
|
||||||
|
name, file_label if file_label != state else "ohne Datei",
|
||||||
|
),
|
||||||
|
file_label if file_label != state else "",
|
||||||
|
run_id or prev.get("run_id") or (dangling or {}).get("id"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from grotti import jobs
|
||||||
|
|
||||||
|
jobs.sync_history(conn, self.cfg)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Watcher Abgleich %s: %s", host, exc)
|
||||||
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")
|
||||||
|
store.abandon_live_runs(conn, host)
|
||||||
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"
|
||||||
@@ -138,18 +249,37 @@ class StateWatcher:
|
|||||||
"fehler",
|
"fehler",
|
||||||
"%s: %s · Heizung aus" % (name, msg),
|
"%s: %s · Heizung aus" % (name, msg),
|
||||||
filename,
|
filename,
|
||||||
|
run_id or prev.get("run_id"),
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
from grotti import jobs
|
||||||
|
|
||||||
|
jobs.sync_history(conn, self.cfg)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Watcher Abgleich %s: %s", host, exc)
|
||||||
elif state == "error":
|
elif state == "error":
|
||||||
# Schon im Fehler, Heizer laufen noch → aus
|
|
||||||
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":
|
elif state == "complete" and (not prev_state or prev_offline):
|
||||||
known = conn.execute(
|
# Erster Blick nach Neustart: nur mit Endzeit des letzten Laufs,
|
||||||
"SELECT 1 FROM bed_status WHERE host = %s", (host,)
|
# sonst wird bereits Quittiertes wieder „Platte belegt“.
|
||||||
|
ended = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT ended_at FROM runs
|
||||||
|
WHERE printer_host = %s
|
||||||
|
AND state IN ('completed', 'cancelled', 'error', 'interrupted')
|
||||||
|
AND ended_at IS NOT NULL
|
||||||
|
ORDER BY ended_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(host,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not known:
|
store.ensure_bed_after_print(
|
||||||
# Erster Stand nach Deploy: fertiger Druck ohne Quittierung
|
conn,
|
||||||
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
|
host,
|
||||||
|
ended_at=ended["ended_at"] if ended else None,
|
||||||
|
message=filename or "fertig",
|
||||||
|
)
|
||||||
|
|
||||||
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:
|
||||||
@@ -158,40 +288,193 @@ class StateWatcher:
|
|||||||
self._prev[host] = {
|
self._prev[host] = {
|
||||||
"state": state,
|
"state": state,
|
||||||
"offline": False,
|
"offline": False,
|
||||||
|
"heating": heating,
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"spool_warn": spool_warn if state == "printing" else False,
|
"spool_warn": spool_warn if state == "printing" else False,
|
||||||
|
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") or heating else None),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _should_sample(self, conn, host, live, state):
|
||||||
|
"""Proben bei Druck/Pause und beim Aufheizen mit Bezug zu einem Auftrag."""
|
||||||
|
if state in ("printing", "paused"):
|
||||||
|
return True
|
||||||
|
if state in ("error", "offline", ""):
|
||||||
|
return False
|
||||||
|
nozzle_t = usage.as_float(live.get("nozzle_target"))
|
||||||
|
bed_t = usage.as_float(live.get("bed_target"))
|
||||||
|
if nozzle_t <= 40 and bed_t <= 30:
|
||||||
|
return False
|
||||||
|
if live.get("filename"):
|
||||||
|
return True
|
||||||
|
if store.running_print_on_host(conn, host):
|
||||||
|
return True
|
||||||
|
return bool(store.find_live_run(conn, host))
|
||||||
|
|
||||||
|
def _track_print(self, conn, host, name, filename, live, state, force_sample=False):
|
||||||
|
if not filename:
|
||||||
|
running = store.running_print_on_host(conn, host)
|
||||||
|
if running:
|
||||||
|
filename = running.get("filename") or ""
|
||||||
|
if not filename:
|
||||||
|
existing = store.find_live_run(conn, host)
|
||||||
|
if existing:
|
||||||
|
filename = existing.get("filename") or ""
|
||||||
|
if not filename and state not in ("printing", "paused"):
|
||||||
|
return None
|
||||||
|
sent = store.latest_send(conn, host, filename) if filename else None
|
||||||
|
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)
|
||||||
|
objects = self._merge_objects(run.get("objects"), live.get("exclude"))
|
||||||
|
planned = usage.as_float(live.get("weight"))
|
||||||
|
total_mm = usage.as_float(live.get("filament_total"))
|
||||||
|
used_mm = usage.as_float(live.get("filament_used"))
|
||||||
|
progress = usage.as_float(live.get("progress"))
|
||||||
|
estimated = usage.as_float(live.get("estimated"))
|
||||||
|
store.update_run_progress(conn, run["id"], {
|
||||||
|
"state": "paused" if state == "paused" else "in_progress",
|
||||||
|
"progress_last": progress,
|
||||||
|
"filament_used_mm": used_mm,
|
||||||
|
"filament_total_mm": total_mm,
|
||||||
|
"weight_planned_g": planned,
|
||||||
|
"estimated_s": estimated,
|
||||||
|
"message": (live.get("message") or "") if state == "error" else "",
|
||||||
|
"objects": objects,
|
||||||
|
"duration_s": usage.as_float(live.get("elapsed")),
|
||||||
|
})
|
||||||
|
store.ensure_run_chargen(conn, run["id"])
|
||||||
|
self._maybe_sample(conn, run["id"], live, used_mm, force=force_sample)
|
||||||
|
return store.get_run_row(conn, run["id"])
|
||||||
|
|
||||||
|
def _merge_objects(self, current, exclude):
|
||||||
|
known = {}
|
||||||
|
if isinstance(current, str):
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
current = json.loads(current)
|
||||||
|
except Exception:
|
||||||
|
current = []
|
||||||
|
for item in current or []:
|
||||||
|
if isinstance(item, dict) and item.get("name"):
|
||||||
|
known[item["name"]] = {
|
||||||
|
"name": item["name"],
|
||||||
|
"excluded": bool(item.get("excluded")),
|
||||||
|
}
|
||||||
|
exclude = exclude or {}
|
||||||
|
excluded = set(exclude.get("excluded") or [])
|
||||||
|
for item in exclude.get("objects") or []:
|
||||||
|
name = (item.get("name") if isinstance(item, dict) else str(item)).strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
known[name] = {"name": name, "excluded": name in excluded}
|
||||||
|
for name in excluded:
|
||||||
|
if name not in known:
|
||||||
|
known[name] = {"name": name, "excluded": True}
|
||||||
|
return list(known.values())
|
||||||
|
|
||||||
|
def _heat_gap(self, live):
|
||||||
|
"""Abstand Ist↔Soll — während Aufheizen dichter samplen."""
|
||||||
|
gaps = []
|
||||||
|
for key, tkey in (("nozzle", "nozzle_target"), ("bed", "bed_target")):
|
||||||
|
target = usage.as_float(live.get(tkey))
|
||||||
|
if target <= 0:
|
||||||
|
continue
|
||||||
|
gaps.append(abs(usage.as_float(live.get(key)) - target))
|
||||||
|
return max(gaps) if gaps else 0
|
||||||
|
|
||||||
|
def _maybe_sample(self, conn, run_id, live, used_mm, force=False):
|
||||||
|
now = time.monotonic()
|
||||||
|
last = self._last_sample.get(run_id, 0)
|
||||||
|
interval = SAMPLE_HEAT_S if self._heat_gap(live) > 3 else SAMPLE_EVERY_S
|
||||||
|
if not force and now - last < interval:
|
||||||
|
return
|
||||||
|
self._last_sample[run_id] = now
|
||||||
|
store.add_run_sample(conn, run_id, {
|
||||||
|
"sampled_at": datetime.now(timezone.utc),
|
||||||
|
"nozzle": live.get("nozzle"),
|
||||||
|
"nozzle_target": live.get("nozzle_target"),
|
||||||
|
"bed": live.get("bed"),
|
||||||
|
"bed_target": live.get("bed_target"),
|
||||||
|
"chamber": live.get("chamber"),
|
||||||
|
"fan": live.get("fan"),
|
||||||
|
"speed_factor": live.get("speed_factor"),
|
||||||
|
"extrude_factor": live.get("extrude_factor"),
|
||||||
|
"layer": live.get("layer") or 0,
|
||||||
|
"progress": live.get("progress"),
|
||||||
|
"filament_used_mm": used_mm,
|
||||||
|
"mmu_tool": live.get("mmu_tool"),
|
||||||
|
"nozzle_power": live.get("nozzle_power"),
|
||||||
|
"bed_power": live.get("bed_power"),
|
||||||
|
})
|
||||||
|
|
||||||
|
def _ensure_start_segment(self, conn, host, filename, live, run_id):
|
||||||
|
"""Nur externe Einzelspule: ein Abschnitt. ACE-Farben ohne Wechsel → Anteil-Split."""
|
||||||
|
if not run_id:
|
||||||
|
return
|
||||||
|
existing = store.list_run_segments(conn, run_id)
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
sent = store.latest_send(conn, host, filename) if filename else None
|
||||||
|
ace = (sent or {}).get("ace_map") or []
|
||||||
|
from grotti import acm
|
||||||
|
|
||||||
|
mapping = acm.normalize_mapping(ace) if ace else []
|
||||||
|
if mapping:
|
||||||
|
return
|
||||||
|
used_mm = usage.as_float(live.get("filament_used"))
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM spools
|
||||||
|
WHERE printer_host = %s AND ace_slot IS NULL
|
||||||
|
ORDER BY id LIMIT 1
|
||||||
|
""",
|
||||||
|
(host,),
|
||||||
|
).fetchone()
|
||||||
|
if rows:
|
||||||
|
store.open_segment(conn, run_id, rows["id"], None, used_mm)
|
||||||
|
|
||||||
def _cool_heaters(self, host):
|
def _cool_heaters(self, host):
|
||||||
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)
|
||||||
if need <= 0 or not filename:
|
if need <= 0 or not filename:
|
||||||
return False
|
return False
|
||||||
sent = store.latest_send(conn, host, filename)
|
sent = store.latest_send(conn, host, filename)
|
||||||
spool_id = (sent or {}).get("spool_id")
|
ace = (sent or {}).get("ace_map") or []
|
||||||
if not spool_id:
|
from grotti import jobs as grotti_jobs
|
||||||
|
|
||||||
|
at_host = grotti_jobs.resolve_spools_at_host(conn, host, ace)
|
||||||
|
if not at_host:
|
||||||
|
spool_id = (sent or {}).get("spool_id")
|
||||||
|
if spool_id:
|
||||||
|
row = store.get_spool_row(conn, spool_id)
|
||||||
|
if row:
|
||||||
|
at_host = [row]
|
||||||
|
if not at_host:
|
||||||
return False
|
return False
|
||||||
spool = store.get_spool_row(conn, spool_id)
|
left = sum(float(s.get("remaining_g") or 0) for s in at_host)
|
||||||
if not spool:
|
|
||||||
return False
|
|
||||||
left = float(spool["remaining_g"] or 0)
|
|
||||||
if left + 0.5 >= need:
|
if left + 0.5 >= need:
|
||||||
return False
|
return False
|
||||||
|
label = " / ".join(s.get("name") or "?" for s in at_host[:3])
|
||||||
self._emit(
|
self._emit(
|
||||||
conn,
|
conn,
|
||||||
host,
|
host,
|
||||||
name,
|
name,
|
||||||
"spule",
|
"spule",
|
||||||
"%s: Spule „%s“ hat nur %.0f g, Druck braucht %.0f g."
|
"%s: %s hat nur %.0f g, Druck braucht %.0f g."
|
||||||
% (name, spool["name"], left, need),
|
% (name, label, left, need),
|
||||||
filename,
|
filename,
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _emit(self, conn, host, printer, kind, message, filename):
|
def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
|
||||||
notify.record(conn, self.cfg, host, printer, kind, message, filename, dedupe_s=60)
|
notify.record(
|
||||||
|
conn, self.cfg, host, printer, kind, message, filename,
|
||||||
|
dedupe_s=60, run_id=run_id,
|
||||||
|
)
|
||||||
|
publish_live("ereignis", {})
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut grotti_<version>_<arch>.deb (auf dem Zielsystem oder arm64-Container).
|
||||||
|
# Aufruf aus dem Repo-Root: bash packaging/build-deb.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
if ! command -v dpkg-deb >/dev/null 2>&1; then
|
||||||
|
echo "dpkg-deb fehlt — bitte auf Debian/Raspberry Pi OS bauen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="$(python3 -c "from grotti.version import __version__; print(__version__)")"
|
||||||
|
ARCH="$(dpkg --print-architecture)"
|
||||||
|
PKG="grotti_${VERSION}_${ARCH}"
|
||||||
|
DIST="$ROOT/packaging/dist"
|
||||||
|
STAGE="$DIST/$PKG"
|
||||||
|
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
mkdir -p "$STAGE/DEBIAN" \
|
||||||
|
"$STAGE/usr/lib/grotti" \
|
||||||
|
"$STAGE/usr/bin" \
|
||||||
|
"$STAGE/etc/grotti" \
|
||||||
|
"$STAGE/lib/systemd/system" \
|
||||||
|
"$STAGE/usr/share/grotti" \
|
||||||
|
"$STAGE/usr/share/doc/grotti" \
|
||||||
|
"$STAGE/var/lib/grotti/data/eingang" \
|
||||||
|
"$STAGE/var/lib/grotti/slice"
|
||||||
|
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude '__pycache__' --exclude '*.pyc' --exclude '.pytest_cache' \
|
||||||
|
"$ROOT/grotti/" "$STAGE/usr/lib/grotti/grotti/"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude '__pycache__' --exclude '*.pyc' \
|
||||||
|
"$ROOT/web/" "$STAGE/usr/lib/grotti/web/"
|
||||||
|
cp "$ROOT/server.py" "$STAGE/usr/lib/grotti/server.py"
|
||||||
|
cp "$ROOT/requirements.txt" "$STAGE/usr/lib/grotti/requirements.txt"
|
||||||
|
cp "$ROOT/config.example.json" "$STAGE/usr/lib/grotti/config.example.json"
|
||||||
|
echo '[]' > "$STAGE/usr/lib/grotti/drucker.json"
|
||||||
|
cp "$ROOT/packaging/config.pi.json" "$STAGE/usr/share/grotti/config.pi.json"
|
||||||
|
cp "$ROOT/packaging/config.pi.json" "$STAGE/etc/grotti/config.json"
|
||||||
|
cp "$ROOT/packaging/grotti.service" "$STAGE/lib/systemd/system/grotti.service"
|
||||||
|
cp "$ROOT/docs/Anleitung-Installation-Pi.md" "$STAGE/usr/share/doc/grotti/"
|
||||||
|
cp "$ROOT/CHANGELOG.md" "$STAGE/usr/share/doc/grotti/changelog"
|
||||||
|
gzip -9 -f "$STAGE/usr/share/doc/grotti/changelog"
|
||||||
|
|
||||||
|
cat > "$STAGE/usr/bin/grotti" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
export GROTTI_CONFIG="${GROTTI_CONFIG:-/etc/grotti/config.json}"
|
||||||
|
exec /usr/lib/grotti/venv/bin/python /usr/lib/grotti/server.py "$@"
|
||||||
|
EOF
|
||||||
|
chmod 755 "$STAGE/usr/bin/grotti"
|
||||||
|
|
||||||
|
python3 -m venv "$STAGE/usr/lib/grotti/venv"
|
||||||
|
"$STAGE/usr/lib/grotti/venv/bin/pip" install --upgrade pip wheel
|
||||||
|
"$STAGE/usr/lib/grotti/venv/bin/pip" install -r "$ROOT/requirements.txt"
|
||||||
|
|
||||||
|
SIZE_KB="$(du -sk "$STAGE" | awk '{print $1}')"
|
||||||
|
cat > "$STAGE/DEBIAN/control" <<EOF
|
||||||
|
Package: grotti
|
||||||
|
Version: ${VERSION}
|
||||||
|
Section: web
|
||||||
|
Priority: optional
|
||||||
|
Architecture: ${ARCH}
|
||||||
|
Maintainer: Dominic Gundel <[email protected]>
|
||||||
|
Depends: python3 (>= 3.11), python3-venv, postgresql, adduser
|
||||||
|
Installed-Size: ${SIZE_KB}
|
||||||
|
Homepage: https://git.gundel.dev/dg/grotti
|
||||||
|
Description: Farm-Manager für Moonraker-Drucker (Kobra 3 / S1)
|
||||||
|
Grotti steuert eine 3D-Drucker-Farm über Moonraker: Wand, Warteschlange,
|
||||||
|
Material, Abläufe und Protokoll. PostgreSQL wird lokal mit eingerichtet.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cp "$ROOT/packaging/debian/conffiles" "$STAGE/DEBIAN/conffiles"
|
||||||
|
for script in postinst prerm postrm; do
|
||||||
|
cp "$ROOT/packaging/debian/$script" "$STAGE/DEBIAN/$script"
|
||||||
|
chmod 755 "$STAGE/DEBIAN/$script"
|
||||||
|
done
|
||||||
|
|
||||||
|
dpkg-deb --root-owner-group --build "$STAGE" "$DIST/${PKG}.deb"
|
||||||
|
echo "Gebaut: $DIST/${PKG}.deb"
|
||||||
|
echo "Install: sudo apt install ./packaging/dist/${PKG}.deb"
|
||||||
|
echo "Logs: journalctl -u grotti -f"
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8770,
|
||||||
|
"data_dir": "/var/lib/grotti/data",
|
||||||
|
"watch_dir": "/var/lib/grotti/slice",
|
||||||
|
"database_url": "postgresql:///grotti?host=/var/run/postgresql",
|
||||||
|
"token": "",
|
||||||
|
"secret_key": "",
|
||||||
|
"fqdn": "",
|
||||||
|
"log_level": "INFO",
|
||||||
|
"auto_queue": true,
|
||||||
|
"auto_retry": true,
|
||||||
|
"oauth": {
|
||||||
|
"enabled": false,
|
||||||
|
"issuer": "",
|
||||||
|
"client_id": "",
|
||||||
|
"client_secret": "",
|
||||||
|
"redirect_uri": ""
|
||||||
|
},
|
||||||
|
"energy": {
|
||||||
|
"watts": 40,
|
||||||
|
"nozzle_watts": 60,
|
||||||
|
"bed_watts": 220,
|
||||||
|
"euro_per_kwh": 0.35,
|
||||||
|
"dryer_watts": 0,
|
||||||
|
"dry_hours": 0,
|
||||||
|
"waste_percent": 0,
|
||||||
|
"utilization": 50,
|
||||||
|
"tariff": {
|
||||||
|
"ht_euro": 0.35,
|
||||||
|
"nt_euro": 0.22,
|
||||||
|
"nt_from": "22:00",
|
||||||
|
"nt_to": "06:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"enabled": false,
|
||||||
|
"bot_token": "",
|
||||||
|
"chat_id": ""
|
||||||
|
},
|
||||||
|
"ntfy": {
|
||||||
|
"enabled": false,
|
||||||
|
"server": "https://ntfy.sh",
|
||||||
|
"topic": "",
|
||||||
|
"token": ""
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"fertig": true,
|
||||||
|
"fehler": true,
|
||||||
|
"offline": true,
|
||||||
|
"queue": true,
|
||||||
|
"spule": true,
|
||||||
|
"ablauf": false,
|
||||||
|
"start": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/etc/grotti/config.json
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
_pg() {
|
||||||
|
if command -v runuser >/dev/null 2>&1; then
|
||||||
|
runuser -u postgres -- "$@"
|
||||||
|
else
|
||||||
|
su -s /bin/sh postgres -c "$*"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
configure)
|
||||||
|
if ! getent passwd grotti >/dev/null; then
|
||||||
|
adduser --system --group --home /var/lib/grotti --no-create-home \
|
||||||
|
--gecos "Grotti farm manager" grotti
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p /var/lib/grotti/data/eingang /var/lib/grotti/slice /etc/grotti
|
||||||
|
chown -R grotti:grotti /var/lib/grotti
|
||||||
|
chmod 750 /var/lib/grotti
|
||||||
|
|
||||||
|
if [ ! -f /etc/grotti/config.json ]; then
|
||||||
|
cp /usr/share/grotti/config.pi.json /etc/grotti/config.json
|
||||||
|
fi
|
||||||
|
chmod 640 /etc/grotti/config.json
|
||||||
|
chown root:grotti /etc/grotti/config.json
|
||||||
|
|
||||||
|
if getent passwd postgres >/dev/null 2>&1; then
|
||||||
|
_pg psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='grotti'" | grep -q 1 \
|
||||||
|
|| _pg createuser -D -R -S grotti || true
|
||||||
|
_pg psql -tAc "SELECT 1 FROM pg_database WHERE datname='grotti'" | grep -q 1 \
|
||||||
|
|| _pg createdb -O grotti grotti || true
|
||||||
|
_pg psql -c "GRANT ALL PRIVILEGES ON DATABASE grotti TO grotti;" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
systemctl enable grotti.service || true
|
||||||
|
systemctl restart grotti.service || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
purge)
|
||||||
|
rm -f /etc/grotti/config.json
|
||||||
|
rmdir /etc/grotti 2>/dev/null || true
|
||||||
|
# Daten und DB bleiben absichtlich (Backup). Manuell:
|
||||||
|
# sudo rm -rf /var/lib/grotti
|
||||||
|
# sudo -u postgres dropdb grotti; sudo -u postgres dropuser grotti
|
||||||
|
;;
|
||||||
|
remove)
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
remove|upgrade|deconfigure)
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl stop grotti.service 2>/dev/null || true
|
||||||
|
systemctl disable grotti.service 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||