8 Commits
Author SHA1 Message Date
Dominic Gundel 5166ed6530 Grotti 3.5.1: MCP-Anweisung für Clients, Queue-Strip nur bei Ablauf, Start wartet auf printing. 2026-10-20 21:39:22 +02:00
Dominic Gundel f034c5e626 Wand: Queue-Strip nur bei Ablauf; Start wartet auf printing statt Falsch-Fertig. 2026-10-19 11:35:59 +02:00
Dominic Gundel f1f79c2ce2 Grotti 3.5.0: Pagination, Logging, Deb-Paket, Makro-Doku, PLA-Match-Fix. 2026-10-19 11:14:24 +02:00
Dominic Gundel 4ffe1dcfc6 README: Banner, Badges und Community-Screenshots oben. 2026-10-18 22:06:56 +02:00
Dominic Gundel dd0e7ebc6b Grotti 3.4.0: Dateien umbenennen, Bedarf/PLA-Match, Pause- und gklib-Fixes. 2026-10-18 21:36:12 +02:00
Dominic Gundel 67a89d1794 Grotti 3.3.0: Projekte mit Ordner, Datei-Löschen/Verschieben, Material-Archiv, UI-Polish. 2026-10-18 20:10:32 +02:00
Dominic Gundel 4ddffbbeb1 Grotti 3.2.0: Jetzt-Liste, Filament-Pause, Strom-Torte, UI-Polish.
Jetzt/Topbar und Material-Routing, Pause bei Filament-Ende, Druckbericht mit Stromanteilen, Design- und Kamera-Stream-Polish; Bedarf-Alert-Spam und Platte-belegt-Bugs behoben.
2026-10-18 14:58:32 +02:00
Dominic Gundel 6e59df22e5 Grotti 3.1.0: Bedarf-UI, Zeitfenster, HT/NT, Theme, Chrome-Fixes.
Bedarf in Sidebar/Material, Drucker-Zeitfenster und Tarif in Einstellungen, helles Theme; Legacy-Token und /api/drucker entfernt; SSE/SW-Fixes gegen hängende Navigation in Chrome.
2026-10-18 02:20:12 +02:00
115 changed files with 9111 additions and 1430 deletions

No files matched your search

+1 -1
View File
@@ -9,7 +9,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`.
- Nutzer-Anleitung: `docs/Anleitung-*.md` unter `/doku`. Technik/Debug: `docs/internal/` — nicht in der App. - 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. - 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`.
+5
View File
@@ -10,3 +10,8 @@ slice/*
__pycache__/ __pycache__/
*.pyc *.pyc
_tmp_* _tmp_*
%s
# gebaute .deb (Skript legt sie hier ab)
packaging/dist/*
!packaging/dist/.gitkeep
+1 -1
View File
@@ -4,7 +4,7 @@ Grotti ist ein eigenständiges Repo. Alles, was das Tool betrifft, bleibt hier.
- Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits. - 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`.
- Nutzer-Anleitung: `docs/Anleitung-*.md`, gerendert unter `/doku`. Technik und Debug: `docs/internal/` — erscheint **nicht** in der App (inkl. MCP.md, Anycubic-Stack, …). - 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`.
+142
View File
@@ -4,6 +4,148 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
## [Unreleased] ## [Unreleased]
## [3.5.1] – 2026-10-20
### Geändert
- **MCP:** Server-Anweisung für Clients (Ablauf, confirm, Filament-Pause, Platten-Quittung, kein G-code), damit der Agent die Tools in der richtigen Reihenfolge nutzt.
- **Wand:** „Aus der Warteschlange“-Strip nur noch bei Ablauf (mit Schritten); einfacher Queue-Druck zeigt nur DRUCKT.
### Behoben
- **Queue zu früh „Fertig.“:** Nach Start bleibt Phase `starting`, bis Moonraker `printing` meldet — alter `complete`/`standby` schließt den Job nicht mehr.
## [3.5.0] – 2026-10-19
### Neu
- **Doku:** interne Makro-Referenz `docs/internal/Makros-Kobra.md` (Nicht-tun, Vanilla-Klipper-Alltag, ACE, LeviQ/Wipe für S1 und K3).
- **Pagination:** Dateien, Protokoll, Material, Aufträge, Warteschlange, Aktivität, Projekte — max. 40 Zeilen pro Seite (Zurück/Weiter).
- **Logging:** `log_level` in der Config; Kernpfade schreiben nach stdout (unter systemd → `journalctl -u grotti`).
- **Debian-Paket:** `packaging/build-deb.sh` baut `.deb` mit venv, systemd-Unit und PostgreSQL-`postinst` (Peer-Auth). Anleitung: `docs/Anleitung-Installation-Pi.md`.
### Geändert
- **Config:** `GROTTI_CONFIG` bzw. `/etc/grotti/config.json` für Paket-Install; sonst Repo-`config.json`.
- **README:** Banner, Badges, Feature-Übersicht und Community-Screenshots oben; Setup/API darunter; Pi-`.deb`-Hinweis.
### Behoben
- **Queue „braucht Eryone PLA Lite“ trotz PLA-Spule:** Basis-Profil (ohne Meta/Matte/Silk) matcht nach Typ; Slice-Hex-Farbe (`#26A69A`) blockiert Lagerfarbe „Grau“ nicht mehr.
- **Dateien Info-Popup:** Details-Popup wird nicht mehr vom Bibliothek-Scroll abgeschnitten; Position fixed mit Flip nach oben, besserer Kontrast.
## [3.4.0] – 2026-10-18
### Neu
- **Dateien:** Umbenennen in der Bibliothek (Stift-Icon; Endung bleibt erhalten).
### Behoben
- **Queue „druckt schon“ bei Folgejobs:** Einfach-Druck bleibt `running` bis Moonraker fertig — nicht mehr sofort `done` nach Start (sonst startete der nächste Job zu früh).
- **Bedarf „443 g fehlen“ trotz voller Spulen:** Slice-Farbe `#26A69A` matchte nicht Lagerfarbe „Grau“ → Rest 0. Bei Hex-Farbe zählt jetzt der Rest gleicher Sorte; archivierte Spulen nicht.
- **Falsches „Material nachlegen“ bei manueller Pause:** Anycubic meldet `active_filament.empty=true` auch ohne Runout (`tool`/`gate` = −1). Heuristik nur noch bei Meldung/`reason_for_pause`/10107, aktivem Tool + differenziertem `gate_status`, oder G-code-Store.
- **gklib 10111 nach Einfarbig-Upload:** Orca-Zeile `; filament_colour_type` wird beim Senden entfernt (nur Einfarbig; Multicolor unangetastet).
- **Protokoll „druckt“ nach Offline:** Live-Läufe werden bei anhaltendem Offline als `interrupted` geschlossen (z. B. gklib-Shutdown).
- **Sorte „Sunlu PLA“ vs. Eryone PLA:** Basis-Profil ohne Meta/Matte/Silk matcht nach Material-Typ (Marke egal); Varianten bleiben streng.
## [3.3.0] – 2026-10-18
### Neu
- **Projekte** `/projekte`: Arbeitsmappe mit Ordner unter dem Slice-Pfad (`projects.folder`); G-code-Zuordnung per Sync; Upload weiterer Dateien (STEP/STL/PDF/…); Nav unter Verwalten.
- **Dateien:** Uploads in Slice-Ordner verschieben (werden Watch); Löschen für Upload und Watch (nur unter Slice/`eingang`); kompaktere Bibliothek-Filter.
- **Material:** Filter Sorte/Farbe; leere/archivierte Spulen ausgeblendet (Filter **Archiv**); Buttons **Leer**, **Archivieren**, **Wiederherstellen** (`archived_at`).
- **Wand:** kleines Kamera-Snapshot-Thumb auf jeder Karte (nie Stream); Menüpunkt Kameras entfernt, `/kameras` → Wand.
### Geändert
- **Technik-Cockpit:** kompakte Fact-Chips; Steuerung in Gruppen Heizung / Druck / ACE-Plätze.
- **Protokoll:** kurze Dateinamen, festere Spaltenbreiten.
- **Statistik:** kompakte Filter inkl. Farbe, Auto-Laden, KPI Primär/Sekundär.
- **Orca-Login:** Session 30 Tage (überlebt App-Fenster schließen); kein zweites Chrome mit demselben Profil (sonst erneutes Anmelden).
- **Druckbericht:** Kosten-Torte und Tabelle inkl. Filament (nicht nur Strom); Summe = Material + Energie.
- **Dateien:** Starten / Einreihen / Ablauf mit einem Klick (kein Zwei-Klick mehr). API weiter mit `confirm: true`.
- **Senden:** Zwei Start-Modi statt drei — **Dieser Drucker** (frei → sofort, sonst Warteschlange) und **Nächster passender** (Grotti wählt nach Sorte).
- **Material-Routing:** Match auf Katalog-Sorte aus Orca-`filament_settings_id` (PLA Meta ≠ Matte ≠ Silk); falsche Variante wartet mit Grund. Farbe bleibt soft. Bei Auto-Wahl baut Grotti die ACE-Map aus den Spulen am Claim-Drucker. Bestehende Dateien: **Dateien neu lesen**.
- **Wand:** Bei Filament-Pause Hinweis, die neue Rolle unter Material dem Drucker zuzuweisen — sonst zählt der Verbrauch weiter zur alten Spule.
- Anleitung Material/ACE: Rollenwechsel während Pause klargestellt.
### Behoben
- **Orca/Anycubic öffnet zwei Browserfenster:** Nachbearbeitung läuft oft 2× — Debounce (~25 s) öffnet nur noch eines. Deep-Link lädt fehlende `paint_index` nach (ACE zeigt sonst fälschlich T0/T1).
- **Dateien-Tab hängt nach Orca:** 3D-Pfad erst nach ACE/Senden (Idle); Debounce ignoriert Anycubic-Zeitstempel im Dateinamen; erneuter Export aktualisiert den offenen Tab statt ein zweites Fenster.
- **Orca/Anycubic → Desktop-Fenster:** `orca_senden` öffnet Grotti als Chrome/Edge-App (`browser: app`). Anycubic-Dialog nur vermeiden durch **G-code exportieren**, nicht Remote Print / Aufgabe starten.
- Anleitung Orca: Grotti unter **Gerät** per Device-UI-URL (`http://127.0.0.1:8770/`); Upload-zum-Host weiter meiden.
- **ACE-Map mit Lücken-T (Bambu/Anycubic):** UI behält Slicer-`Tn` (z. B. T1+T10). Beim Senden verdichtet Grotti auf T0…n und schreibt passende `.acm` — sonst gklib **10011700 invalid filament id** bzw. Hang bei Map-Mismatch. Senden prüft Map gegen T-Befehle. Bestehende Dateien: **Dateien neu lesen**.
- **10011700 trotz dichter UI-Map:** Verdichtung hängt am G-code (`T1`/`T10`), nicht daran, ob die Map schon 0/1 zeigt — sonst blieb T10 auf dem Drucker. Grotti neu starten, Clip erneut senden.
- **Abräumen ließ Lauf offen:** Urteil und Platte waren quittiert, der Live-Lauf blieb `in_progress` (Moonraker-History oft ohne Endzeit). Abräumen und Watcher (`printing`→`complete`) schließen den Lauf jetzt als `completed` inkl. Materialabzug.
- **Rollenwechsel während Pause:** Einlegen hängt Abschnitte/Ereignisse auch an offene History-Jobs (nicht nur `live:`). Send-Spule und Lauf-Spule werden mitgezogen; Abzug bevorzugt die Rolle am Drucker statt eines veralteten Send-Hints.
## [3.2.0] – 2026-10-18
### Neu
- **Jetzt-Liste** / Topbar-Chips / Mobile-Tab `/jetzt`: Platte räumen, Queue-Fehler, Bedarf, Wartung, Aufträge, Material-Wartegründe; Farm-ETA; Filament-Pause → „Material nachlegen“.
- **`GET /api/v1/jetzt`**, **`GET /api/v1/prognose`**, **`GET /api/v1/suche`** (Ctrl+K).
- **Filament-Pause:** Pause + ACE/`mmu`/Runout-Hinweis → Event `spule`, Jetzt-Eintrag, Alert.
- **Druckbericht:** Strom-Aufschlüsselung (Grundlast / Düse / Bett / Trockner) mit Tortendiagramm.
- **Material-Routing:** „Nächster freier“ bevorzugt passende Rolle/Farbe/Rest; sonst Wartegrund in der Queue.
- **Termine** (`due_at`) sortieren die Warteschlange vor der Priorität.
- **Auto-Retry** (einmal, Einstellung) bei Queue-/Ablauf-Fehler.
- **Gleich nochmal drucken** im Abräumen-Dialog.
- **Tagesbericht** 7 Uhr (Telegram/ntfy) und Push bei Wartung fällig, Bedarf, Auftrag überfällig.
- **Kosten pro Teil** und Stück-Yield in Bericht, Auftrag und Statistik.
- **Undo-Toast** beim Entfernen aus der Warteschlange; **Ctrl+K**-Suche.
- **Design-Polish:** Lucide-Icons (`icons.svg`), Schrift Inter, Tokens/Komponenten; Topbar und Sidebar-Gruppen.
- **Kamera-Stream:** Snapshot oder MJPEG in Einstellungen und auf der Kameras-Seite.
- **Community-Screenshots** unter `docs/community/`.
- Einstellungen: Abschnitts-Navigation, Autosave, Drucker als Karten mit Dialog.
- Cockpit: Alltag oben, **Technik**-Fold (Steuerung, Verlauf, 3D, Bettnetz) standardmäßig zu.
### Geändert
- **Live-Status** in der Topbar (Chips + Popover); Mobile-Tab **Jetzt**.
- **PA12-CF** in Grotti als PA12-CF; am ACE als PA6-CF (ACE kennt den Typ nicht).
- Falsche Sorte warnt in Dateien, hält die Queue nicht an; zu wenig Rest blockiert weiter.
- Alert-Hygiene: `spule`/`start`/`auth` quiet; Sidebar-Label „Meldungen“.
- Status als Chip; Einstellungen-Nav mit Icons; Autosave; Light-Theme; `tabular-nums`.
- Begriffe: Queue-Eintrag = Druck; Empty-States mit Handlung; Primär-/Danger-Buttons.
- Cool-Park blockiert den Queue-Tick nicht mehr (Worker).
- Kunden und Positionen bearbeiten/löschen unter Aufträge.
- `projekte.html` / `ideen.html` entfernt (Redirect bleibt).
### Behoben
- **Bedarf/Wartung/Auftrag-Spam:** Ops-Alerts Dedup-Art und Event-`kind` stimmten nicht überein (jede Minute derselbe Hinweis).
- **Platte belegt nach Neustart:** Watcher setzte bereits Quittiertes wieder auf `needs_clear`.
- **„Pausiert“ nach Abbruch/Offline:** Live-Lauf blieb offen bei Offline→standby.
- **Regal-Modus:** Sidebar-Menü unter Desktop-Breite ausgeblendet.
## [3.1.0] – 2026-10-18
### Neu
- **Bedarf in der UI:** Sidebar-Warnung, Karte unter Material, Chip in der Warteschlange.
- **Start-Zeitfenster** je Drucker in den Einstellungen (Queue nur im Fenster).
- **HT/NT-Tarif** in den Energie-Einstellungen.
- **ntfy-Test** wie bei Telegram.
- **Helles Theme** (Dunkel / Hell / System) pro Browser.
- Statistik: Balkendiagramme Druckzeit/Verbrauch, Empty-State ohne Daten.
- Wartung: KPIs, Empty-State, Reset mit Bestätigung (`armButton`).
### Geändert
- Cockpit und Warteschlange nutzen SSE/`createPoller` statt hartem 5‑s-Intervall.
- Shared-`config.token` wird nicht mehr als Auth akzeptiert — nur noch Session und `grt_…`-Tokens.
- Legacy-Alias `GET /api/drucker` entfernt (nur `/api/v1/drucker`).
### Behoben
- SSE unter waitress: Hop-by-Hop-Header `Connection` entfernt (sonst Fehler auf `/api/v1/stream`).
- Bedarf: Material/Farbe aus `files.filaments` statt nicht existierender Spalten.
- Chrome: Service-Worker lieferte bei Fehlern die Startseite statt der angeklickten Route; „Mehr“-Dialog blockierte die Navigation; SSE und Poller liefen parallel und stauten Verbindungen.
## [3.0.0] – 2026-10-18 ## [3.0.0] – 2026-10-18
Major: neue Tabellen (Aufträge, Wartung), Projekte/Ideen → Aufträge, Navigation umgebaut, Dev-Server → waitress, Schema über Migrationen. Major: neue Tabellen (Aufträge, Wartung), Projekte/Ideen → Aufträge, Navigation umgebaut, Dev-Server → waitress, Schema über Migrationen.
+112 -77
View File
@@ -1,76 +1,121 @@
# Grotti <p align="center">
<img src="docs/brand/readme-banner.png" alt="Grotti" width="720">
</p>
Steuert die 3D-Drucker im Haus von einer Weboberfläche aus: Live-Zustand, Dateien senden, Warteschlange, Abläufe für den Kobra 3 (drucken, abkühlen, abschieben, prüfen), Material, Projekte und ein Protokoll, was wann passiert ist. <p align="center">
<strong>Farm-Manager für Moonraker-Drucker</strong><br>
Eine Oberfläche für die ganze Farm: Wand, Warteschlange, Material, Abläufe und Protokoll.<br>
<em>Benannt nach der Mühle Grotti — sie mahlt ohne Pause, was man ihr aufträgt.</em>
</p>
Der Name kommt von der Mühle Grotti: sie mahlt ohne Pause, was man ihr aufträgt. <p align="center">
<a href="https://git.gundel.dev/dg/grotti"><img src="https://img.shields.io/badge/Version-3.5.1-e85d04?style=flat-square" alt="Version 3.5.1"></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">
&nbsp;
<img src="docs/community/02-warteschlange.png" alt="Warteschlange" width="440">
</p>
<p align="center">
<img src="docs/community/09-dateien.png" alt="Dateien" width="440">
&nbsp;
<img src="docs/community/03-material.png" alt="Material" width="440">
</p>
<p align="center">
<img src="docs/community/05-statistik.png" alt="Statistik" width="440">
&nbsp;
<img src="docs/community/07-cockpit.png" alt="Cockpit" width="440">
</p>
Weitere Bilder und Post-Text: [`docs/community/`](docs/community/README.md).
## Schnellstart
**Voraussetzungen:** Python 3.11, PostgreSQL (leere DB `grotti`), Drucker mit Moonraker im LAN.
```powershell ```powershell
cd D:\Projekte\Grotti cd D:\Projekte\Grotti
python -m venv .venv python -m venv .venv
.\.venv\Scripts\pip install -r requirements.txt .\.venv\Scripts\pip install -r requirements.txt
copy config.example.json config.json copy config.example.json config.json
```
`config.json` anpassen:
| Schlüssel | Bedeutung |
| --- | --- |
| `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. |
| `database_url` | PostgreSQL-Verbindung. |
| `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. |
| `watch_dir` | Slice-Ordner (unter Einstellungen setzen). Leer = nur Upload. |
| `token` | Wenn gesetzt, braucht die API `Authorization: Bearer <token>`. |
| `fqdn` | Öffentliche Basis-URL für Spulen-Etiketten-QR. Leer = Request-Host. |
| `energy` | Watt und Strompreis für die Kosten im Protokoll. |
| `telegram`, `alerts` | Bot und welche Ereignisse als Nachricht kommen. |
| `auto_queue` | Wenn `false`, startet die Warteschlange nichts Neues. |
Die Drucker stehen beim ersten Start in `drucker.json` und landen dann in der Datenbank. Danach werden sie unter Einstellungen gepflegt.
Schema-Änderungen laufen beim Start über `grotti/migrations/` (Tabelle `schema_version`). Vor Major-Updates die Datenbank sichern (`pg_dump`).
```json
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
```
Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen.
## Starten
```powershell
.\.venv\Scripts\python.exe server.py .\.venv\Scripts\python.exe server.py
``` ```
Dann <http://127.0.0.1:8770/> öffnen. Bedienungsanleitung mit Screenshots in der Oberfläche unter **Doku** (Kapitel `Anleitung-*`), Technik-Notizen darunter. Dann <http://127.0.0.1:8770/>. Bedienung unter **Doku** in der App (`docs/Anleitung-*.md`).
`config.json` und `data/` sind lokal und nicht im Git. ### Raspberry Pi (`.deb`)
Auf dem Pi (arm64 / Bookworm) bauen und installieren — PostgreSQL und systemd-Dienst werden mit eingerichtet:
```bash
bash packaging/build-deb.sh
sudo apt install ./packaging/dist/grotti_*_arm64.deb
journalctl -u grotti -f
```
Details: [docs/Anleitung-Installation-Pi.md](docs/Anleitung-Installation-Pi.md). Öffentliches apt-Repo folgt später.
`config.json` und `data/` bleiben lokal (nicht im Git). Drucker zuerst in `drucker.json`, danach unter Einstellungen.
| Schlüssel | Bedeutung |
| --- | --- |
| `host`, `port` | Oberfläche (Standard `127.0.0.1:8770`) |
| `database_url` | PostgreSQL |
| `watch_dir` | Slice-Ordner (oder leer = nur Upload) |
| `energy` / `telegram` / `alerts` | Kosten und Benachrichtigungen |
Schema-Migrationen laufen beim Start (`grotti/migrations/`). Vor Major-Updates: `pg_dump`.
## Ordner ## Ordner
| Pfad | Inhalt | | Pfad | Inhalt |
| --- | --- | | --- | --- |
| `server.py` | Start, Datenbank vorbereiten, Hintergrund-Threads | | `server.py` | Start, DB, Hintergrund-Threads |
| `grotti/` | Kern: Moonraker, Warteschlange (`print_queue.py`), Senden (`send_queue.py`), Abräumen (`eject.py`), Zustandswächter (`watcher.py`), Ereignisse und Telegram (`notify.py`), Datenbank (`store.py`, `schema.sql`) | | `grotti/` | Moonraker, Queue, Senden, Abräumen, Watcher, Store |
| `web/` | Flask-App (`app.py`), Seiten (`templates/`), CSS und JS (`static/`) | | `web/` | Flask, Templates, CSS/JS, Branding |
| `docs/` | Bedienungsanleitung (`Anleitung-*.md`) unter `/doku`; Technik unter `docs/internal/` (nicht in der App) | | `docs/` | Anleitungen (`/doku`), Community-Screens, Technik unter `internal/` |
| `web/static/doku/` | Screenshots für die Anleitung | | `CHANGELOG.md` | Versionen |
| `CHANGELOG.md` | Änderungen je Version |
## API ## API
Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bearer grt_…` (API-Token unter Einstellungen). Optional noch Shared-`token` in der Config (Migrationsbrücke). Alles unter `/api/v1`, JSON. Auth: Session oder `Authorization: Bearer grt_…`.
| Methode und Pfad | Zweck | | Methode und Pfad | Zweck |
| --- | --- | | --- | --- |
@@ -78,43 +123,33 @@ Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bear
| `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens | | `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens |
| `GET /version` | Version | | `GET /version` | Version |
| `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe | | `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe |
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag | | `GET /drucker?live=1` | Drucker mit Live-Zustand |
| `POST /drucker` · `PATCH`/`DELETE /drucker/<id>` | Drucker anlegen/ändern (Admin) |
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen | | `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE | | `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE |
| `POST /drucker/<host>/abraeumen` | Platte geräumt (`confirm`) | | `POST /drucker/<host>/abraeumen` | Platte geräumt (`confirm`) |
| `GET /kamera/<host>`, `/vorschau/<host>` | Snapshot / Job-Thumbnail | | `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek |
| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek, Upload, Metadaten | | `POST /senden`, `GET`/`POST /druck-queue` | Senden und Warteschlange (`confirm`) |
| `POST /dateien/neu-lesen`, `/dateien/ordner`, `/dateien/<id>/verschieben` | Watch-Sync, Ordner, Verschieben | | `GET /spulen`, `POST /spulen/<id>/einlegen` | Material |
| `POST /senden`, `GET /sende-auftraege` | Sofort senden (`confirm`), Fortschritt | | `GET /laeufe`, `/statistik`, `/ereignisse` | Protokoll und Aktivität |
| `GET`/`POST /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange (`confirm` beim Einreihen) |
| `GET /laeuft`, `/aufgaben` | Direktstarts und Platte-belegt |
| `POST /ablauf`, `/schieben-testen` | Ablauf Kobra 3 / Schieben-Test |
| `GET /spulen`, `POST /spulen`, `POST /spulen/<id>/einlegen` | Spulen und Einlegen |
| `GET /spulen/<id>/akte`, `/spulen/by-code` | Spulenakte, Scan `S-…` |
| `GET`/`POST /materialien`, `/farben` | Sorten und Farbpalette |
| `GET /laeufe`, `/laeufe/aktuell`, `/laeufe/<id>` | Protokoll und Druckbericht |
| `GET /statistik`, `/statistik.csv` | Auslastung und CSV |
| `GET`/`POST /projekte`, `/ideen` | Projekte und Ideen-Backlog |
| `GET /ereignisse`, `POST /ereignisse/gelesen` | Aktivität und Alerts |
| `POST /kalkulation` | Vorkalkulation Material+Strom |
MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN` (siehe `docs/internal/MCP.md`). MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` / `GROTTI_TOKEN` → `docs/internal/MCP.md`.
## Sicherheit ## Sicherheit
- Login Pflicht (Setup legt den ersten Admin an). Rollen: Admin, Operator. - Login Pflicht. Rollen: Admin, Operator.
- Druckstarts brauchen `confirm: true`. Die Oberfläche fragt per Zwei-Klick. - Druckstarts brauchen `confirm: true` (Zwei-Klick in der UI).
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt. - Keine freie G-code-Konsole; kein `FIRMWARE_RESTART` über Grotti.
- Für Internet: Reverse Proxy mit TLS; nicht den Entwickler-Server öffentlich freigeben. - Nach außen: Reverse Proxy mit TLS.
## Versionen und Changelog ## Versionen
- Versionierung nach [SemVer](https://semver.org/lang/de/): Patch für Fehlerbehebung, Minor für neue Funktion, Major für Bruch an API oder Datenbankschema. - SemVer in `grotti/version.py`, Einträge in [CHANGELOG.md](CHANGELOG.md).
- Jede Änderung kommt in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben). - Release: `[Unreleased]` → `[x.y.z] – Datum`, Tag `vX.Y.Z`.
- Beim Release: Version in `grotti/version.py` setzen, `[Unreleased]` in `[x.y.z] – Datum` umbenennen, Git-Tag `vX.Y.Z`. - CSS/JS-Cache: `?v=` in `layout.html` mitziehen.
- Ändert sich `web/static/grotti.css` oder `api.js`, in `layout.html` den `?v=` hochzählen.
## Repo ---
Eigenes Repository: <https://git.gundel.dev/dg/grotti>. Doku zum Tool gehört nach `docs/`. <p align="center">
<img src="web/static/brand/logo.png" alt="" width="72"><br>
<sub>Repo: <a href="https://git.gundel.dev/dg/grotti">git.gundel.dev/dg/grotti</a></sub>
</p>
+9 -1
View File
@@ -7,7 +7,9 @@
"token": "", "token": "",
"secret_key": "", "secret_key": "",
"fqdn": "", "fqdn": "",
"log_level": "INFO",
"auto_queue": true, "auto_queue": true,
"auto_retry": true,
"oauth": { "oauth": {
"enabled": false, "enabled": false,
"issuer": "", "issuer": "",
@@ -23,7 +25,13 @@
"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,
+18 -4
View File
@@ -1,6 +1,6 @@
# ACE und Spulen # ACE und Spulen
Stand **17.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker. Stand **18.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
![ACE-Zuordnung beim Senden](../web/static/doku/ace-zuordnung.png) ![ACE-Zuordnung beim Senden](../web/static/doku/ace-zuordnung.png)
@@ -12,27 +12,41 @@ Stand **17.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Date
Es gibt **kein freies Spulen-Dropdown** beim Start: Grotti nimmt die Rolle(n), die am Ziel-Drucker liegen. Material-Mismatch oder zu wenig Rest → Warnung vor dem Start. Es gibt **kein freies Spulen-Dropdown** beim Start: Grotti nimmt die Rolle(n), die am Ziel-Drucker liegen. Material-Mismatch oder zu wenig Rest → Warnung vor dem Start.
### Rollenwechsel während Pause
Während Druck oder Pause die neue Spule unter Material dem Drucker zuweisen und speichern. Grotti schließt den Abschnitt der alten Rolle und öffnet einen neuen — im Druckbericht erscheinen dann zwei Spulenzeilen und Ereignisse. Nur Filament am Gerät laden ohne Zuweisung: Abzug und Protokoll bleiben bei der alten Rolle.
Wenn Moonraker den Druck nach Filament-Pause als neuen History-Job führt, übernimmt Grotti die neu zugewiesene Rolle für den fortgesetzten Teil (Send- und Lauf-Spule werden beim Einlegen aktualisiert).
Etiketten mit QR: [Material und mehr](Anleitung-Material.md). Etiketten mit QR: [Material und mehr](Anleitung-Material.md).
## ACE-Map beim Senden ## ACE-Map beim Senden
Unter **Dateien → Optionen → ACE-Zuordnung**: Unter **Dateien → Optionen → ACE-Zuordnung**:
- Jede Farbe aus der Datei (`paint_index`) einem **ACE-Platz** zuordnen - Jede Farbe aus der Datei (`Tn` / Slicer-`paint_index`) einem **ACE-Platz** zuordnen
- Liegt unter Material eine Spule auf dem Platz, zeigt die Liste Name, Farbe und Restgramm mit - Bei Bambu-/Anycubic-Dateien sind die Nummern oft **nicht 0–3** (z. B. T1 und T10 bei 16 Slots) — Grotti zeigt `T…`, verdichtet beim Senden aber auf T0…n (sonst Fehler **invalid filament id**)
- Liegt unter Material eine Spule auf dem Platz, zeigt die Liste Name, Farbe und Restgramm mit — Vorauswahl bevorzugt die **Katalog-Sorte** aus dem Orca-Profil (Meta/Matte …)
- Ohne passende Map schreibt der Drucker oft **„cannot parse the file“** und startet nicht - Ohne passende Map schreibt der Drucker oft **„cannot parse the file“** und startet nicht
- Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit - Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit
Bei **Nächster passender** (kein Drucker gewählt) baut Grotti die Map erst beim Claim aus den Spulen am gewählten Drucker. Passt die Sorte nicht → Wartegrund in der Queue.
Externe Spule / keine ACE-Slots: keine `.acm` nötig. Externe Spule / keine ACE-Slots: keine `.acm` nötig.
Nach einem Grotti-Update mit ACE-Farben: unter Dateien **neu lesen**, damit alte Einträge die echten `Tn` bekommen.
## Typische Fehler ## Typische Fehler
| Symptom | Check | | Symptom | Check |
| --- | --- | | --- | --- |
| „cannot parse the file“ | ACE-Zuordnung vollständig? Farben richtig? | | „cannot parse the file“ | ACE-Zuordnung vollständig? Farben richtig? |
| Material-Warnung | Eingelegte Sorte ≠ Datei — Spule wechseln oder Slice prüfen | | „T… fehlt in der Map“ | Datei neu lesen, Zuordnung erneut setzen (Slicer-T ≠ ACE-Platz) |
| **invalid filament id** (10011700) | Alte Grotti-Version ohne T-Verdichtung, oder Map nicht neu gesetzt. Grotti neu starten, Datei neu lesen, nochmal senden. |
| Material-Warnung | Eingelegte Sorte ≠ Datei. Der Start geht trotzdem. PA12-CF steht in Grotti so, an den ACE geht PA6-CF. |
| Rest zu gering | Andere Spule oder leichteres Teil | | Rest zu gering | Andere Spule oder leichteres Teil |
| ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten | | ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten |
| Drucker hängt kurz nach Multi-Color-Start | Oft falsche Map (T10 gemappt als Platz 0). Neu lesen + Zuordnung. |
## Weiter ## Weiter
+1
View File
@@ -14,6 +14,7 @@ Im Auftrag **Lieferschein** öffnen: Positionen, Chargen `D-…`, druckbar. Dana
## Weiter ## Weiter
- [Projekte](Anleitung-Projekte.md) (Arbeitsmappe / Konstruktion — nicht dasselbe wie Auftrag)
- [Warteschlange](Anleitung-Warteschlange.md) - [Warteschlange](Anleitung-Warteschlange.md)
- [Material](Anleitung-Material.md) - [Material](Anleitung-Material.md)
- [Protokoll / Bericht](Anleitung-Oberflaeche.md) - [Protokoll / Bericht](Anleitung-Oberflaeche.md)
+13 -9
View File
@@ -1,31 +1,34 @@
# Dateien senden # Dateien senden
Stand **17.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts. Stand **18.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
![Dateien: Bibliothek und Senden](../web/static/doku/dateien-flow.png) ![Dateien: Bibliothek und Senden](../web/static/doku/dateien-flow.png)
## Bibliothek ## Bibliothek
- Suche nach Name, Material und Projekt - Kompakte Filterleiste: Suche, Maschine, Projekt, Material, Ordner, Sortierung
- Filter: Maschine, Projekt, Material, Ordner (Unterordner unter dem Slice-Ordner; Uploads als „Upload“) - Filter: Maschine, Projekt, Material, Ordner (Unterordner unter dem Slice-Ordner; Uploads als „Upload“)
- Sortierung: Neueste, Name, Material, Ordner, Druckzeit, Gewicht — bei Material/Ordner mit Gruppenköpfen - Sortierung: Neueste, Name, Material, Ordner, Druckzeit, Gewicht — bei Material/Ordner mit Gruppenköpfen
- Auswahl der Filter bleibt im Browser merken - Auswahl der Filter bleibt im Browser merken
- Vorschaubild, Material, Zeit, Gewicht, Schichthöhe - Vorschaubild, Material, Zeit, Gewicht, Schichthöhe
- Info-Popup mit Slice-Metadaten (Kopf und Footer) - Info-Popup mit Slice-Metadaten (Kopf und Footer)
- **Hochladen** per Knopf oder Drag & Drop - **Hochladen** per Knopf oder Drag & Drop
- Mit gesetztem Slice-Ordner ([Einstellungen](Anleitung-Einstellungen.md)): **Ordner anlegen**, Watch-Dateien per Auswahl **verschieben** (Datei bleibt in der Bibliothek dieselbe, inkl. Projekt) - Mit gesetztem Slice-Ordner ([Einstellungen](Anleitung-Einstellungen.md)): **Ordner anlegen**; Watch- **und** Upload-Dateien per Auswahl **verschieben** (Upload wird zur Watch-Datei im Slice-Ordner)
- **Umbenennen** (Stift-Icon) — Datei auf der Platte und in der Bibliothek; Endung `.gcode`/`.3mf` bleibt, wenn du sie weglässt
- **Löschen** (Papierkorb, Zwei-Klick) für jede Bibliotheksdatei — entfernt Eintrag und Datei auf der Platte (nur unter Slice-Ordner oder `eingang`)
- **Ordner neu lesen**, wenn unter Einstellungen ein Slice-Ordner gesetzt ist - **Ordner neu lesen**, wenn unter Einstellungen ein Slice-Ordner gesetzt ist
Ohne Slice-Ordner geht nur manueller Upload — das reicht für den Alltag. Uploads liegen flach unter „Upload“ und lassen sich in der Bibliothek nicht in Slice-Ordner schieben. Ohne Slice-Ordner geht nur manueller Upload; Verschieben in Ordner braucht den Slice-Pfad.
Projekte mit eigenem Ordner: [Projekte](Anleitung-Projekte.md).
## Senden: drei Schritte ## Senden: drei Schritte
1. **Datei** wählen (Klick in der Liste). 1. **Datei** wählen (Klick in der Liste).
2. **Drucker** wählen — Kacheln zeigen Live-Zustand und eingelegte Rolle. Passt die Maschine nicht, ist die Kachel blass. 2. **Drucker** wählen — Kacheln zeigen Live-Zustand und eingelegte Rolle. Passt die Maschine nicht, ist die Kachel blass.
3. **Wann:** 3. **Start:**
- **Jetzt** — sofort senden und starten (Zwei-Klick) - **Dieser Drucker** — frei und Platte klar → sofort starten; sonst Warteschlange für diesen Drucker
- **Wenn frei** — in die Warteschlange für diesen Drucker - **Nächster passender** — Grotti wählt den nächsten freien Drucker mit passender Sorte (z. B. Sunlu PLA Meta, nicht Matte)
- **Nächster freier** — erster passender freier Drucker
Auswahl merkt sich der Browser (`localStorage`). Auswahl merkt sich der Browser (`localStorage`).
@@ -44,7 +47,7 @@ Große Dateien brauchen oft **eine Minute**. In der Sidebar unter **Senden** sie
## Nach dem Start ## Nach dem Start
- Fortschritt auf der [Wand](Anleitung-Wand.md) und in der Sidebar **Läuft jetzt** - Fortschritt auf der [Wand](Anleitung-Wand.md) und in der Sidebar **Läuft jetzt**
- Bei „Wenn frei“: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md) - Bei Warteschlange / Nächster passender: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md)
- Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map) - Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map)
## Aus Orca ## Aus Orca
@@ -53,6 +56,7 @@ Nach dem G-code-Export kann Orca die Datei direkt nach Grotti laden und diese Se
## Weiter ## Weiter
- [Projekte](Anleitung-Projekte.md)
- [Orca an Grotti](Anleitung-Orca.md) - [Orca an Grotti](Anleitung-Orca.md)
- [ACE und Spulen](Anleitung-ACE.md) - [ACE und Spulen](Anleitung-ACE.md)
- [Warteschlange](Anleitung-Warteschlange.md) - [Warteschlange](Anleitung-Warteschlange.md)
+1 -1
View File
@@ -54,7 +54,7 @@ Dann <http://127.0.0.1:8770/> öffnen. Beim ersten Start legst du den Admin-Benu
## Sicherheit ## Sicherheit
- Druckstart nur mit Bestätigung (Zwei-Klick in der UI bzw. `confirm: true` in der API). - Druckstart über die API nur mit `confirm: true` (Dateien-UI: ein Klick).
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt. - Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
- Benutzer und API-Tokens unter Einstellungen. Für Zugriff aus dem Internet: Reverse Proxy mit TLS, nicht den Entwickler-Server öffentlich freigeben. - Benutzer und API-Tokens unter Einstellungen. Für Zugriff aus dem Internet: Reverse Proxy mit TLS, nicht den Entwickler-Server öffentlich freigeben.
- `config.json` und `data/` bleiben lokal, nicht im Git. - `config.json` und `data/` bleiben lokal, nicht im Git.
+9 -7
View File
@@ -1,17 +1,18 @@
# Einstellungen # Einstellungen
Stand **17.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie. Stand **18.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram/ntfy, Energie.
![Einstellungen: Drucker](../web/static/doku/einstellungen-drucker.png) ![Einstellungen: Drucker](../web/static/doku/einstellungen-drucker.png)
## Dieser Browser ## Dieser Browser
- **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche.md). - **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. - **Zeitzone** — Browser automatisch oder fest (z. B. Europe/Berlin) für Protokoll, Bericht und Aktivität.
## Automatik ## Automatik
Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen „wenn frei“-Starts. Laufende Abläufe und manuelle „Jetzt“-Starts bleiben möglich. Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen Queue-Starts. Laufende Abläufe und manuelle Sofort-Starts bleiben möglich.
## Slice-Ordner ## Slice-Ordner
@@ -19,29 +20,30 @@ Pfad zum Ordner, den die Bibliothek beobachtet (Export aus Orca/Anycubic, **Ordn
## Drucker ## Drucker
Tabelle: Name, Host, Familie, **gesperrt**. Pro Drucker: Name, Host, Familie, **gesperrt**, optional **Start-Zeitfenster**.
- Seed beim allerersten Start aus `drucker.json`, danach nur noch hier (Datenbank). - Seed beim allerersten Start aus `drucker.json`, danach nur noch hier (Datenbank).
- Familie aus dem Namen (Kobra 3 / S1) oder gesetzt. - Familie aus dem Namen (Kobra 3 / S1) oder gesetzt.
- **Gesperrt:** keine neuen Queue-Starts auf diesem Host. - **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 ## 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. Ö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 und Alerts ## Telegram, ntfy und Alerts
Bot-Token und Chat-ID. Pro Ereignisart an/aus (Fertig, Fehler, Offline, Queue, …). Test-Knopf zum Prüfen. Wichtige Ereignisse stehen auch in der Sidebar unter Alerts. Telegram: Bot-Token und Chat-ID, pro Ereignisart an/aus, Test-Knopf. **ntfy:** Server, Topic, optional Token — gleiche Meldungen, unabhängig schaltbar, ebenfalls mit Test. Ereignisse stehen auch in der Sidebar unter Alerts.
## Benutzer und API-Tokens ## Benutzer und API-Tokens
Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`). 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`. 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 ## 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. Beim Speichern warnt die Seite, wenn Düse oder Bett noch 0 sind. Optional Trockner/Ausschuss/Auslastung für die Kalkulation. **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 ## Weiter
+92
View File
@@ -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).
+11 -7
View File
@@ -1,6 +1,6 @@
# Material und mehr # Material und mehr
Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras, Aktivität und Ideen. Stand **18.10.2026**. Spulen, Etiketten, Aufträge, Protokoll, Statistik, Aktivität.
![Material: Spulen](../web/static/doku/material-spule.png) ![Material: Spulen](../web/static/doku/material-spule.png)
@@ -8,7 +8,9 @@ Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras
Zwei Reiter: **Spulen** und **Sorten**. Zwei Reiter: **Spulen** und **Sorten**.
**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest. Formular bei Auswahl oder „Neue Spule“. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in. 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. **Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
@@ -18,15 +20,17 @@ Pro Spule ein Etikett mit Chargennummer `S-…` und QR. Der Code öffnet Materia
Druck-Etiketten (`D-…`) kommen vom Druckbericht: ein Etikett für den ganzen Lauf oder je Objekt. Scan von `D-…` öffnet den Bericht. Druck-Etiketten (`D-…`) kommen vom Druckbericht: ein Etikett für den ganzen Lauf oder je Objekt. Scan von `D-…` öffnet den Bericht.
## Projekte ## Aufträge
Unter **Projekte** Aufträge mit Notiz und Kalkulation. Dateien lassen sich einem Projekt zuweisen (Bibliothek / Senden). Unter **Aufträge** Kunden, Positionen und Fortschritt. Dateien lassen sich einem Auftrag zuweisen; siehe [Aufträge](Anleitung-Auftraege.md).
## Protokoll und Statistik ## Protokoll und Statistik
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option. Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Kosten-Aufschlüsselung Filament + Strom mit Tortendiagramm, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
Unter **Statistik** Auslastung, Erfolg, Verbrauch und CSV-Export. Zeitraum per Knopf (Tag, Woche, Monat, Gestern, Letzte Woche, Letzter Monat) oder eigene Von/Bis-Daten. 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. 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.
@@ -34,7 +38,7 @@ Abzug nach dem Druck nimmt das Ist aus `filament_used` (Abbruch nur anteilig). M
## Kameras ## Kameras
`/kameras` — alle Snapshots nebeneinander mit Live-Zustand. Praktisch auf dem Regal-Tablet. Auf der **Druckerwand** erscheint je Drucker ein kleines Snapshot-Bild (ca. alle 3 s), wenn eine Webcam erreichbar ist. Klick öffnet das Cockpit — dort gilt weiter die Einstellung Snapshot/Stream unter Einstellungen → Dieser Browser. Die frühere Seite `/kameras` leitet auf die Wand um.
## Aktivität ## Aktivität
+24 -13
View File
@@ -1,38 +1,49 @@
# Oberfläche # Oberfläche
Stand **15.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop und Regal. Stand **18.10.2026**. Wie die App aufgebaut ist — Menü, Jetzt-Liste, Desktop und Regal.
## Navigation ## Navigation
Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Aktivität**, **Warteschlange**, **Protokoll**, **Statistik**, **Material**, **Projekte**. Darunter **Einstellungen**, **Ideen**, **Doku**. Fuß: Version (öffnet die Doku). 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 mit den wichtigsten Seiten. Das Seitenmenü lässt sich zur Icon-Rail einklappen. Unter ca. 760 px: untere Leiste (Drucker, Dateien, Warteschlange, **Jetzt**, Mehr) plus **Mehr**-Sheet (inkl. Aktivität und Abmelden). Das Seitenmenü lässt sich zur Icon-Rail einklappen.
![Seitenleiste mit Live-Boxen](../web/static/doku/sidebar-live.png) Shortcut **Ctrl+K** (Mac: Cmd+K) oder der Suche-Knopf in der Topbar: Suche über Drucker, Dateien, Spulen, Chargen und Aufträge.
## Live-Leiste ## Jetzt
Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Browser): 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 …**.
| Box | Inhalt | 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 |
| --- | --- | | --- | --- |
| **Läuft jetzt** | Eine Zeile pro Drucker (Name · % · Fertig-Uhr); Klick öffnet die Warteschlange, × bricht ab. Auch Direktstarts ohne Queue-Zeile. | | Platte räumen | Quittieren mit Urteil (optional „gleich nochmal“) |
| **Wartet** | Wartende Aufträge: Priorität, Abbrechen. Gestoppte Abläufe mit Fortsetzen. | | Material nachlegen | Druck pausiert, Filament vermutlich leer (ACE/Runout) — Spule zuweisen, dann an der Wand **Weiter** |
| **Aufgaben** | Platte belegt ohne laufenden Ablauf — **geräumt** (Zwei-Klick). | | Druck fehlgeschlagen | Fortsetzen / Ausblenden |
| **Senden** | Uploads mit MB-Fortschritt. | | Spule fehlt | Link zu Material / Bedarf |
| **Alerts** | Ungelesene wichtige Ereignisse; als gelesen markieren oder zur Aktivität. | | 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 ## Desktop und Regal
Unter **Einstellungen → Dieser Browser**: Unter **Einstellungen → Dieser Browser**:
- **Desktop** — normale Dichte - **Desktop** — normale Dichte
- **Regal (Touch)** — größere Bedienelemente, kompaktere Nav, Sidebar oft eingeklappt - **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 - **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`). 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 ## Weiter
- [Druckerwand](Anleitung-Wand.md) - [Druckerwand](Anleitung-Wand.md)
- [Dateien senden](Anleitung-Dateien.md) - [Dateien senden](Anleitung-Dateien.md)
- [Projekte](Anleitung-Projekte.md)
+38 -9
View File
@@ -1,6 +1,6 @@
# Orca an Grotti # Orca an Grotti
Stand **17.10.2026**. Nach dem Export in OrcaSlicer landet der G-code in der Bibliothek und die Seite [Dateien senden](Anleitung-Dateien.md) öffnet sich mit der Datei schon gewählt. Drucker, ACE-Platz und Einreihen wählst du dort wie gewohnt. Stand **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 ## Einmal einrichten
@@ -11,11 +11,13 @@ Stand **17.10.2026**. Nach dem Export in OrcaSlicer landet der G-code in der Bib
```json ```json
{ {
"url": "http://127.0.0.1:8770", "url": "http://127.0.0.1:8770",
"token": "grt_…" "token": "grt_…",
"browser": "app"
} }
``` ```
`url` ist die Adresse, unter der du Grotti im Browser öffnest. `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: 4. Im **Prozessprofil** (Druckqualität, z. B. „0.20mm Standard“): **Sonstiges → Nachbearbeitungsskripte**, eine Zeile:
@@ -25,13 +27,31 @@ D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_send
Gilt für **OrcaSlicer** und **AnycubicSlicerNext**. Pfade anpassen. Der Slicer hängt den Export-Pfad selbst an. Gilt für **OrcaSlicer** und **AnycubicSlicerNext**. Pfade anpassen. Der Slicer hängt den Export-Pfad selbst an.
Unter **Gerät / Drucker** keinen Print-Host (Moonraker) eintragen — sonst geht der Job am Grotti-Senden vorbei. ### Gerät-Reiter = Grotti (nur OrcaSlicer)
## Ablauf Damit unter **Gerät** die Grotti-Oberfläche erscheint:
1. In Orca slicen und **G-code exportieren** (Platte schreiben). Als Ziel den Slice-Ordner wählen — **Unterordner** (z. B. `slice\Gridfinity\…`) gehen: Grotti legt die Datei dort ab und liest den Watch neu. Export außerhalb des Slice-Ordners landet als Upload (ohne Ordner-Verschieben). 1. Orca → Drucker-Dropdown → **Physikalischer Drucker** (Verbindung/Stecker-Symbol).
2. Der Browser öffnet `/dateien?file=…` mit der neuen Datei. 2. **Host-Typ:** z. B. `Octo/Klipper` (egal, solange du nicht „Drucken“ an den Host schickst).
3. Drucker wählen, unter Optionen Farbe → ACE-Platz, **Jetzt** / **Wenn frei** / **Nächster freier**, dann **Starten**. 3. **Hostname / API:** leer lassen — oder nur eine Dummy-Adresse. **Nicht** die Moonraker-IP eines Kobras eintragen.
4. **Device UI / Web-UI-URL:** `http://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: Test ohne Orca:
@@ -43,11 +63,20 @@ D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_send
| Symptom | Check | | Symptom | Check |
| --- | --- | | --- | --- |
| Anycubic-Dialog trotz Grotti | Du hast Remote Print / Aufgabe starten genutzt → nur **G-code exportieren** |
| Meldungsfenster „Config fehlt“ | `data/orca-senden.json` anlegen | | Meldungsfenster „Config fehlt“ | `data/orca-senden.json` anlegen |
| „Grotti nicht erreichbar“ | Server läuft? `url` stimmt? | | „Grotti nicht erreichbar“ | Server läuft? `url` stimmt? |
| „Token fehlt oder stimmt nicht“ | Token neu anlegen, in der JSON eintragen | | „Token fehlt oder stimmt nicht“ | Token neu anlegen, in der JSON eintragen |
| Browser ohne Datei / Login | Einmal anmelden; Deep-Link behält `?file=` nach dem Login | | App-Fenster will Login | Einmal anmelden (Session 30 Tage). Profil: `data/grotti-app-profile`. Zweites Chrome-Fenster schließen, nur eines offen lassen. Grotti neu starten nach Update. |
| Skript läuft nicht | Export wirklich speichern; Zeile im **Druckerprofil**; Python-Pfad prüfen | | Browser-Tab statt Fenster | In `orca-senden.json` `"browser": "app"` setzen (oder Chrome/Edge fehlt) |
| Zwei Fenster / Tab hängt | Debounce (~25 s); erneuter Export aktualisiert den offenen Tab. 3D-Pfad lädt verzögert |
| ACE zeigt T0/T1 statt T1/T10 | Seite neu laden oder **Ordner neu lesen**; Deep-Link zieht `paint_index` nach |
| Skript läuft nicht | Export wirklich speichern; Zeile im **Prozessprofil**; Python-Pfad prüfen |
## Was Grotti nicht kann
- Den **Anycubic-Cloud-/Aufgabe-Dialog** unterdrücken (liegt im Slicer, nicht im Skript).
- Sich in Anycubics Reiter **Arbeitsfläche** einbetten. In **Orca** dagegen: Device-UI-URL → Grotti unter **Gerät** (siehe oben).
## Weiter ## Weiter
+33
View File
@@ -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)
+4
View File
@@ -49,6 +49,10 @@ Oft noch Verarbeitung auf dem Drucker. In der Sidebar unter **Senden** den Forts
Oft Dateinamen mit Nicht-ASCII-Zeichen (z. B. Chinesisch). Grotti normalisiert den Namen beim Senden auf ASCII. Nochmal senden; in der Bibliothek darf der Originalname bleiben. Oft Dateinamen mit Nicht-ASCII-Zeichen (z. B. Chinesisch). Grotti normalisiert den Namen beim Senden auf ASCII. Nochmal senden; in der Bibliothek darf der Originalname bleiben.
## Drucker „heruntergefahren“ / 10111 / 506 nach Upload
Oft **Einfarbig**-G-code mit `; filament_colour_type` (Orca) — gklib stürzt ab. Grotti entfernt die Zeile beim Senden nur bei Einfarbig. Multicolor bleibt unverändert. Betroffene Drucker: Display-Reboot oder Strom aus/an, dann in Grotti ggf. **Fehler löschen**.
## Wo nachschauen ## Wo nachschauen
- Sidebar: Alerts, Läuft, Wartet, Senden - Sidebar: Alerts, Läuft, Wartet, Senden
+5 -4
View File
@@ -1,6 +1,6 @@
# Druckerwand # Druckerwand
Stand **15.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit. Stand **18.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
![Druckerwand](../web/static/doku/wand-uebersicht.png) ![Druckerwand](../web/static/doku/wand-uebersicht.png)
@@ -9,6 +9,7 @@ Stand **15.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet da
Pro Drucker siehst du: Pro Drucker siehst du:
- Zustand (Chip), Fertig-Uhrzeit, Fortschrittsbalken - Zustand (Chip), Fertig-Uhrzeit, Fortschrittsbalken
- Kleines Kamerabild (Snapshot ca. alle 3 s), wenn eine Webcam erreichbar ist — Klick öffnet das Cockpit
- Restzeit, Schicht, Laufzeit - Restzeit, Schicht, Laufzeit
- Temperaturen Düse/Bett (mit Ziel) - Temperaturen Düse/Bett (mit Ziel)
- Dateiname, bei ACE oft Farb-Swatches - Dateiname, bei ACE oft Farb-Swatches
@@ -18,7 +19,7 @@ Aktionen auf der Karte brauchen oft **zwei Klicks** (erster Klick „scharf“,
| Aktion | Wann | | Aktion | Wann |
| --- | --- | | --- | --- |
| **Pause** / **Weiter** | Während des Drucks | | **Pause** / **Weiter** | Während des Drucks; nach Materialwechsel Spule in Grotti an den Drucker hängen, dann **Weiter** |
| **Abbrechen** | Druck oder Pause | | **Abbrechen** | Druck oder Pause |
| **Fehler löschen** | Zustand Fehler — räumt den hängenden Fehler, Heizung aus | | **Fehler löschen** | Zustand Fehler — räumt den hängenden Fehler, Heizung aus |
| **Platte geräumt** | Nach Fertig, wenn die Platte wieder frei ist | | **Platte geräumt** | Nach Fertig, wenn die Platte wieder frei ist |
@@ -34,11 +35,11 @@ URL mit Hash: `/#<host>` (z. B. nach Klick auf die Karte).
Inhalt: Inhalt:
- **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg - **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg. Modus Snapshot/Stream unter Einstellungen → Dieser Browser (gilt nur im Cockpit; die Wand-Thumbs sind immer Snapshot)
- Status, Temps inkl. Bauraum wo verfügbar, Lüfter - Status, Temps inkl. Bauraum wo verfügbar, Lüfter
- **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole. Mit ACE zusätzlich Trockner (Temp, Dauer, Start/Stopp) und pro bestücktem Platz **an den Drucker** (Sorte und Farbe aus der Grotti-Spule) - **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole. Mit ACE zusätzlich Trockner (Temp, Dauer, Start/Stopp) und pro bestücktem Platz **an den Drucker** (Sorte und Farbe aus der Grotti-Spule)
- **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an - **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an
- **Verlauf** Temperatur und ED des aktuellen oder letzten Drucks; Link zum Druckbericht - **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) - **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
- **Bettnetz**-Heatmap, wenn vorhanden - **Bettnetz**-Heatmap, wenn vorhanden
+4 -2
View File
@@ -1,6 +1,6 @@
# Warteschlange und Ablauf # Warteschlange und Ablauf
Stand **15.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten). Stand **18.10.2026**. Aufträge in der Warteschlange (Dieser Drucker / Nächster passender), Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
![Warteschlange](../web/static/doku/warteschlange.png) ![Warteschlange](../web/static/doku/warteschlange.png)
@@ -18,9 +18,11 @@ 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. 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? ## 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. 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) ## Ablauf (nur Kobra 3)
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

+47
View File
@@ -0,0 +1,47 @@
# Grotti — Community-Vorstellung
Screenshots aus dem laufenden Betrieb (v3.4.0, Okt. 2026). Zielgruppe: mittlere bis große Printfarmen.
Logo und Repo-Banner: [`docs/brand/`](../brand/). Haupt-README: [`../../README.md`](../../README.md).
---
## Kurztext (zum Posten)
**Grotti** ist ein Farm-Manager für Moonraker/Klipper-Drucker — gebaut für den Alltag mit mehreren Maschinen, nicht für den Einzelplatz.
Statt zehn Tabs und Excel: eine Wand, eine Warteschlange, Material und Abräumen an einem Ort. Orca/AnycubicSlicerNext schicken den G-code per Nachbearbeitungsskript rein; Grotti wählt den nächsten freien Drucker (inkl. ACE-Farbe/Rest), startet im Zeitfenster und meldet, wenn die Platte geräumt werden muss oder Spule fehlt.
**Was schon geht**
- **Druckerwand + Cockpit** — Live-Status, Temps, Pause/Cancel/E-Stop, Farm-ETA („Alles fertig um …“)
- **Jetzt-Liste** — Platte räumen, Materialbedarf, Wartung, überfällige Aufträge als To-do oben
- **Warteschlange** — Priorität, Termine, Auto-Retry, Routing „nächster freier“ mit Material-Match
- **Kobra-3-Ablauf** — Druck → Abkühlen → Schieben → Tasten (unbeaufsichtigte Serie)
- **Material** — Spulen am Drucker/Regal, Chargen, Restgewicht, Fehlmenge aus der Queue
- **Aufträge** — Kunden, Positionen, Einreihen, Lieferschein, Stück-Fortschritt aus dem Abräumen
- **Kosten & Statistik** — Material + Strom (auch HT/NT), €/Teil, Yield, Ausschussgründe
- **Wartung** — Laufstunden/Filamentmeter, Verschleißteile mit Intervallen
- **Betrieb** — Auth (Session/API-Token), Telegram/ntfy, PWA/Regal-Touch, Ctrl+K-Suche
Stack: Python, PostgreSQL, Moonraker. UI auf Deutsch. Self-hosted.
Fragen und Feedback willkommen.
---
## Screenshots
| Datei | Inhalt |
| --- | --- |
| [01-druckerwand.png](01-druckerwand.png) | Farm-Übersicht, gruppiert nach Modell |
| [06-jetzt.png](06-jetzt.png) | Jetzt-Popover: Abräumen + Materialbedarf |
| [07-cockpit.png](07-cockpit.png) | Einzeldrucker mit Job, Spule, Steuerung |
| [02-warteschlange.png](02-warteschlange.png) | Queue mit Status und Fehlmenge |
| [09-dateien.png](09-dateien.png) | Bibliothek + Senden an mehrere Drucker |
| [03-material.png](03-material.png) | Spulenbestand und Bedarf aus der Queue |
| [04-auftraege.png](04-auftraege.png) | Kundenaufträge und Positionen |
| [05-statistik.png](05-statistik.png) | KPIs: Läufe, Kosten, Erfolg, Schätztreue |
| [08-wartung.png](08-wartung.png) | Laufzeit und Filament je Maschine |
Empfohlene Reihenfolge im Post: Wand → Jetzt → Warteschlange → Material → Statistik.
+1 -1
View File
@@ -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.
+5
View File
@@ -139,3 +139,8 @@ Ohne Map: Rinkhals füllt oft Identity T0→Platz0 … — deshalb Grotti bei ei
- Quellcode von **gkapi** (Task-State-Machine, exakte 10101-Bedingungen) ist nicht öffentlich. - Quellcode von **gkapi** (Task-State-Machine, exakte 10101-Bedingungen) ist nicht öffentlich.
- Deadlock in **FIRMWARE_RESTART** ist beobachtet und in Rinkhals abgefangen, Root Cause im Binary nicht freigelegt. - Deadlock in **FIRMWARE_RESTART** ist beobachtet und in Rinkhals abgefangen, Root Cause im Binary nicht freigelegt.
- Kobra X / K4-Stack ist ein anderes System (C++-Klipper), nicht GoKlipper — für unsere Drucker irrelevant. - Kobra X / K4-Stack ist ein anderes System (C++-Klipper), nicht GoKlipper — für unsere Drucker irrelevant.
## Siehe auch
- [Makros-Kobra](Makros-Kobra.md) — Nicht-tun, Vanilla-Alltag, ACE, LeviQ/Wipe je Druckertyp
+1 -1
View File
@@ -14,7 +14,7 @@ In der Bibliothek gespeichert:
| Feld | Bedeutung | | Feld | Bedeutung |
|------|-----------| |------|-----------|
| `filaments` | Typ/Farbe (`paint_info` oder `filament_type`/`filament_colour`) | | `filaments` | Typ/Farbe (`paint_info` oder `filament_type`/`filament_colour`) plus `profile` aus `filament_settings_id` (Sorte, z. B. Sunlu PLA Meta) |
| `weight_g` / `estimated_s` | Filamentgewicht und Druckzeit | | `weight_g` / `estimated_s` | Filamentgewicht und Druckzeit |
| `printer_family` | aus `printer_model` | | `printer_family` | aus `printer_model` |
| `slice_meta` | jsonb: `layer_height`, `layers`, `max_z_mm`, `nozzle_mm`, `temp_nozzle`, `temp_bed`, `slicer` | | `slice_meta` | jsonb: `layer_height`, `layers`, `max_z_mm`, `nozzle_mm`, `temp_nozzle`, `temp_bed`, `slicer` |
+20 -13
View File
@@ -1,33 +1,40 @@
# Grotti: Alltag und Oberfläche # Grotti: Alltag und Oberfläche
Stand **18.10.2026**, v3.0.0. Stand **18.10.2026**, v3.3.0.
## Design ## Design
- Dunkles UI mit Orange-Akzent, Lucide-ähnliche Stroke-Icons (SVG-Sprite in `layout.html`). - Dunkles UI mit Orange-Akzent; Light-Theme über dieselben Tokens (`html[data-theme]`).
- Tokens in `grotti.css`; `--faint` lesbar; `prefers-reduced-motion`; Touch-Ziele bei `pointer: coarse`. - 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. - **Anzeige pro Browser**: Desktop oder Regal (Touch); Zeitzone; Nav einklappbar.
- PWA: `manifest.webmanifest` + Service-Worker (Shell-Cache). - PWA: `manifest.webmanifest` + Service-Worker (Shell-Cache inkl. Icons/Font).
- Live: SSE `/api/v1/stream` plus Poll-Fallback; Verbindungsbanner. - 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 ## Seitenleiste und Topbar
- **Live:** Drucker, Dateien, Kameras, Warteschlange, Aufträge. - Sidebar: Abschnittslabels **Live** / **Rückschau** / **Verwalten** (ohne Live-Panel).
- **Rückschau:** Protokoll, Statistik, Aktivität. - Topbar-Live-Chips (**Jetzt** · Läufe · Senden · Meldungen) mit Popover; Farm-ETA nur bei mehreren Läufen.
- **Verwalten:** Material, Wartung; Einstellungen, Doku. - Mobile: Tab **Jetzt** (`/jetzt`) statt Aktivität in der unteren Leiste; Aktivität unter Mehr.
- Live-Panel: Läuft / Wartet / Aufgaben / Senden / Alerts. - Topbar: Suche, Einstellungen, Doku, Benutzer-Menü (Avatar → Abmelden, Version, Copyright).
- Mobile: Bottom-Nav Drucker · Dateien · Queue · Aktivität · **Mehr** (Sheet). - 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 ## Wand und Abräumen
- Karten + Cockpit wie bisher. - Karten + Cockpit; Technik-Fold mit Icons je Abschnitt.
- **Platte geräumt** öffnet Dialog: gut/Ausschuss, Grund, Stückzahlen. - **Platte geräumt** öffnet Dialog: gut/Ausschuss, Grund, Stückzahlen.
## Aufträge und Wartung ## Aufträge und Wartung
- `/auftraege`: Kunden, Positionen, Einreihen, Lieferschein. `/projekte` und `/ideen` leiten um. - `/auftraege`: Kunden, Positionen, Einreihen, Lieferschein. Status als Chip.
- `/wartung`: Stunden/Meter und Verschleißteile. - `/wartung`: Stunden/Meter und Verschleißteile.
## Dateien, Material, Statistik ## Dateien, Material, Statistik
- Unverändert im Kern (Bibliothek, Send-Flow, Spulen/Sorten, KPIs + Ausschuss-Gründe). - Bibliothek mit Toolbar/Suche; Senden-Schritte nummeriert.
- Material: Spulen/Sorten-Tabs, Filter-Toolbar.
- Statistik: KPI-Karten mit Icons.
+3 -1
View File
@@ -1,6 +1,8 @@
# MCP-Server # MCP-Server
Stand **15.10.2026**. Agenten steuern Grotti nur über die HTTP-API mit einem API-Token. Stand **19.10.2026**. Agenten steuern Grotti nur über die HTTP-API mit einem API-Token.
Beim Connect bekommt der Client die Server-Anweisung aus `grotti/mcp_server.py` (`INSTRUCTIONS`): Deutsch antworten, Host und Datei-id nachschlagen, erst lesen dann schreiben, `confirm=true` nur auf klaren Wunsch, Warteschlange vor Sofortstart, Filament-Pause nicht blind fortsetzen, `clear_bed` ist nur eine Quittung, kein G-code und kein Firmware-Restart.
## Start ## Start
+195
View File
@@ -0,0 +1,195 @@
# Makros und G-code: Kobra S1 und Kobra 3
Stand **19.10.2026**. Interne Referenz — nicht unter `/doku`. Messwerte und Bettnetz-Story: `D:\Projekte\3DP\FDM\Wissen\Drucker\` (`Kobra S1.md`, `Kobra 3.md`). Stack: [Anycubic-Stack](Anycubic-Stack.md).
Quellen: `printer/gcode/help` (11.10. auf den Maschinen), GoKlipper/`extras_ace.go`, Rinkhals-RE `GOKLIPPER_ACE_GCODE_COMMANDS.md`, Grotti-Policy.
---
## Nicht tun
| Nicht | Warum | Stattdessen |
| --- | --- | --- |
| `FIRMWARE_RESTART` / `RESTART` | gklib Deadlock bis Power-Cycle (Rinkhals #32) | Display-Reboot oder Strom; hängenden Job: **Fehler löschen** (`SDCARD_RESET_FILE`) |
| `LEVIQ` / `LEVIQ2` aus Mainsail | Heizung + volle Nivellierfolge, fährt unerwartet | Am Display Auto-Level; Einzelschritte nur bewusst |
| `SAVE_CONFIG` aus Mainsail | schreibt u. a. Z-Offset mit | Dauerhaft nur gezielt / nach Display-Nivellieren |
| Nur Moonraker-Cancel | Anycubic-Task bleibt → Start-Code **10101** | Cancel + MQTT-stop (Grotti `abbrechen`) |
| Community-`ACE_SET_SLOT` / manuelles `G9111` als Grotti-Pfad | Grotti mappt über `.acm` + MQTT | Map in der App; Slice-`G9111` im Start-G-code belassen |
| `minimum_cruise_ratio` auf **0** (S1) | Moonraker hängt auf dieser Firmware | bei **0,05** belassen |
| `G28 Y` mit Teilen auf dem Bett (K3) | Sensorless-Home nach **vorn** über die Teile | Bett leeren; Abräumen ohne `G28 Z` |
| Blind `FEED` / `FEED_FILAMENT` ohne Unwind | Blockade bis Neustart (RE-Doku) | Unload/Unwind-Sequenz einhalten |
| `SET_KINEMATIC_POSITION` mit erfundener Z nach Stepper-aus | Portal sackt → Null daneben, Probe „No trigger“ oder Crash | echte letzte XYZ; oder Bett leer + `G28` |
| Freie G-code-Konsole in Grotti für Experimente | Policy | nur bekannte, bestätigte Scripts |
| Abräumen-Makro in `printer.cfg` pflegen | Rinkhals schreibt Config neu | Grotti schickt Scripts per Moonraker |
---
## Vanilla-Klipper-Alltag (beide Typen)
Befehl → was er macht. Anycubic-Hinweis nur wo nötig.
### Homes und Bewegung
| Befehl | Funktion |
| --- | --- |
| `G28` | Homed die genannten Achsen (ohne Argument: alle). **K3:** Y-Home fährt nach vorn — Bett muss leer sein. |
| `G0` / `G1` | Lineare Bewegung / Extrusion. Absolute/relative Modi wie üblich (`G90`/`G91`, `M82`/`M83`). |
| `M400` | Wartet, bis alle Moves fertig sind. |
| `SET_VELOCITY_LIMIT` | Limits für Speed/Accel zur Laufzeit. |
| `SET_KINEMATIC_POSITION` | Setzt die interne Position ohne zu fahren. Nach `M84`/Leerlauf riskant — siehe „Nicht tun“. |
| `SET_IDLE_TIMEOUT` | Idle-Zeit bis Stepper aus. Abräumen hält Idle bewusst länger. |
| `M84` / `M18` | Stepper aus → Achsen unhomed. Slice-Ende K3 macht das oft; Abräumen entfernt es beim Upload. |
### Heizen
| Befehl | Funktion |
| --- | --- |
| `M104` / `M109` | Düse Soll / warten bis erreicht. |
| `M140` / `M190` | Bett Soll / warten. |
| `TURN_OFF_HEATERS` | Düse und Bett aus. |
| `PID_CALIBRATE` | PID für Extruder oder Bett kalibrieren. Danach oft `SAVE_CONFIG` — auf diesen Maschinen vorsichtig. |
| `M106` / `M107` | Teilkühlung an / aus. |
### Drucklauf
| Befehl | Funktion |
| --- | --- |
| `PAUSE` / `RESUME` / `CANCEL_PRINT` | Rinkhals-Makros um Pause/Weiter/Abbruch. Moonraker-Cancel allein räumt den Anycubic-Task nicht. |
| `SDCARD_RESET_FILE` | Löscht hängenden virtual-sdcard-/Druckstatus („Fehler löschen“ in Grotti). |
| `PRINT_STATS` | Status des laufenden/letzten Jobs (Objekt auch über Moonraker). |
### Bettnetz und Probe
| Befehl | Funktion |
| --- | --- |
| `BED_MESH_CALIBRATE` | Tastet das Netz und schaltet es aktiv. |
| `BED_MESH_OUTPUT` | Gibt die Punkte aus, ohne zu fahren. |
| `BED_MESH_MAP` | Schreibt das interpolierte Netz in die Konsole. |
| `BED_MESH_CLEAR` | Schaltet die Korrektur aus. |
| `BED_MESH_OFFSET` | Verschiebt das Netz in X/Y. |
| `BED_MESH_PROFILE` | `LOAD` / `SAVE` / `REMOVE` für Profile. Dauerhaft erst mit `SAVE_CONFIG`. |
| `PROBE` | Ein Tastpunkt an der aktuellen Position. |
| `PROBE_ACCURACY` | Mehrfach tasten, Streuung ausgeben. |
| `PROBE_CALIBRATE` | Sensor kalibrieren (nicht der Z-Regler am Display). |
| `Z_OFFSET_APPLY_PROBE` | Laufenden G-code-Offset in `probe.z_offset` schreiben. |
| `SET_GCODE_OFFSET` | Offset nur für den aktuellen Auftrag. |
| `SAVE_CONFIG` | Schreibt Config dauerhaft — **nicht** blind aus Mainsail. |
### Diagnose
| Befehl | Funktion |
| --- | --- |
| `QUERY_ENDSTOPS` / `M119` | Endstop-Zustände. |
| `GET_POSITION` / `M114` | Toolhead-/Stepper-Position. |
| `QUERY_ADC` | ADC-Werte (z. B. Thermistoren). |
| `DUMP_TMC` | TMC-Register (Stepper nennen). |
### Slice-Start Anycubic
| Befehl | Funktion |
| --- | --- |
| `G9111` | Vorheizen aus dem Slicer: typisch `bedTemp=…` und `extruderTemp=…`. Steht im Maschinen-Start-G-code. Grotti ersetzt das nicht und startet auch nicht darüber. |
---
## ACE (gemeinsam)
Zwei Namenswelten:
1. **Open-Source** GoKlipper `extras_ace.go` (BunnyACE-Stil): `ACE_CHANGE_TOOL`, `ACE_FEED`, `ACE_RETRACT`, `ACE_SET_SLOT`, `ACE_QUERY_SLOTS`, `ACE_START_DRYING` / `ACE_STOP_DRYING`, Feed-Assist, Endless Spool, …
2. **Ausgeliefertes Binary** (Rinkhals-RE): u. a. `FEED_FILAMENT`, `UNWIND_FILAMENT`, `UNWIND_ALL_FILAMENT`, `SET_CURRENT_FILAMENT`, `ACE_INFO`, `ACE_STATUS`, Cutter-/Pipe-Befehle.
Grotti nutzt **keine** Community-Makros `ACE_SET_SLOT` / manuelles Tool-Mapping per G-code. Zuordnung: Sidecar `.acm` + MQTT `ams_settings` ([Senden-Queue](Senden-Queue.md), [ACE-Live](ACE-Live.md)). Slots anzeigen: Moonraker-Objekt `mmu` (Rinkhals). Trocknen: Moonraker `POST /server/filament_hub/start_drying` (nicht zwingend der G-code).
| Befehl (Auswahl) | Funktion |
| --- | --- |
| `ACE_CHANGE_TOOL` / Tool-Macros `T0`… | Werkzeugwechsel über ACE. |
| `ACE_FEED` / `FEED_FILAMENT` | Filament vom Gate zur Düse laden. **Vorher unloaden.** |
| `ACE_RETRACT` / `UNWIND_FILAMENT` | Zurückziehen / parkieren. |
| `ACE_SET_SLOT` | Inventar Slot setzen — Grotti meidet das. |
| `ACE_START_DRYING` / `ACE_STOP_DRYING` | Trockner; alternativ `filament_hub`-API. |
| `ACE_ENABLE_ENDLESS_SPOOL` / `…_DISABLE` / `…_STATUS` | Endless Spool. |
Details und Parameter: Rinkhals `docs/GOKLIPPER_ACE_GCODE_COMMANDS.md` (lokal unter `D:\Projekte\_ref\Rinkhals\`).
---
## Kobra S1 — Anycubic-Makros
Config-Sektion heißt `[leviQ3]`, die Befehle auf dem Gerät heißen **`LEVIQ2`**. Stecken in GoKlipper. Hilfe gelesen 11.10. über `printer/gcode/help`.
### LeviQ2
| Befehl | Funktion |
| --- | --- |
| `LEVIQ2` | Ganze Nivellierfolge: heizen, wischen, antasten. **Nicht** aus Mainsail. `SYNC=enable` startet danach neu. |
| `LEVIQ2_PREHEATING` | Vorheizen: Bett 55 °C, Düse 170 °C, dann `HOME_XY`. `S` = Düsentemp, `E` = Temp nach dem Wischen. |
| `LEVIQ2_WIPING` | Wischen: Netz löschen, Düse auf 140 °C, intern `G9112`, dann `G28 Z`. |
| `LEVIQ2_PROBE` | Antasten: `BED_MESH_CALIBRATE`, Lüfter/Bett aus, Z auf 20 mm, `HOME_XY`, Düse aus. |
| `LEVIQ_SET_ZERO` | Z-Basis und Home-Position auf 0. |
| `LEVIQ2_AUTO_ZOFFSET` | Löscht laut Hilfe den Koeffizienten K. |
| `LEVIQ2_AUTO_ZOFFSET_ON_OFF` | Schaltet automatischen Z-Offset. |
| `LEVIQ2_TEMP_OFFSET` | Temperaturausgleich (`expansion_factor` / `zoffset_base` in der Config). Fehlt im veröffentlichten Quelltext. |
| `HOME_XY` | Nur X/Y homen (von LeviQ-Schritten genutzt). |
Netz: 5×5, X/Y 5–245 mm. Details: Wissen `Kobra S1.md`.
### Wipe (Pad hinten)
| Befehl | Funktion |
| --- | --- |
| `WIPE_ENTER` | Anfahrt Pad (Y 250 → X 81, Y 273). |
| `WIPE_NOZZLE` | Wischbahn; wiederholt die Strecke X 96↔81. |
| `WIPE_STOP` | Kurze Stopp-Bahnen, dann X 75. |
| `WIPE_EXIT` | Weg vom Pad (X 83, Y 250). |
| `TO_THROW_POSITION` | Position zum Abwerfen (~X 47, Y 276). |
| `MOVE_TO_SWEEP_POSITION` | Wisch-/Kehrposition (gleiche Gegend). |
| `MOVE_TO_IMPACT_POSITION` | Weg vom Pad nach vorn rechts (Y 25, X 261). |
| `PRINT_LINE` | Purge-Linie bei Y 251, Z 0,28 bis X 125. |
---
## Kobra 3 — Anycubic-Makros
Befehle gelesen 11.10. auf `da26` (`92ee`/`6e41` gleiche `printer.generated.cfg`). Im veröffentlichten Quelltext heißen Teile noch `LEVIQ3_*` / `LEVIQ2_PROBE` — auf den drei Druckern gelten die Namen unten.
### LeviQ
| Befehl | Funktion |
| --- | --- |
| `LEVIQ` | Ganze Folge inkl. Z-Offset-Rechnung. **Nicht** aus Mainsail. `SYNC=enable` startet danach neu. |
| `LEVIQ_PREHEATING` | Z-Basis 0, Bett 60 °C, Düse 170 °C, `HOME_XY`, auf Düse warten. `S` / `E` wie bei S1. |
| `LEVIQ_WIPING` | Netz löschen, Düse 140 °C, `G9112`, `G28 Z`. |
| `LEVIQ_PROBE` | `BED_MESH_CALIBRATE`, Lüfter/Bett aus, Z auf 30 mm, `HOME_XY`, Düse aus. |
| `LEVIQ_SET_ZERO` | Z-Basis und Home auf 0. |
| `LEVIQ_AUTO_ZOFFSET` | Löscht Koeffizient K (Hilfe). |
| `LEVIQ_AUTO_ZOFFSET_ON_OFF` | Automatischen Z-Offset schalten (oft an). |
| `LEVIQ_TEMP_OFFSET` | Temperaturausgleich (Hilfezeile „LEVIQ3 TEMP OFFSET“). Fehlt im Open-Source-Code. |
| `HOME_XY` | Nur X/Y homen. |
Netz: 5×5, X 15–240, Y 15–235 mm. Details: Wissen `Kobra 3.md`.
### Wipe
| Befehl | Funktion |
| --- | --- |
| `WIPE_NOZZLE` | Dreimal `SINGLE_WIPE_NOZZLE` (Y 260/259, X 46–90, Z −0,8↔1, Lüfter an). |
| `SINGLE_WIPE_NOZZLE` | Eine einzelne Wischbahn. |
| `MOVE_HEAT_POS` | Z 15, dann X 278 (Heiz-/Parknahe Position). |
| `UNDERLINE` | Purge-Linie Z 0,28 bis X 180, ~40 mm Extrusion. |
| `MOVE_END_POS` | Ende/Park X 5, Y 242. |
### Wägezelle
| Befehl | Funktion |
| --- | --- |
| `CS1237_DUMP` | Debug-Ausgabe zum CS1237 (Z-Sensor / Kraft). Moonraker liefert keine laufende Gramm-Anzeige; `probe.last_z_result` fehlt. |
---
## Siehe auch
- [Anycubic-Stack](Anycubic-Stack.md) — MQTT-Start/Stop, 10101, kein Firmware-Restart
- [Senden-Queue](Senden-Queue.md) — `.acm`, kein Community-ACE-Makro
- [Abraeumen](Abraeumen.md) — K3 Farm-Scripts statt Druckermakro
- [ACE-Live](ACE-Live.md) — Live-Mapping und Trailer
+1 -1
View File
@@ -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
+5
View File
@@ -3,3 +3,8 @@
Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App. Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App.
Nutzer-Handbuch: `docs/Anleitung-*.md`. Nutzer-Handbuch: `docs/Anleitung-*.md`.
| Datei | Inhalt |
| --- | --- |
| [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 |
+171 -2
View File
@@ -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
+34 -5
View File
@@ -2,6 +2,10 @@
from __future__ import annotations from __future__ import annotations
import re
_HEX_COLOR_RE = re.compile(r"^#?[0-9a-f]{6}([0-9a-f]{2})?$", re.I)
def _group_key(material_type, color): def _group_key(material_type, color):
return ( return (
@@ -10,12 +14,33 @@ def _group_key(material_type, color):
) )
def _is_slicer_hex(color):
"""Orca/Anycubic speichert oft #26A69A — Lagerfarben heißen „Grau“/„Weiß“."""
return bool(_HEX_COLOR_RE.match((color or "").strip()))
def _stock_for_need(stock_map, mat, color):
"""Exakte Farbe zuerst; bei Slice-Hex oder leerer Farbe: Summe gleicher Sorte."""
key = _group_key(mat, color)
exact = stock_map.get(key, 0.0)
if exact > 0.5:
return exact
if not color or _is_slicer_hex(color):
mat_key = (mat or "").strip().lower()
return sum(
grams
for (m, _c), grams in stock_map.items()
if m == mat_key and grams > 0
)
return exact
def compute(conn): def compute(conn):
"""Wartende Jobs × Dateigewicht vs. Spulenrest, gruppiert nach Sorte/Farbe.""" """Wartende Jobs × Dateigewicht vs. Spulenrest, gruppiert nach Sorte/Farbe."""
queue_rows = conn.execute( queue_rows = conn.execute(
""" """
SELECT q.id, q.spool_id, q.repeat_total, q.repeat_done, SELECT q.id, q.spool_id, q.repeat_total, q.repeat_done,
f.weight_g, f.material_type AS file_material, f.color AS file_color, f.weight_g, f.filaments,
s.material_type AS spool_material, s.color AS spool_color s.material_type AS spool_material, s.color AS spool_color
FROM print_queue q FROM print_queue q
JOIN files f ON f.id = q.file_id JOIN files f ON f.id = q.file_id
@@ -26,8 +51,12 @@ def compute(conn):
needed_map = {} needed_map = {}
for row in queue_rows: for row in queue_rows:
mat = (row.get("spool_material") or row.get("file_material") or "").strip() filaments = row.get("filaments") or []
color = (row.get("spool_color") or row.get("file_color") or "").strip() 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) key = _group_key(mat, color)
try: try:
repeats = max(1, int(row.get("repeat_total") or 1) - int(row.get("repeat_done") or 0)) repeats = max(1, int(row.get("repeat_total") or 1) - int(row.get("repeat_done") or 0))
@@ -48,6 +77,7 @@ def compute(conn):
""" """
SELECT material_type, color, COALESCE(SUM(remaining_g), 0) AS remaining_g SELECT material_type, color, COALESCE(SUM(remaining_g), 0) AS remaining_g
FROM spools FROM spools
WHERE archived_at IS NULL
GROUP BY material_type, color GROUP BY material_type, color
""" """
).fetchall() ).fetchall()
@@ -63,8 +93,7 @@ def compute(conn):
) )
shortfall = [] shortfall = []
for item in needed: for item in needed:
key = _group_key(item["material_type"], item["color"]) have = _stock_for_need(stock_map, item["material_type"], item["color"])
have = stock_map.get(key, 0.0)
gap = item["weight_g"] - have gap = item["weight_g"] - have
if gap > 0.5: if gap > 0.5:
shortfall.append({ shortfall.append({
+9 -1
View File
@@ -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"},
+33 -5
View File
@@ -1,6 +1,7 @@
"""Host, Port, Datenordner und Datenbank kommen aus config.json.""" """Host, Port, Datenordner und Datenbank kommen aus config.json."""
import json import json
import os
import secrets import secrets
import threading import threading
from pathlib import Path from pathlib import Path
@@ -9,11 +10,24 @@ from grotti.errors import GrottiError
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
LOOPBACK = {"127.0.0.1", "::1", "localhost"} LOOPBACK = {"127.0.0.1", "::1", "localhost"}
CONFIG_PATH = ROOT / "config.json"
DEFAULT_WATCH_REL = "slice" DEFAULT_WATCH_REL = "slice"
_SETTINGS_LOCK = threading.Lock() _SETTINGS_LOCK = threading.Lock()
def config_path():
"""GROTTI_CONFIG, sonst /etc/grotti/config.json (Paket), sonst Repo-config.json."""
env = (os.environ.get("GROTTI_CONFIG") or "").strip()
if env:
return Path(env)
etc = Path("/etc/grotti/config.json")
if etc.is_file():
return etc
return ROOT / "config.json"
CONFIG_PATH = config_path()
def _tariff(raw): def _tariff(raw):
tariff = raw.get("tariff") if isinstance(raw, dict) else None tariff = raw.get("tariff") if isinstance(raw, dict) else None
if not isinstance(tariff, dict) or not tariff: if not isinstance(tariff, dict) or not tariff:
@@ -121,9 +135,11 @@ def _ensure_secret_key(path, raw):
def load(): def load():
path = CONFIG_PATH path = config_path()
if not path.exists(): if not path.exists():
path.write_text((ROOT / "config.example.json").read_text(encoding="utf-8"), encoding="utf-8") example = ROOT / "config.example.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(example.read_text(encoding="utf-8"), encoding="utf-8")
raw = json.loads(path.read_text(encoding="utf-8")) raw = json.loads(path.read_text(encoding="utf-8"))
host = (raw.get("host") or "127.0.0.1").strip() host = (raw.get("host") or "127.0.0.1").strip()
token = raw.get("token") or "" token = raw.get("token") or ""
@@ -139,6 +155,9 @@ def load():
if not data_dir.is_absolute(): if not data_dir.is_absolute():
data_dir = ROOT / data_dir data_dir = ROOT / data_dir
watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True) watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
printers_file = Path(raw.get("printers_file") or (ROOT / "drucker.json"))
if not printers_file.is_absolute():
printers_file = ROOT / printers_file
return { return {
"host": host, "host": host,
"port": int(raw.get("port") or 8770), "port": int(raw.get("port") or 8770),
@@ -149,12 +168,14 @@ def load():
"secret_key": secret_key, "secret_key": secret_key,
"oauth": _oauth(raw), "oauth": _oauth(raw),
"fqdn": normalize_fqdn(raw.get("fqdn") or ""), "fqdn": normalize_fqdn(raw.get("fqdn") or ""),
"printers_file": ROOT / "drucker.json", "printers_file": printers_file,
"log_level": str(raw.get("log_level") or "INFO").strip() or "INFO",
"energy": _energy(raw), "energy": _energy(raw),
"telegram": _telegram(raw), "telegram": _telegram(raw),
"ntfy": _ntfy(raw), "ntfy": _ntfy(raw),
"alerts": _alerts(raw), "alerts": _alerts(raw),
"auto_queue": bool(raw.get("auto_queue", True)), "auto_queue": bool(raw.get("auto_queue", True)),
"auto_retry": bool(raw.get("auto_retry", True)),
"_raw": raw, "_raw": raw,
"_path": path, "_path": path,
} }
@@ -210,6 +231,7 @@ def settings_public(cfg):
"watch_dir": str(cfg.get("watch_dir") or ""), "watch_dir": str(cfg.get("watch_dir") or ""),
"watch_dir_default": default_watch_suggestion(), "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")), "legacy_token_set": bool(cfg.get("token")),
"energy": dict(cfg.get("energy") or {}), "energy": dict(cfg.get("energy") or {}),
@@ -243,6 +265,7 @@ def save_settings(
ntfy=None, ntfy=None,
alerts=None, alerts=None,
auto_queue=None, auto_queue=None,
auto_retry=None,
fqdn=None, fqdn=None,
watch_dir=None, watch_dir=None,
): ):
@@ -255,6 +278,7 @@ def save_settings(
ntfy=ntfy, ntfy=ntfy,
alerts=alerts, alerts=alerts,
auto_queue=auto_queue, auto_queue=auto_queue,
auto_retry=auto_retry,
fqdn=fqdn, fqdn=fqdn,
watch_dir=watch_dir, watch_dir=watch_dir,
) )
@@ -267,6 +291,7 @@ def _save_settings_locked(
ntfy=None, ntfy=None,
alerts=None, alerts=None,
auto_queue=None, auto_queue=None,
auto_retry=None,
fqdn=None, fqdn=None,
watch_dir=None, watch_dir=None,
): ):
@@ -280,6 +305,9 @@ def _save_settings_locked(
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: if watch_dir is not None:
resolved, stored = parse_watch_dir(watch_dir, create=True, missing_ok=False) resolved, stored = parse_watch_dir(watch_dir, create=True, missing_ok=False)
raw["watch_dir"] = stored raw["watch_dir"] = stored
@@ -300,7 +328,7 @@ def _save_settings_locked(
"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 tariff_in = energy.get("tariff") if isinstance(energy.get("tariff"), dict) else None
if tariff_in is not None: if tariff_in:
raw_energy["tariff"] = { raw_energy["tariff"] = {
"ht_euro": float(tariff_in.get("ht_euro", raw_energy["euro_per_kwh"])), "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_euro": float(tariff_in.get("nt_euro", raw_energy["euro_per_kwh"])),
+60 -12
View File
@@ -62,11 +62,13 @@ def _avg_power(a, b):
return max(0.0, min(1.0, sum(vals) / len(vals))) return max(0.0, min(1.0, sum(vals) / len(vals)))
def _ed_print_kwh(samples, base_w, nozzle_w, bed_w): def _ed_print_parts(samples, base_w, nozzle_w, bed_w):
"""Trapez aus Grundlast + ED×Heizerleistung. None wenn zu wenig ED-Proben.""" """Trapez: Grundlast / Düse / Bett getrennt. None wenn zu wenig ED-Proben."""
if not samples or len(samples) < 2: if not samples or len(samples) < 2:
return None return None
total = 0.0 base_kwh = 0.0
nozzle_kwh = 0.0
bed_kwh = 0.0
ed_steps = 0 ed_steps = 0
# Max. Intervall: 3× Normal-Sample (90 s), damit Lücken nicht die ED fortschreiben # Max. Intervall: 3× Normal-Sample (90 s), damit Lücken nicht die ED fortschreiben
max_dt = 90.0 max_dt = 90.0
@@ -77,6 +79,7 @@ def _ed_print_kwh(samples, base_w, nozzle_w, bed_w):
if t0 is None or t1 is None or t1 <= t0: if t0 is None or t1 is None or t1 <= t0:
continue continue
dt = min(t1 - t0, max_dt) dt = min(t1 - t0, max_dt)
hours = dt / 3600.0
pn0, pn1 = a.get("nozzle_power"), b.get("nozzle_power") pn0, pn1 = a.get("nozzle_power"), b.get("nozzle_power")
pb0, pb1 = a.get("bed_power"), b.get("bed_power") pb0, pb1 = a.get("bed_power"), b.get("bed_power")
has_ed = any( has_ed = any(
@@ -87,11 +90,17 @@ def _ed_print_kwh(samples, base_w, nozzle_w, bed_w):
ed_steps += 1 ed_steps += 1
ed_n = _avg_power(pn0, pn1) ed_n = _avg_power(pn0, pn1)
ed_b = _avg_power(pb0, pb1) ed_b = _avg_power(pb0, pb1)
watts = float(base_w) + float(nozzle_w) * ed_n + float(bed_w) * ed_b base_kwh += (float(base_w) / 1000.0) * hours
total += (watts / 1000.0) * (dt / 3600.0) nozzle_kwh += (float(nozzle_w) * ed_n / 1000.0) * hours
bed_kwh += (float(bed_w) * ed_b / 1000.0) * hours
if ed_steps < 1: if ed_steps < 1:
return None return None
return total 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): def _is_nt_moment(moment, tariff):
@@ -130,10 +139,21 @@ def effective_euro_per_kwh(duration_s, energy, end_dt=None):
return nt_frac * nt + (1.0 - nt_frac) * 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): def energy_estimate(duration_s, energy, samples=None, end_dt=None):
"""Strom aus ED-Proben oder pauschal Dauer × Watt. """Strom aus ED-Proben oder pauschal Dauer × Watt.
Quelle ``ed`` nur wenn Düse-/Bett-Watt gesetzt und Proben mit power vorliegen. 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 {} energy = energy or {}
euro = effective_euro_per_kwh(duration_s, energy, end_dt=end_dt) euro = effective_euro_per_kwh(duration_s, energy, end_dt=end_dt)
@@ -142,25 +162,43 @@ def energy_estimate(duration_s, energy, samples=None, end_dt=None):
nozzle_w = float(energy.get("nozzle_watts") or 0) nozzle_w = float(energy.get("nozzle_watts") or 0)
bed_w = float(energy.get("bed_watts") or 0) bed_w = float(energy.get("bed_watts") or 0)
print_kwh = None parts_map = None
source = "flat" source = "flat"
if (nozzle_w > 0 or bed_w > 0) and samples: if (nozzle_w > 0 or bed_w > 0) and samples:
print_kwh = _ed_print_kwh(samples, base_w, nozzle_w, bed_w) parts_map = _ed_print_parts(samples, base_w, nozzle_w, bed_w)
if print_kwh is not None: if parts_map is not None:
source = "ed" source = "ed"
if print_kwh is None: if parts_map is None:
hours = float(duration_s or 0) / 3600 hours = float(duration_s or 0) / 3600
print_kwh = (base_w / 1000) * hours parts_map = {
"base_kwh": (base_w / 1000) * hours,
"nozzle_kwh": 0.0,
"bed_kwh": 0.0,
"print_kwh": (base_w / 1000) * hours,
}
source = "flat" source = "flat"
print_kwh = float(parts_map["print_kwh"])
kwh = print_kwh + dry_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 { return {
"kwh": kwh, "kwh": kwh,
"print_kwh": print_kwh, "print_kwh": print_kwh,
"dry_kwh": dry_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, "cost": kwh * euro,
"euro_per_kwh": euro,
"source": source, "source": source,
"parts": parts,
} }
@@ -199,6 +237,7 @@ def order_actuals(conn, order_id):
SELECT SELECT
COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost_total, COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost_total,
COALESCE(SUM(r.duration_s), 0) AS duration_s, 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.pieces_bad), 0) AS pieces_bad,
COALESCE(SUM(r.weight_g) FILTER (WHERE r.verdict = 'ausschuss'), 0) AS scrap_g, COALESCE(SUM(r.weight_g) FILTER (WHERE r.verdict = 'ausschuss'), 0) AS scrap_g,
COUNT(*) FILTER (WHERE r.verdict = 'ausschuss') AS scrap_runs COUNT(*) FILTER (WHERE r.verdict = 'ausschuss') AS scrap_runs
@@ -208,10 +247,19 @@ def order_actuals(conn, order_id):
""", """,
(order_id,), (order_id,),
).fetchone() ).fetchone()
pieces_ok = int(row["pieces_ok"] or 0)
cost_total = float(row["cost_total"] or 0)
return { return {
"cost_total": float(row["cost_total"] or 0), "cost_total": cost_total,
"duration_s": float(row["duration_s"] or 0), "duration_s": float(row["duration_s"] or 0),
"pieces_ok": pieces_ok,
"pieces_bad": int(row["pieces_bad"] or 0), "pieces_bad": int(row["pieces_bad"] or 0),
"scrap_g": float(row["scrap_g"] or 0), "scrap_g": float(row["scrap_g"] or 0),
"scrap_runs": int(row["scrap_runs"] 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
),
} }
+150
View File
@@ -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
+5 -1
View File
@@ -13,6 +13,10 @@ from psycopg import sql
from psycopg.rows import dict_row from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool from psycopg_pool import ConnectionPool
from grotti import logutil
log = logutil.get("grotti.db")
MIGRATIONS_DIR = Path(__file__).parent / "migrations" MIGRATIONS_DIR = Path(__file__).parent / "migrations"
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql" SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
@@ -123,7 +127,7 @@ def prepare(url):
if not exists: if not exists:
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name))) conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
except psycopg.Error as exc: except psycopg.Error as exc:
print("Datenbank anlegen: %s" % exc, flush=True) log.warning("Datenbank anlegen: %s", exc)
try: try:
with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn: with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
apply_migrations(conn) apply_migrations(conn)
+289
View File
@@ -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),
}
+56 -29
View File
@@ -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, usage 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
@@ -57,25 +57,47 @@ def unix_to_dt(value):
def choose_spool(conn, host, material, hinted_id, ended_at=None): def choose_spool(conn, host, material, hinted_id, ended_at=None):
"""Spule für History-Abzug. Ohne Hint nur Rollen, die vor Druckende existierten.""" """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:
row = store.get_spool_row(conn, hinted_id)
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
if row and not at_host:
# Rolle schon im Regal, kein Nachfolger am Host → Hint behalten (alte Jobs)
return row
if len(at_host) == 1:
return at_host[0]
if material:
same = [row for row in at_host if (row["material_type"] or "").lower() == material.lower()]
if len(same) == 1:
return same[0]
if hinted_id: if hinted_id:
row = store.get_spool_row(conn, hinted_id) row = store.get_spool_row(conn, hinted_id)
if row and _spool_valid_for_end(row, ended_at): if row and _spool_valid_for_end(row, ended_at):
return row return row
if row and not _spool_valid_for_end(row, ended_at):
return None
rows = [
row for row in store.spools_for_printer(conn, host)
if _spool_valid_for_end(row, ended_at)
]
if len(rows) == 1:
return rows[0]
if material:
same = [row for row in rows if (row["material_type"] or "").lower() == material.lower()]
if len(same) == 1:
return same[0]
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): def _spool_valid_for_end(spool, ended_at):
"""Verhindert Abzug alter Moonraker-Jobs auf neu eingelegte Spulen.""" """Verhindert Abzug alter Moonraker-Jobs auf neu eingelegte Spulen."""
if ended_at is None or not spool: if ended_at is None or not spool:
@@ -127,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)}
if not want or not spools:
return 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):
@@ -557,12 +566,30 @@ def send_file(
) )
# CJK/Unicode im Namen → Rinkhals 10115 not found (Upload ≠ Start-Name) # CJK/Unicode im Namen → Rinkhals 10115 not found (Upload ≠ Start-Name)
filename = moonraker.safe_upload_name(filename) filename = moonraker.safe_upload_name(filename)
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
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):
@@ -686,7 +713,7 @@ def steuern(conn, host, action, params=None):
continue continue
if not spool: if not spool:
raise GrottiError("Auf diesem Platz liegt keine Spule in Grotti.", "spule") raise GrottiError("Auf diesem Platz liegt keine Spule in Grotti.", "spule")
material = (spool.get("material_type") or "").strip() material = catalog.ace_material_type(spool.get("material_type"))
if not material: if not material:
raise GrottiError("Die Spule braucht eine Sorte.", "material") raise GrottiError("Die Spule braucht eine Sorte.", "material")
color = moonraker.spool_rgb(spool) color = moonraker.spool_rgb(spool)
+310 -22
View File
@@ -59,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:
@@ -80,20 +125,7 @@ def refresh_file_meta(conn, force=False):
meta = meta_for(path) meta = meta_for(path)
except (OSError, zipfile.BadZipFile): except (OSError, zipfile.BadZipFile):
continue continue
store.update_file( _apply_meta(conn, row["id"], row, meta)
conn,
row["id"],
{
"printer_family": meta.get("printer_family") or row.get("printer_family"),
"filaments": meta.get("filaments") or [],
"weight_g": meta.get("weight_g") or 0,
"estimated_s": meta.get("estimated_s") or 0,
"thumb_entry": meta.get("thumb_entry") or "",
"kind": meta.get("kind") or row.get("kind"),
"plate": meta.get("plate") or "",
"slice_meta": meta.get("slice_meta") or {},
},
)
updated += 1 updated += 1
return updated return updated
@@ -213,27 +245,39 @@ def delete_folder(cfg, rel):
return folder return folder
def _link_file_to_project_folder(conn, file_id, stored_path, watch_dir):
"""Wenn Pfad unter einem Projekt-Ordner liegt → project_id setzen."""
folder = store.file_folder(stored_path, "watch", watch_dir)
if not folder:
return
for row in store.list_project_folders(conn):
base = (row.get("folder") or "").strip().replace("\\", "/").strip("/")
if not base:
continue
if folder == base or folder.startswith(base + "/"):
store.set_file_project(conn, file_id, row["id"])
return
def move_file(conn, cfg, file_id, folder): def move_file(conn, cfg, file_id, folder):
"""Watch-Datei in Unterordner (oder Wurzel) verschieben; ID bleibt.""" """Watch- oder Upload-Datei in Slice-Unterordner (oder Wurzel); Upload wird Watch."""
root = require_watch_dir(cfg) root = require_watch_dir(cfg)
folder = normalize_folder(folder) folder = normalize_folder(folder)
row = store.get_file_row(conn, file_id) row = store.get_file_row(conn, file_id)
if not row: if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404) raise GrottiError("Die Datei fehlt.", "datei", 404)
if row.get("source") != "watch": source = row.get("source") or ""
raise GrottiError( if source not in ("watch", "upload"):
"Nur Dateien aus dem Slice-Ordner lassen sich verschieben.", raise GrottiError("Diese Datei lässt sich nicht verschieben.", "datei")
"watch",
)
src = Path(row.get("stored_path") or "") src = Path(row.get("stored_path") or "")
if not src.is_file(): if not src.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404) raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
dest_dir = path_under_watch(root, folder) dest_dir = path_under_watch(root, folder)
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / src.name dest = dest_dir / src.name
if dest.resolve() == src.resolve(): if dest.resolve() == src.resolve() and source == "watch":
return store.file_out(row, watch_dir=root) return store.file_out(row, watch_dir=root)
if dest.exists(): if dest.exists() and dest.resolve() != src.resolve():
dest = unique_path(dest_dir, src.name) dest = unique_path(dest_dir, src.name)
try: try:
shutil.move(str(src), str(dest)) shutil.move(str(src), str(dest))
@@ -247,11 +291,255 @@ def move_file(conn, cfg, file_id, folder):
name=dest.name, name=dest.name,
size_bytes=stat.st_size, size_bytes=stat.st_size,
mtime=stat.st_mtime, 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) row = store.get_file_row(conn, file_id)
return store.file_out(row, watch_dir=root) 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)
+52
View File
@@ -0,0 +1,52 @@
"""Zentraler Logger für Dienst und journald (stdout)."""
from __future__ import annotations
import logging
import sys
_CONFIGURED = False
LEVELS = {
"critical": logging.CRITICAL,
"error": logging.ERROR,
"warning": logging.WARNING,
"warn": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
}
def parse_level(value, default=logging.INFO):
if isinstance(value, int):
return value
text = str(value or "").strip().lower()
return LEVELS.get(text, default)
def setup(level="INFO"):
"""Root-Logger einmalig auf stdout (systemd → journald)."""
global _CONFIGURED
resolved = parse_level(level)
root = logging.getLogger()
if _CONFIGURED:
root.setLevel(resolved)
return root
root.handlers.clear()
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)s [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root.addHandler(handler)
root.setLevel(resolved)
logging.getLogger("waitress").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
_CONFIGURED = True
return root
def get(name="grotti"):
return logging.getLogger(name)
+52 -13
View File
@@ -57,6 +57,51 @@ def _write_guard():
raise RuntimeError("MCP läuft im Read-only-Modus (GROTTI_MCP_READ_ONLY).") raise RuntimeError("MCP läuft im Read-only-Modus (GROTTI_MCP_READ_ONLY).")
# Wird beim Connect an den Client gegeben (Claude, Cursor, …).
INSTRUCTIONS = """\
Grotti steuert eine 3D-Drucker-Farm (Anycubic Kobra, Klipper/Moonraker).
Antworte dem Nutzer auf Deutsch. Handle nur über diese Tools.
Identität
- Drucker über das Feld host (IP). Den Namen nur zur Anzeige nutzen. Hosts nie raten: erst list_printers oder get_printer.
- Dateien über die numerische id aus list_files (Feld id), nicht über den Dateinamen allein.
- Vor jedem Schreibzugriff den aktuellen Stand lesen.
Lesen
- Farm: list_printers (live=true). Ein Drucker: get_printer(host).
- Läuft: list_running. Warteschlange: list_queue. Ereignisse: list_events.
- Bibliothek: list_files. Spulen: list_spools. Filament-Fehlmenge: bedarf_lesen.
- Kundenaufträge: auftraege_lesen. Verschleißteile: wartung_lesen.
Zustand (Feld state)
- printing: druckt. paused: Pause. standby: frei. complete: fertig, Platte oft noch belegt.
- error: Fehler, nicht einfach neu starten. offline: nicht erreichbar.
- progress ist 0…1. filament_empty oder pause_reason bei Pause heißt Filament leer.
Dann dem Nutzer sagen, die neue Spule in Grotti dem Drucker zuzuweisen, und erst danach resume_printer.
Eine Filament-Pause nicht blind fortsetzen.
Schreiben
confirm=true nur setzen, wenn der Nutzer diese Aktion in diesem Zug klar will. Nicht vorauseilend bestätigen.
- enqueue_when_free: Datei in die Warteschlange. host leer = Grotti wählt den nächsten Drucker mit passender Sorte. host gesetzt = dieser Drucker, sobald er frei ist und die Platte klar ist. Das ist der normale Weg.
- enqueue_print: sofort senden und starten. Nur wenn der Nutzer einen Sofortstart auf einem genannten freien Drucker will (state standby, Platte frei). Große Dateien brauchen oft etwa eine Minute bis zum Start.
- Den Material-Match macht Grotti (PLA Meta ist nicht Matte und nicht Silk). Eine falsche Maschine nicht erzwingen. Eine Ablehnung wörtlich weitergeben.
- bed_leveling=true nur auf ausdrücklichen Wunsch (Bettnetz / Auto-Level).
- pause_printer und resume_printer während des Drucks, ohne confirm.
- cancel_printer bricht den Druck ab und braucht confirm=true. Bei Unklarheit erst nachfragen.
- clear_bed ist nur die Quittung „Platte ist leer“ und schiebt nichts physisch. Erst aufrufen, wenn der Nutzer sagt, das Teil ist herunter. Ohne diese Quittung startet der nächste „wenn frei“-Job nicht.
- steer_temp (Düse und Bett in °C), steer_speed (Prozent), steer_fan (0–100): nur diese Werte. Kein freier G-code.
Grenzen
- Keine G-code-Konsole, kein Firmware-Restart, kein E-Stop, keine Benutzer- oder Token-Verwaltung. Diese Lücken nicht umgehen.
- Im Read-only-Modus (GROTTI_MCP_READ_ONLY) schlagen Schreib-Tools fehl. Das sagen und nicht wiederholen.
- Gesperrte Drucker und ausgeschaltete Automatik starten keine Warteschlangen-Jobs. Den Fehler nennen.
Antworten
- Kurz und konkret: Druckername, host, Datei, Zustand, was du getan hast.
- Den Fehlertext von Grotti zitieren. Keinen Workaround außerhalb der Tools erfinden.
"""
def main(): def main():
try: try:
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
@@ -67,23 +112,17 @@ def main():
) )
sys.exit(1) sys.exit(1)
server = FastMCP( server = FastMCP("grotti", instructions=INSTRUCTIONS)
"grotti",
instructions=(
"Grotti steuert die 3D-Drucker-Farm. Destruktive Aktionen brauchen "
"confirm=true. Kein freier G-code, kein Firmware-Restart."
),
)
@server.tool() @server.tool()
def list_printers(live: bool = True) -> str: def list_printers(live: bool = True) -> str:
"""Druckerliste mit optionalem Live-Zustand.""" """Alle Drucker. live=true liefert Zustand, Temps, Fortschritt, Dateiname. Host ist die IP."""
q = "?live=1" if live else "?live=0" q = "?live=1" if live else "?live=0"
return json.dumps(_api("GET", "/drucker" + q), ensure_ascii=False, indent=2) return json.dumps(_api("GET", "/drucker" + q), ensure_ascii=False, indent=2)
@server.tool() @server.tool()
def get_printer(host: str) -> str: def get_printer(host: str) -> str:
"""Einen Drucker anhand Host/IP aus der Live-Liste.""" """Einen Drucker anhand host (IP) aus der Live-Liste. Host vorher über list_printers holen."""
rows = _api("GET", "/drucker?live=1") or [] rows = _api("GET", "/drucker?live=1") or []
for row in rows: for row in rows:
if row.get("host") == host: if row.get("host") == host:
@@ -115,7 +154,7 @@ def main():
@server.tool() @server.tool()
def list_files(limit: int = 50) -> str: def list_files(limit: int = 50) -> str:
"""Dateien in der Bibliothek.""" """Dateien in der Bibliothek. Die numerische id ist die file_id fürs Einreihen und Starten."""
rows = _api("GET", "/dateien") or [] rows = _api("GET", "/dateien") or []
return json.dumps(rows[: max(1, min(limit, 200))], ensure_ascii=False, indent=2) return json.dumps(rows[: max(1, min(limit, 200))], ensure_ascii=False, indent=2)
@@ -181,7 +220,7 @@ def main():
@server.tool() @server.tool()
def clear_bed(host: str, confirm: bool = False) -> str: def clear_bed(host: str, confirm: bool = False) -> str:
"""Platte als geräumt quittieren (kein physisches Schieben).""" """Quittung, dass der Nutzer die Platte geleert hat. Schiebt nichts. confirm muss true sein. Danach kann die Warteschlange den nächsten Job starten."""
_write_guard() _write_guard()
if not confirm: if not confirm:
raise RuntimeError("Quittierung braucht confirm=true.") raise RuntimeError("Quittierung braucht confirm=true.")
@@ -201,7 +240,7 @@ def main():
confirm: bool = False, confirm: bool = False,
bed_leveling: bool = False, bed_leveling: bool = False,
) -> str: ) -> str:
"""Datei in die Warteschlange („wenn frei“). confirm muss true sein.""" """Datei in die Warteschlange. host leer: Grotti wählt den nächsten Drucker mit passender Sorte. host gesetzt: dieser Drucker, sobald frei und Platte klar. confirm muss true sein. Normaler Weg zum Drucken."""
_write_guard() _write_guard()
if not confirm: if not confirm:
raise RuntimeError("Einreihen braucht confirm=true.") raise RuntimeError("Einreihen braucht confirm=true.")
@@ -220,7 +259,7 @@ def main():
confirm: bool = False, confirm: bool = False,
bed_leveling: bool = False, bed_leveling: bool = False,
) -> str: ) -> str:
"""Sofort senden und starten. confirm muss true sein.""" """Sofort an diesen host senden und starten. Nur bei freiem Drucker (standby, Platte frei) und klarem Sofortstart-Wunsch. confirm muss true sein."""
_write_guard() _write_guard()
if not confirm: if not confirm:
raise RuntimeError("Start braucht confirm=true.") raise RuntimeError("Start braucht confirm=true.")
+3
View File
@@ -0,0 +1,3 @@
-- Auto-Retry und Routing-Hinweise in der Warteschlange.
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS retry_count integer NOT NULL DEFAULT 0;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS wait_reason text NOT NULL DEFAULT '';
+8
View File
@@ -0,0 +1,8 @@
-- PA12-CF bleibt in Grotti die echte Sorte. Der ACE kennt nur PA6-CF.
UPDATE materials
SET material_type = 'PA12-CF'
WHERE name = 'Sunlu PA12-CF' AND material_type = 'PA';
UPDATE spools
SET material_type = 'PA12-CF'
WHERE name = 'Sunlu PA12-CF' AND material_type = 'PA';
+2
View File
@@ -0,0 +1,2 @@
-- Leere/alte Spulen aus der Alltagsliste nehmen
ALTER TABLE spools ADD COLUMN IF NOT EXISTS archived_at timestamptz;
+2
View File
@@ -0,0 +1,2 @@
-- Projekt-Arbeitsmappe: Relativordner unter dem Slice-Pfad
ALTER TABLE projects ADD COLUMN IF NOT EXISTS folder text NOT NULL DEFAULT '';
+122 -10
View File
@@ -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 = {}
@@ -889,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}
@@ -1055,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
@@ -1064,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,
@@ -1137,11 +1244,13 @@ def snapshot(printers):
card.update(query(printer["host"])) card.update(query(printer["host"]))
except Exception as error: except Exception as error:
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus. # Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
print("snapshot %s: %s" % (printer.get("host"), error), flush=True) log.warning("snapshot %s: %s", printer.get("host"), error)
card.update({ card.update({
"ok": False, "ok": False,
"state": "offline", "state": "offline",
"message": str(error), "message": str(error),
"filament_empty": False,
"pause_reason": "",
"camera": "", "camera": "",
"has_camera": False, "has_camera": False,
"fan": None, "fan": None,
@@ -1185,20 +1294,23 @@ 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): def _camera_fields(host, quoted_host):
"""Kamera-URL nur, wenn nicht als tot bekannt (Cache-Miss = erst versuchen).""" """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) cached = CAMERA_CACHE.get(host)
if cached is not None and cached[2] is None: if cached is not None and cached[2] is None:
return {"camera": "", "has_camera": False} return {"camera": "", "camera_stream": "", "has_camera": False}
if cached is not None and cached[2] is not None: if cached is not None and cached[2] is not None:
return { return {"camera": snap, "camera_stream": stream, "has_camera": True}
"camera": "/api/v1/kamera?host=%s" % quoted_host, return {"camera": snap, "camera_stream": stream, "has_camera": None}
"has_camera": True,
}
return {
"camera": "/api/v1/kamera?host=%s" % quoted_host,
"has_camera": None,
}
def embedded_bytes(host, name): def embedded_bytes(host, name):
+6 -1
View File
@@ -34,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"}
+113 -9
View File
@@ -17,6 +17,8 @@ TRAILER_RE = re.compile(rb";\s*referenced_tools\s*=", re.I)
RINKHALS_FINGERPRINT = b"; processed by MmuAcePatcher" 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"):
+193 -23
View File
@@ -10,7 +10,7 @@ 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 from grotti import time_window
@@ -18,6 +18,8 @@ from grotti import time_window
WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…" WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…"
from grotti.events_bus import publish as publish_live from grotti.events_bus import publish as publish_live
log = logutil.get("grotti.queue")
def _notify_queue(): def _notify_queue():
publish_live("queue", {}) publish_live("queue", {})
@@ -111,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):
@@ -144,14 +146,46 @@ class PrintQueueDispatcher:
return (datetime.now(timezone.utc) - updated).total_seconds() return (datetime.now(timezone.utc) - updated).total_seconds()
return 0 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): def _fail_stuck_simple_start(self, conn, item, printer):
host = printer["host"] 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( store.update_print_queue(
conn, conn,
item["id"], item["id"],
{ {
"status": "error", "status": "error",
"message": "Start abgebrochen (Neustart)", "message": msg,
"phase": "", "phase": "",
"phase_started_at": None, "phase_started_at": None,
}, },
@@ -175,7 +209,7 @@ class PrintQueueDispatcher:
try: try:
target(*args) target(*args)
except Exception as exc: except Exception as exc:
print("Queue-Worker %s: %s" % (item_id, exc), flush=True) log.warning("Queue-Worker %s: %s", item_id, exc)
finally: finally:
_notify_queue() _notify_queue()
with self._worker_lock: with self._worker_lock:
@@ -246,13 +280,15 @@ class PrintQueueDispatcher:
continue continue
running = store.running_print_on_host(conn, host) running = store.running_print_on_host(conn, host)
if running: if running:
if not _has_ablauf(running):
if ( if (
not _has_ablauf(running) (running.get("phase") or "").lower() == "starting"
and (running.get("phase") or "").lower() == "starting"
and not self._worker_busy(running["id"]) and not self._worker_busy(running["id"])
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
): ):
self._fail_stuck_simple_start(conn, running, printer) self._fail_stuck_simple_start(conn, running, printer)
else:
self._advance_simple(conn, running, printer, live)
conn.commit() conn.commit()
continue continue
if store.bed_needs_clear(conn, host): if store.bed_needs_clear(conn, host):
@@ -274,6 +310,11 @@ class PrintQueueDispatcher:
if (waiting.get("message") or "") != WINDOW_WAIT_MSG: if (waiting.get("message") or "") != WINDOW_WAIT_MSG:
store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG}) store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG})
continue continue
# Nächster passender / leere Map: ACE aus Spulen bauen oder warten
if not _has_ablauf(waiting):
waiting = self._ensure_ace_map(conn, waiting, host, live)
if not waiting:
continue
claimed = store.claim_print_queue(conn, waiting["id"], host) claimed = store.claim_print_queue(conn, waiting["id"], host)
if not claimed: if not claimed:
continue continue
@@ -285,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"]
@@ -337,15 +421,17 @@ class PrintQueueDispatcher:
from grotti import orders from grotti import orders
orders.attach_order_item_to_live_run(wconn, host, name, oid) orders.attach_order_item_to_live_run(wconn, host, name, oid)
# Phase „starting“ lassen, bis Moonraker printing meldet.
# Sofort print_active + alter complete/standby → fälschlich „Fertig.“
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": "Startet auf %s: %s" % (printer_copy["name"], name),
"host": host, "host": host,
"phase": "", "phase": "starting",
"phase_started_at": None, "phase_started_at": "now",
}, },
) )
self._event( self._event(
@@ -397,6 +483,59 @@ 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…",
},
)
# complete/standby vom vorherigen Auftrag: noch warten, nicht „Fertig.“
return
if phase != "print_active":
return
if state in ("printing", "paused", "heating"):
return
if state == "error":
store.update_print_queue(
conn,
item["id"],
{
"status": "error",
"message": (live.get("message") or "Druckerfehler").strip()[:200],
"phase": "",
"phase_started_at": None,
},
)
return
if state in FREE or state in ("complete", "cancelled"):
store.update_print_queue(
conn,
item["id"],
{
"status": "done",
"message": "Fertig.",
"phase": "",
"phase_started_at": None,
},
)
self._event(
conn,
printer["host"],
printer["name"],
"fertig",
"%s fertig: %s" % (printer["name"], item.get("filename") or "Druck"),
item.get("filename") or "",
)
def _begin_ablauf(self, conn, item, printer, live): def _begin_ablauf(self, conn, item, printer, live):
try: try:
eject.require_kobra_3(printer) eject.require_kobra_3(printer)
@@ -828,43 +967,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):
# TODO: _try_cool_park (bis 60 s G-code) blockiert tick() — ggf. in Worker auslagern. # 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)
item_id = item["id"]
printer_copy = dict(printer)
item_copy = dict(item)
def work():
with connect(self.database_url) as wconn:
try: try:
moonraker.gcode_script(host, eject.cool_script(cool), timeout=30) moonraker.gcode_script(host, eject.cool_script(cool), timeout=30)
# Idle zuerst verlängern, dann Park (sonst Stepper aus → must home).
try: try:
moonraker.gcode_script(host, eject.idle_timeout_script(cool), timeout=15) moonraker.gcode_script(host, eject.idle_timeout_script(cool), timeout=15)
except Exception: except Exception:
pass pass
park = self._try_cool_park(host, item["id"], force=True) park = self._try_cool_park(host, item_id, force=True)
self._keepalive[item["id"]] = time.monotonic() self._keepalive[item_id] = time.monotonic()
park_note = "" park_note = ""
if not park["ok"] and park["error"]: if not park["ok"] and park["error"]:
park_note = " — Mitte fehlgeschlagen: %s" % park["error"] park_note = " — Mitte fehlgeschlagen: %s" % park["error"]
elif park["ok"]: elif park["ok"]:
park_note = " · Düse Mitte" park_note = " · Düse Mitte"
row = store.get_print_queue_row(wconn, item_id) or item_copy
store.update_print_queue(
wconn,
item_id,
{
"message": self._ablauf_message(row, "kühlt%s" % park_note),
"phase": "cooling",
"phase_started_at": datetime.now(timezone.utc),
},
)
wconn.commit()
except GrottiError as exc:
self._fail_ablauf(wconn, item_copy, printer_copy, "Abkühlen: %s" % exc.message)
wconn.commit()
except Exception as exc:
self._fail_ablauf(wconn, item_copy, printer_copy, "Abkühlen: %s" % exc)
wconn.commit()
store.update_print_queue( store.update_print_queue(
conn, conn,
item["id"], item_id,
{ {
"phase": "cooling",
"phase_started_at": "now",
"message": self._ablauf_message( "message": self._ablauf_message(
item, item,
"kühlt ab (Bett ≤ %.0f °C, Düse ≤ %.0f °C)%s" "kühlt ab (Bett ≤ %.0f °C, Düse ≤ %.0f °C)"
% (cool["bed_max"], cool["nozzle_max"], park_note), % (cool["bed_max"], cool["nozzle_max"]),
), ),
"phase": "cooling",
"phase_started_at": datetime.now(timezone.utc),
}, },
) )
self._step_event( self._step_event(
conn, item, printer, conn, item, printer,
"kühlt ab bis Bett ≤ %.0f °C, Düse ≤ %.0f °C%s" "kühlt ab bis Bett ≤ %.0f °C, Düse ≤ %.0f °C" % (cool["bed_max"], cool["nozzle_max"]),
% (cool["bed_max"], cool["nozzle_max"], park_note),
) )
except Exception as exc: self._spawn_worker(item_id, work)
self._fail_ablauf(conn, item, printer, "Abkühlen: %s" % exc)
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)
@@ -1214,6 +1374,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"],
+6
View File
@@ -7,9 +7,12 @@ CREATE TABLE IF NOT EXISTS projects (
notes text NOT NULL DEFAULT '', notes text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'idee', status text NOT NULL DEFAULT 'idee',
quote jsonb NOT NULL DEFAULT '{}'::jsonb, quote jsonb NOT NULL DEFAULT '{}'::jsonb,
folder text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now() created_at timestamptz NOT NULL DEFAULT now()
); );
ALTER TABLE projects ADD COLUMN IF NOT EXISTS folder text NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS spools ( CREATE TABLE IF NOT EXISTS spools (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL, name text NOT NULL,
@@ -123,6 +126,7 @@ ALTER TABLE spools ADD COLUMN IF NOT EXISTS material_id bigint REFERENCES materi
ALTER TABLE spools ADD COLUMN IF NOT EXISTS chargen_nr text; ALTER TABLE spools ADD COLUMN IF NOT EXISTS chargen_nr text;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS hersteller_charge text NOT NULL DEFAULT ''; ALTER TABLE spools ADD COLUMN IF NOT EXISTS hersteller_charge text NOT NULL DEFAULT '';
ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text; ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS archived_at timestamptz;
CREATE UNIQUE INDEX IF NOT EXISTS spools_chargen_nr ON spools (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> ''; CREATE UNIQUE INDEX IF NOT EXISTS spools_chargen_nr ON spools (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
CREATE UNIQUE INDEX IF NOT EXISTS spools_code ON spools (code) WHERE code IS NOT NULL AND code <> ''; CREATE UNIQUE INDEX IF NOT EXISTS spools_code ON spools (code) WHERE code IS NOT NULL AND code <> '';
@@ -172,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';
+579 -40
View File
@@ -20,6 +20,7 @@ def project_out(row, files=None, file_count=None):
"notes": row["notes"], "notes": row["notes"],
"status": row["status"], "status": row["status"],
"quote": row["quote"] or {}, "quote": row["quote"] or {},
"folder": (row.get("folder") or "").strip(),
"created_at": iso(row["created_at"]), "created_at": iso(row["created_at"]),
} }
if files is not None: if files is not None:
@@ -76,6 +77,7 @@ def spool_out(row):
colors = row.get("colors") or [] colors = row.get("colors") or []
if isinstance(colors, str): if isinstance(colors, str):
colors = json.loads(colors) colors = json.loads(colors)
archived = row.get("archived_at")
return { return {
"id": row["id"], "id": row["id"],
"name": row["name"], "name": row["name"],
@@ -93,6 +95,8 @@ def spool_out(row):
"chargen_nr": row.get("chargen_nr") or "", "chargen_nr": row.get("chargen_nr") or "",
"hersteller_charge": row.get("hersteller_charge") or "", "hersteller_charge": row.get("hersteller_charge") or "",
"code": row.get("code") or "", "code": row.get("code") or "",
"archived_at": archived.isoformat() if archived and hasattr(archived, "isoformat") else (archived or None),
"archived": bool(archived),
} }
@@ -176,6 +180,12 @@ def run_out(row):
"chargen_nr": row.get("chargen_nr") or "", "chargen_nr": row.get("chargen_nr") or "",
"message": row.get("message") or "", "message": row.get("message") or "",
"objects": _json_list(row.get("objects")), "objects": _json_list(row.get("objects")),
"cost_per_part": (
(float(row.get("filament_cost") or 0) + float(row.get("energy_cost") or 0))
/ int(row["pieces_ok"])
if int(row.get("pieces_ok") or 0) > 0
else None
),
} }
@@ -203,14 +213,14 @@ def get_project(conn, project_id):
return project_out(row, files=[file_out(item) for item in files]) return project_out(row, files=[file_out(item) for item in files])
def create_project(conn, name, notes, status): def create_project(conn, name, notes, status, folder=""):
row = conn.execute( row = conn.execute(
""" """
INSERT INTO projects (name, notes, status) INSERT INTO projects (name, notes, status, folder)
VALUES (%s, %s, %s) VALUES (%s, %s, %s, %s)
RETURNING * RETURNING *
""", """,
(name, notes, status), (name, notes, status, (folder or "").strip()),
).fetchone() ).fetchone()
return project_out(row, files=[], file_count=0) return project_out(row, files=[], file_count=0)
@@ -218,10 +228,10 @@ def create_project(conn, name, notes, status):
def update_project(conn, project_id, fields): def update_project(conn, project_id, fields):
sets = [] sets = []
values = [] values = []
for key in ("name", "notes", "status"): for key in ("name", "notes", "status", "folder"):
if key in fields: if key in fields:
sets.append("%s = %%s" % key) sets.append("%s = %%s" % key)
values.append(fields[key]) values.append(fields[key] if key != "folder" else (fields[key] or "").strip())
if "quote" in fields: if "quote" in fields:
sets.append("quote = %s") sets.append("quote = %s")
values.append(Json(fields["quote"])) values.append(Json(fields["quote"]))
@@ -237,6 +247,24 @@ def update_project(conn, project_id, fields):
return get_project(conn, project_id) return get_project(conn, project_id)
def list_project_folders(conn):
"""id + folder für Sync (nur Projekte mit Ordner)."""
return conn.execute(
"""
SELECT id, folder FROM projects
WHERE coalesce(folder, '') <> ''
ORDER BY length(folder) DESC, folder
"""
).fetchall()
def set_file_project(conn, file_id, project_id):
conn.execute(
"UPDATE files SET project_id = %s WHERE id = %s",
(project_id, file_id),
)
def delete_project(conn, project_id): def delete_project(conn, project_id):
row = conn.execute("DELETE FROM projects WHERE id = %s RETURNING id", (project_id,)).fetchone() row = conn.execute("DELETE FROM projects WHERE id = %s RETURNING id", (project_id,)).fetchone()
return bool(row) return bool(row)
@@ -335,7 +363,7 @@ def update_file(conn, file_id, fields, watch_dir=None):
return file_out(row, watch_dir=watch_dir) if row else None return file_out(row, watch_dir=watch_dir) if row else None
def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None, mtime=None): def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None, mtime=None, source=None):
"""Pfad nach Verschieben auf der Platte — ID und Zuordnungen bleiben.""" """Pfad nach Verschieben auf der Platte — ID und Zuordnungen bleiben."""
sets = ["stored_path = %s"] sets = ["stored_path = %s"]
values = [stored_path] values = [stored_path]
@@ -348,6 +376,9 @@ def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None,
if mtime is not None: if mtime is not None:
sets.append("mtime = %s") sets.append("mtime = %s")
values.append(mtime) values.append(mtime)
if source is not None:
sets.append("source = %s")
values.append(source)
values.append(file_id) values.append(file_id)
return conn.execute( return conn.execute(
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets), "UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
@@ -434,6 +465,14 @@ def update_spool(conn, spool_id, fields):
if key in fields: if key in fields:
sets.append("%s = %%s" % key) sets.append("%s = %%s" % key)
values.append(Json(fields[key]) if key == "colors" else fields[key]) values.append(Json(fields[key]) if key == "colors" else fields[key])
if "archived_at" in fields:
if fields["archived_at"] is None or fields["archived_at"] is False:
sets.append("archived_at = NULL")
elif fields["archived_at"] is True or fields["archived_at"] == "now":
sets.append("archived_at = now()")
else:
sets.append("archived_at = %s")
values.append(fields["archived_at"])
if not sets: if not sets:
row = get_spool_row(conn, spool_id) row = get_spool_row(conn, spool_id)
return spool_out(row) if row else None return spool_out(row) if row else None
@@ -445,6 +484,23 @@ def update_spool(conn, spool_id, fields):
return spool_out(row) if row else None return spool_out(row) if row else None
def mark_spool_empty(conn, spool_id):
"""Rest auf 0, vom Drucker ins Regal, archivieren."""
row = conn.execute(
"""
UPDATE spools
SET remaining_g = 0,
printer_host = '',
ace_slot = NULL,
archived_at = COALESCE(archived_at, now())
WHERE id = %s
RETURNING *
""",
(spool_id,),
).fetchone()
return spool_out(row) if row else None
def material_out(row): def material_out(row):
s1 = row["volumetric_s1"] s1 = row["volumetric_s1"]
return { return {
@@ -1272,6 +1328,27 @@ def find_live_run(conn, host, filename=""):
).fetchone() ).fetchone()
def find_active_run(conn, host, filename=""):
"""Offener Lauf am Host: Live bevorzugt, sonst offener History-Job (Pause/Resume)."""
return find_live_run(conn, host, filename) or find_open_history_run(conn, host, filename)
def update_send_spool(conn, host, filename, spool_id):
"""Spule am letzten Send dieser Datei korrigieren (Rollenwechsel während Druck)."""
host = (host or "").strip()
if not host or not spool_id:
return None
sent = latest_send(conn, host, filename or "")
if not sent:
return None
if sent.get("spool_id") == spool_id:
return sent
return conn.execute(
"UPDATE sends SET spool_id = %s WHERE id = %s RETURNING *",
(spool_id, sent["id"]),
).fetchone()
def ensure_live_run(conn, host, printer_name, filename, spool_id=None): def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
"""Aktiven Lauf: History-Job bevorzugen, sonst Live — nie parallel beide offen.""" """Aktiven Lauf: History-Job bevorzugen, sonst Live — nie parallel beide offen."""
hist = find_open_history_run(conn, host, filename) hist = find_open_history_run(conn, host, filename)
@@ -1280,7 +1357,7 @@ def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
if live: if live:
adopt_live_run(conn, host, filename or hist.get("filename") or "", hist["id"]) adopt_live_run(conn, host, filename or hist.get("filename") or "", hist["id"])
hist = get_run_row(conn, hist["id"]) or hist hist = get_run_row(conn, hist["id"]) or hist
if spool_id and not hist.get("spool_id"): if spool_id and hist.get("spool_id") != spool_id:
conn.execute("UPDATE runs SET spool_id = %s WHERE id = %s", (spool_id, hist["id"])) conn.execute("UPDATE runs SET spool_id = %s WHERE id = %s", (spool_id, hist["id"]))
hist = get_run_row(conn, hist["id"]) or hist hist = get_run_row(conn, hist["id"]) or hist
return hist return hist
@@ -1292,6 +1369,9 @@ def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
(filename, row["id"]), (filename, row["id"]),
) )
row = get_run_row(conn, row["id"]) row = get_run_row(conn, row["id"])
if spool_id and row and row.get("spool_id") != spool_id:
conn.execute("UPDATE runs SET spool_id = %s WHERE id = %s", (spool_id, row["id"]))
row = get_run_row(conn, row["id"])
return row return row
job_id = "live:%s:%s" % (host, datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")) job_id = "live:%s:%s" % (host, datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S"))
return upsert_run(conn, { return upsert_run(conn, {
@@ -1343,6 +1423,30 @@ def abandon_live_runs(conn, host, keep_id=None):
return len(rows) return len(rows)
def complete_open_run(conn, run_id, state="completed"):
"""Offenen Lauf als beendet markieren (Abräumen / Druckende ohne History)."""
state = (state or "completed").strip() or "completed"
if state not in ("completed", "cancelled", "error", "interrupted"):
state = "completed"
return conn.execute(
"""
UPDATE runs
SET state = %s,
ended_at = COALESCE(ended_at, now()),
duration_s = CASE
WHEN COALESCE(duration_s, 0) > 0 THEN duration_s
WHEN started_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (now() - started_at))
ELSE COALESCE(duration_s, 0)
END
WHERE id = %s
AND state IN ('in_progress', 'printing', 'paused')
RETURNING *
""",
(state, run_id),
).fetchone()
def adopt_live_run(conn, host, filename, real_run_id): def adopt_live_run(conn, host, filename, real_run_id):
"""Proben/Segmente vom Live-Lauf auf den History-Lauf umhängen.""" """Proben/Segmente vom Live-Lauf auf den History-Lauf umhängen."""
live = find_live_run(conn, host, filename) live = find_live_run(conn, host, filename)
@@ -1766,7 +1870,7 @@ def exclusive_until(until):
return until return until
def statistics(conn, since=None, until=None, host="", material="", brand="", project_id=None): def statistics(conn, since=None, until=None, host="", material="", brand="", color="", project_id=None):
where = ["r.state IN ('completed', 'cancelled', 'error', 'interrupted')"] where = ["r.state IN ('completed', 'cancelled', 'error', 'interrupted')"]
values = [] values = []
until_bound = exclusive_until(until) until_bound = exclusive_until(until)
@@ -1784,7 +1888,7 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
values.append(project_id) values.append(project_id)
spool_join = "" spool_join = ""
if material or brand: if material or brand or color:
spool_join = ( spool_join = (
"JOIN run_spools rs0 ON rs0.run_id = r.id " "JOIN run_spools rs0 ON rs0.run_id = r.id "
"JOIN spools s0 ON s0.id = rs0.spool_id" "JOIN spools s0 ON s0.id = rs0.spool_id"
@@ -1795,6 +1899,9 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
if brand: if brand:
where.append("s0.brand ILIKE %s") where.append("s0.brand ILIKE %s")
values.append(brand) values.append(brand)
if color:
where.append("s0.color ILIKE %s")
values.append(color)
clause = " AND ".join(where) clause = " AND ".join(where)
overview = conn.execute( overview = conn.execute(
@@ -1833,6 +1940,16 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
run_where.append("r.project_id = %s") run_where.append("r.project_id = %s")
run_values.append(project_id) run_values.append(project_id)
run_clause = " AND ".join(run_where) run_clause = " AND ".join(run_where)
pieces_row = conn.execute(
"""
SELECT
COALESCE(SUM(r.pieces_ok), 0) AS pieces_ok,
COALESCE(SUM(r.pieces_bad), 0) AS pieces_bad
FROM runs r
WHERE %s
""" % run_clause,
run_values,
).fetchone()
by_printer = conn.execute( by_printer = conn.execute(
""" """
@@ -1858,11 +1975,14 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
if brand: if brand:
mat_where.append("s.brand ILIKE %s") mat_where.append("s.brand ILIKE %s")
mat_values.append(brand) mat_values.append(brand)
if color:
mat_where.append("s.color ILIKE %s")
mat_values.append(color)
mat_clause = " AND ".join(mat_where) mat_clause = " AND ".join(mat_where)
by_material = conn.execute( by_material = conn.execute(
""" """
SELECT s.material_type, s.brand, SELECT s.material_type, s.brand, s.color,
COALESCE(SUM(rs.weight_g), 0) AS weight_g, COALESCE(SUM(rs.weight_g), 0) AS weight_g,
COALESCE(SUM(rs.cost), 0) AS cost, COALESCE(SUM(rs.cost), 0) AS cost,
COUNT(DISTINCT rs.run_id) AS jobs COUNT(DISTINCT rs.run_id) AS jobs
@@ -1870,7 +1990,7 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
JOIN runs r ON r.id = rs.run_id JOIN runs r ON r.id = rs.run_id
JOIN spools s ON s.id = rs.spool_id JOIN spools s ON s.id = rs.spool_id
WHERE %s WHERE %s
GROUP BY s.material_type, s.brand GROUP BY s.material_type, s.brand, s.color
ORDER BY weight_g DESC ORDER BY weight_g DESC
""" % mat_clause, """ % mat_clause,
mat_values, mat_values,
@@ -1904,6 +2024,17 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
ov = overview or {} ov = overview or {}
# Bei Spulen-Join können SUM(DISTINCT …) ungenau sein — Überblick ohne Join neu # Bei Spulen-Join können SUM(DISTINCT …) ungenau sein — Überblick ohne Join neu
if spool_join: if spool_join:
spool_filters = []
spool_vals = list(run_values)
if material:
spool_filters.append("AND s.material_type ILIKE %s")
spool_vals.append(material)
if brand:
spool_filters.append("AND s.brand ILIKE %s")
spool_vals.append(brand)
if color:
spool_filters.append("AND s.color ILIKE %s")
spool_vals.append(color)
overview2 = conn.execute( overview2 = conn.execute(
""" """
SELECT SELECT
@@ -1924,18 +2055,16 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
JOIN spools s ON s.id = rs.spool_id JOIN spools s ON s.id = rs.spool_id
WHERE rs.run_id = r.id WHERE rs.run_id = r.id
%s %s
%s
) )
) )
""" % ( """ % (run_clause, " ".join(spool_filters)),
run_clause, spool_vals,
("AND s.material_type ILIKE %%s" if material else ""),
("AND s.brand ILIKE %%s" if brand else ""),
),
run_values + ([material] if material else []) + ([brand] if brand else []),
).fetchone() ).fetchone()
ov = overview2 or ov ov = overview2 or ov
pieces_ok = int((pieces_row or {}).get("pieces_ok") or 0)
pieces_bad = int((pieces_row or {}).get("pieces_bad") or 0)
pieces_total = pieces_ok + pieces_bad
return { return {
"overview": { "overview": {
"jobs": int(ov.get("jobs") or 0), "jobs": int(ov.get("jobs") or 0),
@@ -1946,6 +2075,10 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
"failed": int(ov.get("failed") or 0), "failed": int(ov.get("failed") or 0),
"scrap": int(ov.get("scrap") or 0), "scrap": int(ov.get("scrap") or 0),
"good": int(ov.get("good") or 0), "good": int(ov.get("good") or 0),
"pieces_ok": pieces_ok,
"pieces_bad": pieces_bad,
"yield_percent": (100.0 * pieces_ok / pieces_total) if pieces_total else None,
"cost_per_part": (float(ov.get("cost") or 0) / pieces_ok) if pieces_ok else None,
}, },
"by_printer": [ "by_printer": [
{ {
@@ -1963,6 +2096,7 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
{ {
"material_type": row["material_type"] or "", "material_type": row["material_type"] or "",
"brand": row["brand"] or "", "brand": row["brand"] or "",
"color": row.get("color") or "",
"weight_g": float(row["weight_g"] or 0), "weight_g": float(row["weight_g"] or 0),
"cost": float(row["cost"] or 0), "cost": float(row["cost"] or 0),
"jobs": int(row["jobs"] or 0), "jobs": int(row["jobs"] or 0),
@@ -2081,20 +2215,28 @@ def create_printer(conn, printer_id, name, host, family):
return printer_out(row) return printer_out(row)
def update_printer(conn, printer_id, name, host, family, locked=None): def update_printer(conn, printer_id, name, host, family, locked=None, start_window=None):
current = printer_by_id(conn, printer_id) current = printer_by_id(conn, printer_id)
if not current: if not current:
return None return None
if locked is None: if locked is None:
locked = bool(current.get("locked")) locked = bool(current.get("locked"))
if start_window is None:
sw = current.get("start_window") or {}
elif isinstance(start_window, dict):
sw = start_window
else:
sw = {}
if not isinstance(sw, dict):
sw = {}
row = conn.execute( row = conn.execute(
""" """
UPDATE printers UPDATE printers
SET name = %s, host = %s, family = %s, locked = %s SET name = %s, host = %s, family = %s, locked = %s, start_window = %s
WHERE id = %s WHERE id = %s
RETURNING * RETURNING *
""", """,
(name, host, family, bool(locked), printer_id), (name, host, family, bool(locked), Json(sw), printer_id),
).fetchone() ).fetchone()
if current["host"] != host: if current["host"] != host:
old = current["host"] old = current["host"]
@@ -2178,7 +2320,11 @@ def event_out(row):
# Protokoll-Ereignisse, die kein Alert sind: gleich als gelesen speichern. # Protokoll-Ereignisse, die kein Alert sind: gleich als gelesen speichern.
QUIET_EVENT_KINDS = {"ablauf", "start", "fertig"} QUIET_EVENT_KINDS = {"ablauf", "start", "fertig", "spule", "auth"}
# Was in der Sidebar als „Meldungen“ zählt.
ALERT_EVENT_KINDS = frozenset({
"fehler", "abbruch", "offline", "queue", "spule", "bedarf", "wartung", "auftrag",
})
def add_event(conn, host, printer, kind, message, filename="", run_id=None): def add_event(conn, host, printer, kind, message, filename="", run_id=None):
@@ -2224,19 +2370,29 @@ def list_events(conn, limit=40, unread_only=False, host=None, kinds=None, since=
return [event_out(row) for row in rows] return [event_out(row) for row in rows]
def unread_event_count(conn): def unread_event_count(conn, kinds=None):
if kinds:
return conn.execute(
"SELECT COUNT(*) AS n FROM events WHERE read_at IS NULL AND kind = ANY(%s)",
(list(kinds),),
).fetchone()["n"]
return conn.execute("SELECT COUNT(*) AS n FROM events WHERE read_at IS NULL").fetchone()["n"] return conn.execute("SELECT COUNT(*) AS n FROM events WHERE read_at IS NULL").fetchone()["n"]
def mark_events_read(conn, event_ids=None): def mark_events_read(conn, event_ids=None, kinds=None):
if event_ids: if event_ids:
conn.execute( conn.execute(
"UPDATE events SET read_at = now() WHERE read_at IS NULL AND id = ANY(%s)", "UPDATE events SET read_at = now() WHERE read_at IS NULL AND id = ANY(%s)",
(list(event_ids),), (list(event_ids),),
) )
elif kinds:
conn.execute(
"UPDATE events SET read_at = now() WHERE read_at IS NULL AND kind = ANY(%s)",
(list(kinds),),
)
else: else:
conn.execute("UPDATE events SET read_at = now() WHERE read_at IS NULL") conn.execute("UPDATE events SET read_at = now() WHERE read_at IS NULL")
return unread_event_count(conn) return unread_event_count(conn, kinds=kinds or list(ALERT_EVENT_KINDS))
def recent_event_kind(conn, host, kind, within_s=60): def recent_event_kind(conn, host, kind, within_s=60):
@@ -2315,6 +2471,8 @@ def print_queue_out(row, file_name=None):
"phase_started_at": iso(row.get("phase_started_at")), "phase_started_at": iso(row.get("phase_started_at")),
"is_ablauf": bool(steps), "is_ablauf": bool(steps),
"order_item_id": row.get("order_item_id"), "order_item_id": row.get("order_item_id"),
"retry_count": int(row.get("retry_count") or 0),
"wait_reason": row.get("wait_reason") or "",
"created_at": iso(row["created_at"]), "created_at": iso(row["created_at"]),
"updated_at": iso(row["updated_at"]), "updated_at": iso(row["updated_at"]),
} }
@@ -2490,6 +2648,8 @@ def update_print_queue(conn, item_id, fields):
"spool_id": "spool_id", "spool_id": "spool_id",
"bed_leveling": "bed_leveling", "bed_leveling": "bed_leveling",
"override_machine": "override_machine", "override_machine": "override_machine",
"retry_count": "retry_count",
"wait_reason": "wait_reason",
} }
sets = [] sets = []
values = [] values = []
@@ -2530,32 +2690,334 @@ def update_print_queue(conn, item_id, fields):
return print_queue_out(fresh, fresh.get("filename")) if fresh else None return print_queue_out(fresh, fresh.get("filename")) if fresh else None
def next_waiting_print(conn, host=None, family=None): _PROFILE_VARIANTS = frozenset({"meta", "matte", "matt", "silk", "plus", "+", "hf", "cf"})
if host: _PROFILE_TYPES = frozenset({"pla", "petg", "abs", "asa", "tpu", "pa", "pc", "hips", "pva"})
row = conn.execute(
def _profile_tokens(profile):
return (profile or "").strip().lower().replace("_", " ").replace("-", " ").split()
def _profile_has_variant(profile):
return bool(_PROFILE_VARIANTS.intersection(_profile_tokens(profile)))
def _profile_base_type(profile):
"""Letzter Material-Typ im Profilnamen (Sunlu PLA → pla)."""
for token in reversed(_profile_tokens(profile)):
if token in _PROFILE_TYPES:
return token
return ""
def _profile_matches(need_profile, spool_name):
"""Sorte: exakter Katalogname (Meta ≠ Matte ≠ PLA). Zusatz nur hinter dem Namen."""
a = (need_profile or "").strip().lower()
b = (spool_name or "").strip().lower()
if not a:
return True
if not b:
return False
if a == b:
return True
# Spulen-Anzeigename: "Sunlu PLA Meta schwarz" — nicht "Sunlu PLA" ⊂ "Sunlu PLA Matte"
if b.startswith(a) and len(b) > len(a) and not b[len(a)].isalnum():
rest = b[len(a):].lstrip(" ·-–_|/")
# Nächstes Wort darf keine bekannte PLA-/PETG-Variante sein
first = (rest.split() or [""])[0]
if first in _PROFILE_VARIANTS:
return False
return True
return False
def _need_label(need):
profile = (need.get("profile") or "").strip()
if profile:
return profile
kind = (need.get("material_type") or "").strip()
color = (need.get("color") or "").strip()
return (kind + ((" " + color) if color else "")).strip() or "Material"
def _spool_material_set(conn, host):
rows = conn.execute(
""" """
SELECT q.*, f.name AS filename SELECT lower(coalesce(s.material_type, '')) AS mt,
lower(coalesce(s.color, '')) AS color,
coalesce(s.remaining_g, 0) AS remaining_g,
lower(coalesce(m.name, s.name, '')) AS material_name,
s.ace_slot AS ace_slot,
s.id AS spool_id
FROM spools s
LEFT JOIN materials m ON m.id = s.material_id
WHERE s.printer_host = %s
AND s.archived_at IS NULL
AND coalesce(s.remaining_g, 0) > 0.5
""",
(host or "",),
).fetchall()
return [
{
"material_type": (r["mt"] or "").strip(),
"color": (r["color"] or "").strip(),
"remaining_g": float(r["remaining_g"] or 0),
"material_name": (r["material_name"] or "").strip(),
"ace_slot": r["ace_slot"],
"spool_id": r["spool_id"],
}
for r in rows
]
def _file_needs(conn, file_id):
if not file_id:
return [], 0.0
row = conn.execute(
"SELECT filaments, weight_g FROM files WHERE id = %s",
(file_id,),
).fetchone()
if not row:
return [], 0.0
filaments = row.get("filaments") or []
if isinstance(filaments, str):
try:
import json
filaments = json.loads(filaments)
except Exception:
filaments = []
types = []
for index, item in enumerate(filaments if isinstance(filaments, list) else []):
if isinstance(item, dict):
t = (item.get("type") or item.get("material_type") or "").strip().lower()
c = (item.get("color") or "").strip().lower()
profile = (item.get("profile") or "").strip()
try:
paint = int(item.get("paint_index"))
except (TypeError, ValueError):
paint = index
if t or profile:
types.append({
"material_type": t,
"color": c,
"profile": profile,
"paint_index": paint,
})
elif isinstance(item, str) and item.strip():
types.append({
"material_type": item.strip().lower(),
"color": "",
"profile": "",
"paint_index": index,
})
return types, float(row.get("weight_g") or 0)
def _prefer_color(spools, need_color):
"""Farbe soft: Slice-Hex ignorieren; benannte Farbe bevorzugen, nie blockieren."""
from grotti.bedarf import _is_slicer_hex
need_c = (need_color or "").strip().lower()
if not need_c or _is_slicer_hex(need_c) or not spools:
return list(spools)
preferred = [
h for h in spools
if not (h.get("color") or "").strip() or (h.get("color") or "").strip().lower() == need_c
]
return preferred or list(spools)
def _match_spools_for_need(need, have, used_ids=None):
used = used_ids or set()
candidates = [h for h in have if h.get("spool_id") not in used]
profile = (need.get("profile") or "").strip()
def _by_type(kind):
kind = (kind or "").strip().lower()
return [
h for h in candidates
if not kind or h["material_type"] == kind
]
if profile:
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
if by_profile:
return _prefer_color(by_profile, need.get("color"))
# Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht
# (Sunlu PLA ↔ Eryone PLA Lite; Slice-Hex #26A69A blockiert nicht „Grau“).
if not _profile_has_variant(profile):
kind = _profile_base_type(profile) or (need.get("material_type") or "")
if kind:
typed = [
h for h in _by_type(kind)
if not _profile_has_variant(h.get("material_name") or "")
]
return _prefer_color(typed, need.get("color"))
return []
return _prefer_color(_by_type(need.get("material_type") or ""), need.get("color"))
def material_fit_score(conn, host, file_id):
"""Höher = besser. -1 = warten (falsche Sorte mit Profil, oder zu wenig Rest)."""
need, weight = _file_needs(conn, file_id)
have = _spool_material_set(conn, host)
if not need:
return 0, ""
if not have:
# Mit Profil: ohne Spule warten. Ohne Profil: soft (wie bisher).
if any((n.get("profile") or "").strip() for n in need):
return -1, "braucht %s" % _need_label(need[0])
return 0, ""
has_profiles = any((n.get("profile") or "").strip() for n in need)
if has_profiles:
score = 20
used = set()
for n in need:
matched = _match_spools_for_need(n, have, used)
if not matched:
return -1, "braucht %s" % _need_label(n)
best = max(matched, key=lambda h: h["remaining_g"])
used.add(best.get("spool_id"))
if n.get("color") and any(h["color"] == n["color"] for h in matched):
score += 5
if weight and best["remaining_g"] + 1e-6 >= weight:
score += 5
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
return -1, "braucht %s (%s g fehlen)" % (
_need_label(n),
int(max(0, weight - max(h["remaining_g"] for h in matched))),
)
return score, ""
have_types = {h["material_type"] for h in have if h["material_type"]}
need_types = {n["material_type"] for n in need if n["material_type"]}
# Ohne Profil: falscher Typ hält den Job nicht fest (Sofort-Start warnt in der UI).
score = 0 if (need_types and have_types and need_types.isdisjoint(have_types)) else 10
for n in need:
matched = _match_spools_for_need(n, have)
if matched:
score += 5
if weight and any(h["remaining_g"] + 1e-6 >= weight for h in matched):
score += 5
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
return -1, "braucht %s (%s g fehlen)" % (
_need_label(n),
int(max(0, weight - max(h["remaining_g"] for h in matched))),
)
return score, ""
def auto_ace_map(conn, host, file_id):
"""ACE-Map aus eingelegten Spulen: Sorte (Profil) zuerst, Farbe soft.
Rückgabe: (mapping, reason). reason gesetzt → nicht starten, warten.
Ohne ACE-Spulen am Host: leere Map und kein Grund (externe Rolle).
"""
need, _weight = _file_needs(conn, file_id)
have = [h for h in _spool_material_set(conn, host) if h.get("ace_slot")]
if not need:
return [], ""
if not have:
return [], ""
mapping = []
used = set()
for n in need:
matched = _match_spools_for_need(n, have, used)
# Nur ACE-Plätze
matched = [h for h in matched if h.get("ace_slot")]
if not matched:
return [], "braucht %s auf ACE" % _need_label(n)
# Farbe bevorzugen, dann Rest
matched.sort(
key=lambda h: (
0 if (n.get("color") and h["color"] == n["color"]) else 1,
-h["remaining_g"],
)
)
chosen = matched[0]
used.add(chosen.get("spool_id"))
try:
paint = int(n.get("paint_index"))
except (TypeError, ValueError):
paint = len(mapping)
ams = int(chosen["ace_slot"]) - 1
if ams < 0 or ams > 7:
return [], "ACE-Platz von %s ungültig" % _need_label(n)
mapping.append({"paint_index": paint, "ams_index": ams})
return mapping, ""
def next_waiting_print(conn, host=None, family=None):
"""Nächster wartender Druck: Termin (due_at) vor Priorität, Material-Match bevorzugt."""
if host:
rows = conn.execute(
"""
SELECT q.*, f.name AS filename, o.due_at AS order_due_at
FROM print_queue q FROM print_queue q
LEFT JOIN files f ON f.id = q.file_id LEFT JOIN files f ON f.id = q.file_id
LEFT JOIN order_items oi ON oi.id = q.order_item_id
LEFT JOIN orders o ON o.id = oi.order_id
WHERE q.status = 'waiting' WHERE q.status = 'waiting'
AND (q.host = %s OR (q.host = '' AND (q.family = '' OR q.family = %s))) AND (q.host = %s OR (q.host = '' AND (q.family = '' OR q.family = %s)))
ORDER BY q.priority ASC, q.id ASC ORDER BY
LIMIT 1 CASE WHEN o.due_at IS NULL THEN 1 ELSE 0 END,
o.due_at ASC NULLS LAST,
q.priority ASC,
q.id ASC
LIMIT 20
""", """,
(host, family or ""), (host, family or ""),
).fetchone() ).fetchall()
else: else:
row = conn.execute( rows = conn.execute(
""" """
SELECT q.*, f.name AS filename SELECT q.*, f.name AS filename, o.due_at AS order_due_at
FROM print_queue q FROM print_queue q
LEFT JOIN files f ON f.id = q.file_id LEFT JOIN files f ON f.id = q.file_id
LEFT JOIN order_items oi ON oi.id = q.order_item_id
LEFT JOIN orders o ON o.id = oi.order_id
WHERE q.status = 'waiting' WHERE q.status = 'waiting'
ORDER BY q.priority ASC, q.id ASC ORDER BY
LIMIT 1 CASE WHEN o.due_at IS NULL THEN 1 ELSE 0 END,
o.due_at ASC NULLS LAST,
q.priority ASC,
q.id ASC
LIMIT 20
""", """,
).fetchone() ).fetchall()
return row if not rows:
return None
if not host:
return rows[0]
best = None
best_score = None
for row in rows:
score, reason = material_fit_score(conn, host, row.get("file_id"))
if score < 0:
# Wartegrund setzen, aber anderen Job bevorzugen
if (row.get("wait_reason") or "") != reason or (row.get("message") or "") != reason:
conn.execute(
"UPDATE print_queue SET wait_reason = %s, message = %s, updated_at = now() WHERE id = %s",
(reason, reason, row["id"]),
)
continue
if reason == "" and row.get("wait_reason"):
conn.execute(
"""
UPDATE print_queue
SET message = CASE WHEN message = wait_reason THEN '' ELSE message END,
wait_reason = '',
updated_at = now()
WHERE id = %s
""",
(row["id"],),
)
if best is None or score > best_score:
best = row
best_score = score
return best
def running_print_on_host(conn, host): def running_print_on_host(conn, host):
@@ -2625,7 +3087,10 @@ def ensure_bed_after_print(conn, host, ended_at=None, message=""):
Deckt verpasste Watcher-Übergänge ab (Neustart, Poll-Lücke): History und Deckt verpasste Watcher-Übergänge ab (Neustart, Poll-Lücke): History und
erster Blick auf state=complete setzen needs_clear nur, wenn cleared_at erster Blick auf state=complete setzen needs_clear nur, wenn cleared_at
vor dem Druckende liegt oder noch fehlt. vor dem Druckende liegt oder noch fehlt. Ohne Endzeit und mit bestehender
Quittierung nicht neu belegen — sonst setzt ein Neustart bereits
Quittiertes wieder (cleared_at geht verloren). Live-Übergänge setzen
needs_clear direkt per set_bed_needs_clear.
""" """
host = (host or "").strip() host = (host or "").strip()
if not host: if not host:
@@ -2639,6 +3104,9 @@ def ensure_bed_after_print(conn, host, ended_at=None, message=""):
cleared = row["cleared_at"] if row else None cleared = row["cleared_at"] if row else None
if ended_at is not None and cleared is not None and cleared >= ended_at: if ended_at is not None and cleared is not None and cleared >= ended_at:
return False return False
# Neustart / erster Blick ohne Endzeit: schon quittiert → stehen lassen
if ended_at is None and cleared is not None:
return False
set_bed_needs_clear(conn, host, True, message or "Druckende") set_bed_needs_clear(conn, host, True, message or "Druckende")
return True return True
@@ -2770,3 +3238,74 @@ def revoke_api_token(conn, token_id, user_id=None):
(token_id, user_id), (token_id, user_id),
).fetchone() ).fetchone()
def global_search(conn, query, limit=20):
"""Drucker, Dateien, Spulen, Chargen, Aufträge für Ctrl+K."""
q = (query or "").strip()
if not q:
return []
like = "%" + q + "%"
items = []
for row in conn.execute(
"SELECT host, name FROM printers WHERE name ILIKE %s OR host ILIKE %s ORDER BY name LIMIT 5",
(like, like),
).fetchall():
items.append({
"kind": "drucker",
"title": row["name"] or row["host"],
"subtitle": row["host"],
"href": "/#" + row["host"],
})
for row in conn.execute(
"SELECT id, name FROM files WHERE name ILIKE %s ORDER BY mtime DESC NULLS LAST, id DESC LIMIT 6",
(like,),
).fetchall():
items.append({
"kind": "datei",
"title": row["name"],
"subtitle": "Datei",
"href": "/dateien?file=" + str(row["id"]),
})
for row in conn.execute(
"""
SELECT id, name, chargen_nr, code, material_type, color
FROM spools
WHERE name ILIKE %s OR chargen_nr ILIKE %s OR code ILIKE %s
OR material_type ILIKE %s OR color ILIKE %s
ORDER BY id DESC LIMIT 6
""",
(like, like, like, like, like),
).fetchall():
items.append({
"kind": "spule",
"title": row["name"],
"subtitle": " · ".join(
x for x in [row.get("chargen_nr") or row.get("code"), row.get("material_type"), row.get("color")] if x
),
"href": "/material?spool=" + str(row["id"]),
})
if q.upper().startswith("D-") or q.upper().startswith("S-"):
code = q.upper()
run = conn.execute(
"SELECT id, chargen_nr, filename FROM runs WHERE chargen_nr ILIKE %s ORDER BY id DESC LIMIT 3",
(code + "%",),
).fetchall()
for row in run:
items.append({
"kind": "charge",
"title": row["chargen_nr"],
"subtitle": row.get("filename") or "Druck",
"href": "/protokoll/" + str(row["id"]),
})
for row in conn.execute(
"SELECT id, title, status FROM orders WHERE title ILIKE %s ORDER BY updated_at DESC LIMIT 5",
(like,),
).fetchall():
items.append({
"kind": "auftrag",
"title": row["title"],
"subtitle": row["status"],
"href": "/auftraege",
})
return items[: int(limit)]
+14 -6
View File
@@ -53,15 +53,23 @@ def planned_shares(metadata, ace_map, target_count):
weights = weight_list(metadata) weights = weight_list(metadata)
mapping = ace_map if isinstance(ace_map, list) else [] mapping = ace_map if isinstance(ace_map, list) else []
if weights and mapping and len(weights) >= len(mapping) and target_count == len(mapping): if weights and mapping and len(weights) >= len(mapping) and target_count == len(mapping):
shares = [] paints = []
for item in mapping: for item in mapping:
paint = item.get("paint_index")
try: try:
index = int(paint) paints.append(int(item.get("paint_index")))
except (TypeError, ValueError): except (TypeError, ValueError):
index = len(shares) paints.append(-1)
if 0 <= index < len(weights): # Nur bei dichter 0…n−1-Map paint_index = Gewichtsindex; sonst Map-Reihenfolge
shares.append(weights[index]) # (Bambu/Anycubic oft T1+T10 bei zwei kompakten Gewichten).
dense = sorted(paints) == list(range(len(paints))) and all(
0 <= paint < len(weights) for paint in paints
)
shares = []
for order, paint in enumerate(paints):
if dense:
shares.append(weights[paint])
elif order < len(weights):
shares.append(weights[order])
else: else:
shares.append(0.0) shares.append(0.0)
if sum(shares) > 0: if sum(shares) > 0:
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "3.0.0" __version__ = "3.5.1"
+93 -14
View File
@@ -6,10 +6,12 @@ import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from grotti import moonraker, notify, store, usage from grotti import jobs, logutil, moonraker, notify, store, usage
from grotti.events_bus import publish as publish_live from grotti.events_bus import publish as publish_live
from grotti.db import connect from grotti.db import connect
log = logutil.get("grotti.watcher")
SAMPLE_EVERY_S = 30 SAMPLE_EVERY_S = 30
SAMPLE_HEAT_S = 10 SAMPLE_HEAT_S = 10
@@ -59,7 +61,7 @@ class StateWatcher:
try: try:
self.tick() self.tick()
except Exception as exc: except Exception as exc:
print("Watcher: %s" % exc, flush=True) log.warning("Watcher: %s", exc)
self._stop.wait(self.interval) self._stop.wait(self.interval)
def tick(self): def tick(self):
@@ -95,6 +97,30 @@ class StateWatcher:
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
@@ -139,11 +165,22 @@ class StateWatcher:
) )
self._ensure_start_segment(conn, host, filename, live, 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":
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) self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
elif prev_state == "paused" and state == "printing": elif prev_state == "paused" and state == "printing":
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id) self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id)
elif prev_state == "printing" and state == "complete": elif prev_state == "printing" and state == "complete":
store.ensure_bed_after_print(conn, host, message=filename or "fertig") # Live-Übergang: immer belegen (ensure_bed_after_print ohne Endzeit
# würde nach Quittierung eines älteren Drucks stehen bleiben).
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
self._emit( self._emit(
conn, conn,
host, host,
@@ -158,27 +195,49 @@ class StateWatcher:
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.ensure_bed_after_print(conn, host, message=filename or state) dangling = store.find_live_run(conn, host)
if dangling:
closed = store.complete_open_run(conn, dangling["id"], state="completed")
if closed:
try:
from grotti import jobs
jobs.apply_deduction(conn, closed, self.cfg["energy"])
except Exception as exc:
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( self._emit(
conn, conn,
host, host,
name, name,
"abbruch", "abbruch",
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"), "%s: Druck abgebrochen (%s)" % (
filename, name, file_label if file_label != state else "ohne Datei",
run_id or prev.get("run_id"), ),
file_label if file_label != state else "",
run_id or prev.get("run_id") or (dangling or {}).get("id"),
) )
try: try:
from grotti import jobs 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 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.ensure_bed_after_print(conn, host, message=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"
@@ -197,12 +256,30 @@ class StateWatcher:
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 state == "error": elif state == "error":
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0: if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
self._cool_heaters(host) self._cool_heaters(host)
elif state == "complete" and (not prev_state or prev_offline): elif state == "complete" and (not prev_state or prev_offline):
store.ensure_bed_after_print(conn, host, message=filename or "fertig") # Erster Blick nach Neustart: nur mit Endzeit des letzten Laufs,
# sonst wird bereits Quittiertes wieder „Platte belegt“.
ended = conn.execute(
"""
SELECT ended_at FROM runs
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()
store.ensure_bed_after_print(
conn,
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:
@@ -246,6 +323,8 @@ class StateWatcher:
return None return None
sent = store.latest_send(conn, host, filename) if filename else None sent = store.latest_send(conn, host, filename) if filename else None
spool_id = (sent or {}).get("spool_id") spool_id = (sent or {}).get("spool_id")
# Send kann nach Rollenwechsel veraltet sein — aktuelle Rolle am Host bevorzugt
spool_id = jobs.resolve_tracking_spool(conn, host, spool_id, ace_map=(sent or {}).get("ace_map"))
run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id) run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id)
objects = self._merge_objects(run.get("objects"), live.get("exclude")) objects = self._merge_objects(run.get("objects"), live.get("exclude"))
planned = usage.as_float(live.get("weight")) planned = usage.as_float(live.get("weight"))
@@ -359,7 +438,7 @@ class StateWatcher:
try: try:
moonraker.heaters_off(host) moonraker.heaters_off(host)
except Exception as exc: except Exception as exc:
print("Watcher Heizung aus %s: %s" % (host, exc), flush=True) log.warning("Watcher Heizung aus %s: %s", host, exc)
def _check_spool(self, conn, host, name, filename, live): def _check_spool(self, conn, host, name, filename, live):
need = float(live.get("weight") or 0) need = float(live.get("weight") or 0)
+84
View File
@@ -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"
+56
View File
@@ -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
}
}
+1
View File
@@ -0,0 +1 @@
/etc/grotti/config.json
+45
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
set -e
case "$1" in
purge)
rm -f /etc/grotti/config.json
rmdir /etc/grotti 2>/dev/null || true
# Daten und DB bleiben absichtlich (Backup). Manuell:
# sudo rm -rf /var/lib/grotti
# sudo -u postgres dropdb grotti; sudo -u postgres dropuser grotti
;;
remove)
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
fi
;;
esac
exit 0
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -e
case "$1" in
remove|upgrade|deconfigure)
if command -v systemctl >/dev/null 2>&1; then
systemctl stop grotti.service 2>/dev/null || true
systemctl disable grotti.service 2>/dev/null || true
fi
;;
esac
exit 0
View File
Whitespace-only changes.
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=Grotti Farm-Manager
Documentation=file:///usr/share/doc/grotti/Anleitung-Installation-Pi.md
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service
[Service]
Type=simple
User=grotti
Group=grotti
WorkingDirectory=/usr/lib/grotti
Environment=GROTTI_CONFIG=/etc/grotti/config.json
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/lib/grotti/venv/bin/python /usr/lib/grotti/server.py
Restart=on-failure
RestartSec=5
# Logs → journald (journalctl -u grotti -f)
StandardOutput=journal
StandardError=journal
SyslogIdentifier=grotti
[Install]
WantedBy=multi-user.target
+207
View File
@@ -0,0 +1,207 @@
"""Lucide-Icons als lokales SVG-Sprite bauen.
python scripts/build_icons.py
Schreibt web/static/icons.svg. IDs bleiben i-<grotti-name> (bestehende Templates).
"""
from __future__ import annotations
import re
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "web" / "static" / "icons.svg"
CDN = "https://unpkg.com/[email protected]/icons/{name}.svg"
# grotti-id → lucide dateiname (ohne .svg)
ICONS: dict[str, str] = {
# bestehend
"printer": "printer",
"camera": "camera",
"file": "file",
"spool": "disc-3",
"folder": "folder",
"folder-open": "folder-open",
"list": "list",
"activity": "activity",
"clock": "clock",
"check": "check",
"alert": "triangle-alert",
"flame": "flame",
"pause": "pause",
"play": "play",
"stop": "square",
"layers": "layers",
"x": "x",
"up": "chevron-up",
"down": "chevron-down",
"upload": "upload",
"refresh": "refresh-cw",
"queue": "list-ordered",
"send": "send",
"book": "book-open",
"cube": "box",
"bell": "bell",
"sweep": "brush",
"thermo": "thermometer",
"settings": "settings",
"bulb": "lightbulb",
"search": "search",
"filter": "filter",
"info": "info",
"scan": "scan-line",
"trash": "trash-2",
"inbox": "inbox",
"image": "image",
"grid": "layout-grid",
"menu": "menu",
"panel": "panel-left",
"nozzle": "pipette",
"bed": "rectangle-horizontal",
"chamber": "box",
"fan": "fan",
"fan-chamber": "air-vent",
"fan-aux": "wind",
# neu für Polish
"plus": "plus",
"minus": "minus",
"edit": "pencil",
"save": "save",
"copy": "copy",
"external": "external-link",
"user": "user",
"users": "users",
"key": "key",
"lock": "lock",
"unlock": "lock-open",
"wifi": "wifi",
"wifi-off": "wifi-off",
"power": "power",
"zap": "zap",
"gauge": "gauge",
"chart": "chart-column",
"calendar": "calendar",
"timer": "timer",
"weight": "weight",
"package": "package",
"tag": "tag",
"hash": "hash",
"link": "link",
"download": "download",
"more": "ellipsis",
"chevron-right": "chevron-right",
"chevron-left": "chevron-left",
"home": "house",
"wrench": "wrench",
"hard-drive": "hard-drive",
"server": "server",
"message": "message-circle",
"mail": "mail",
"shield": "shield",
"sliders": "sliders-horizontal",
"palette": "palette",
"moon": "moon",
"sun": "sun",
"monitor": "monitor",
"smartphone": "smartphone",
"keyboard": "keyboard",
"undo": "undo-2",
"redo": "redo-2",
"circle-check": "circle-check",
"circle-x": "circle-x",
"circle-alert": "circle-alert",
"loader": "loader-circle",
"sparkles": "sparkles",
"clipboard": "clipboard-list",
"receipt": "receipt",
"truck": "truck",
"droplet": "droplet",
"percent": "percent",
"euro": "euro",
"arrow-up": "arrow-up",
"arrow-down": "arrow-down",
"history": "history",
"eye": "eye",
"eye-off": "eye-off",
"star": "star",
"heart": "heart",
"ban": "ban",
"rotate-ccw": "rotate-ccw",
"brush": "brush",
}
def fetch(name: str) -> str:
url = CDN.format(name=name)
req = urllib.request.Request(url, headers={"User-Agent": "Grotti-icon-build/1"})
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode("utf-8")
def to_symbol(grotti: str, svg: str) -> str:
# Lucide: optional comment + <svg ...>paths</svg>
text = re.sub(r"<!--.*?-->", "", svg, flags=re.DOTALL).strip()
match = re.search(r"<svg\b[^>]*>(.*)</svg>\s*$", text, flags=re.DOTALL | re.IGNORECASE)
if not match:
raise ValueError("kein SVG-Root")
inner = match.group(1).strip()
# verschachtelte svg-Attribute/Klassen aus Kind-Elementen nicht nötig
return (
f' <symbol id="i-{grotti}" viewBox="0 0 24 24" fill="none" '
f'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
f'stroke-linejoin="round">\n {inner}\n </symbol>'
)
def main() -> None:
symbols = []
failed = []
for grotti, lucide in sorted(ICONS.items()):
try:
raw = fetch(lucide)
symbols.append(to_symbol(grotti, raw))
print("ok", grotti, "<-", lucide)
except urllib.error.HTTPError as exc:
failed.append((grotti, lucide, str(exc)))
print("FAIL", grotti, lucide, exc.code)
except Exception as exc:
failed.append((grotti, lucide, str(exc)))
print("FAIL", grotti, lucide, type(exc).__name__)
if failed and len(failed) == len(ICONS):
raise SystemExit("Keine Icons geladen.")
# Fallbacks für fehlgeschlagene: leeres symbol vermeiden — check als Ersatz
ok_ids = {s.split('id="i-')[1].split('"')[0] for s in symbols}
for grotti, lucide, _ in failed:
if grotti in ok_ids:
continue
# einfaches X als Platzhalter
symbols.append(
f' <symbol id="i-{grotti}" viewBox="0 0 24 24" fill="none" '
f'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
f'stroke-linejoin="round">\n'
f' <path d="M18 6 6 18"/><path d="m6 6 12 12"/>\n </symbol>'
)
print("placeholder", grotti)
body = "\n".join(symbols)
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n'
f'<!-- Lucide icons, built by scripts/build_icons.py — {len(symbols)} symbols -->\n'
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">\n'
f"{body}\n"
"</svg>\n",
encoding="utf-8",
)
print("wrote", OUT, "symbols", len(symbols))
if failed:
print("warnings:", len(failed))
if __name__ == "__main__":
main()
+184 -4
View File
@@ -13,8 +13,11 @@ from __future__ import annotations
import json import json
import mimetypes import mimetypes
import os import os
import re
import shutil import shutil
import subprocess
import sys import sys
import time
import uuid import uuid
import webbrowser import webbrowser
from pathlib import Path from pathlib import Path
@@ -25,7 +28,19 @@ from urllib.request import Request, urlopen
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
CONFIG_PATH = REPO_ROOT / "data" / "orca-senden.json" CONFIG_PATH = REPO_ROOT / "data" / "orca-senden.json"
DEBUG_LOG = REPO_ROOT / "data" / "orca-senden-last.log" DEBUG_LOG = REPO_ROOT / "data" / "orca-senden-last.log"
BROWSER_LOCK = REPO_ROOT / "data" / "orca-senden-browser.lock"
FOCUS_PATH = REPO_ROOT / "data" / "orca-senden-focus.json"
APP_PROFILE = REPO_ROOT / "data" / "grotti-app-profile"
ALLOWED_SUFFIXES = (".gcode", ".gcode.3mf", ".3mf", ".gcode.pp", ".pp") ALLOWED_SUFFIXES = (".gcode", ".gcode.3mf", ".3mf", ".gcode.pp", ".pp")
BROWSER_DEBOUNCE_S = 25.0
# Anycubic: MMDD-HHMM-Name.gcode — Zeitstempel raus, sonst jedes Export ein neues Fenster
_TIME_PREFIX_RE = re.compile(r"^\d{4}-\d{4}-")
_CHROMIUM_CANDIDATES = (
Path(r"C:\Program Files\Google\Chrome\Application\chrome.exe"),
Path(r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"),
Path(os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe")),
Path(os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe")),
)
def show_error(message: str) -> None: def show_error(message: str) -> None:
@@ -76,7 +91,10 @@ def load_config(path: Path = CONFIG_PATH) -> dict[str, str]:
raise ValueError("Config: url fehlt (z. B. http://127.0.0.1:8770).") raise ValueError("Config: url fehlt (z. B. http://127.0.0.1:8770).")
if not token: if not token:
raise ValueError("Config: token fehlt (grt_… aus Einstellungen).") raise ValueError("Config: token fehlt (grt_… aus Einstellungen).")
return {"url": url, "token": token} browser = str(data.get("browser") or "app").strip().lower() or "app"
if browser not in ("app", "tab"):
browser = "app"
return {"url": url, "token": token, "browser": browser}
def is_gcode_path(path: Path) -> bool: def is_gcode_path(path: Path) -> bool:
@@ -140,6 +158,83 @@ def send_page_url(base_url: str, file_id: int) -> str:
return "%s/dateien?file=%s" % (base_url.rstrip("/"), int(file_id)) return "%s/dateien?file=%s" % (base_url.rstrip("/"), int(file_id))
def app_page_url(base_url: str) -> str:
"""Stabile App-URL — Datei kommt über orca-fokus, kein neues Fenster pro Slice."""
return "%s/dateien?orca=1" % base_url.rstrip("/")
def find_chromium() -> Path | None:
for path in _CHROMIUM_CANDIDATES:
try:
if path.is_file():
return path
except OSError:
continue
return None
def app_already_running() -> bool:
"""True wenn Chrome/Edge schon mit grotti-app-profile läuft (Profil-Sperre)."""
needle = "grotti-app-profile"
if sys.platform == "win32":
try:
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
out = subprocess.check_output(
[
"powershell",
"-NoProfile",
"-Command",
(
"Get-CimInstance Win32_Process | "
"Where-Object { "
"($_.Name -match '^(chrome|msedge)\\.exe$') -and "
"($_.CommandLine -like '*%s*') "
"} | Select-Object -First 1 -ExpandProperty ProcessId"
)
% needle,
],
timeout=8,
stderr=subprocess.DEVNULL,
creationflags=creationflags,
)
return bool((out or b"").strip())
except (OSError, subprocess.SubprocessError):
pass
for name in ("SingletonLock", "SingletonSocket", "lockfile"):
path = APP_PROFILE / name
try:
if path.exists() or path.is_symlink():
return True
except OSError:
continue
return False
def open_as_app(url: str) -> bool:
"""Chrome/Edge --app=… → eigenes Fenster auf dem Desktop, kein Browser-Tab."""
binary = find_chromium()
if binary is None:
return False
try:
APP_PROFILE.mkdir(parents=True, exist_ok=True)
subprocess.Popen(
[
str(binary),
"--app=%s" % url,
"--user-data-dir=%s" % str(APP_PROFILE.resolve()),
"--no-first-run",
"--no-default-browser-check",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
)
return True
except OSError as exc:
write_debug(sys.argv, "app_open_fail=%s" % exc)
return False
def _api_json( def _api_json(
base_url: str, base_url: str,
token: str, token: str,
@@ -279,7 +374,79 @@ def upload_file(
return payload return payload
def open_browser(url: str) -> None: def browser_key(name: str) -> str:
"""Gleicher Slice trotz Zeitstempel / -2 / Leerzeichen → ein Browserfenster."""
stem = Path(name or "").stem.lower()
stem = _TIME_PREFIX_RE.sub("", stem)
stem = re.sub(r"-\d+$", "", stem)
return re.sub(r"[^a-z0-9]+", "", stem)
def write_focus(file_id: int, url: str, key: str = "") -> None:
"""Offene Dateien-Seite kann auf die neueste Orca-Datei umschalten."""
try:
FOCUS_PATH.parent.mkdir(parents=True, exist_ok=True)
FOCUS_PATH.write_text(
json.dumps(
{
"file_id": int(file_id),
"url": url,
"key": key,
"ts": time.time(),
},
ensure_ascii=False,
),
encoding="utf-8",
)
except (OSError, TypeError, ValueError):
pass
def open_browser(
url: str,
key: str = "",
file_id: int | None = None,
mode: str = "app",
) -> None:
"""Anycubic/Orca starten das Skript oft 2× — nur ein Fenster pro Modell.
mode=app (Standard): Chrome/Edge als App-Fenster auf dem Desktop.
mode=tab: klassischer Browser-Tab.
"""
token = (key or url).strip() or url
now = time.time()
if file_id is not None:
write_focus(int(file_id), url, key=token)
try:
BROWSER_LOCK.parent.mkdir(parents=True, exist_ok=True)
if BROWSER_LOCK.is_file():
prev = json.loads(BROWSER_LOCK.read_text(encoding="utf-8"))
if (
isinstance(prev, dict)
and prev.get("key") == token
and now - float(prev.get("ts") or 0) < BROWSER_DEBOUNCE_S
):
write_debug(
sys.argv,
"browser_skip key=%s age=%.1fs focus=%s"
% (token, now - float(prev.get("ts") or 0), file_id),
)
return
BROWSER_LOCK.write_text(
json.dumps({"key": token, "ts": now, "url": url, "mode": mode}, ensure_ascii=False),
encoding="utf-8",
)
except (OSError, TypeError, ValueError, json.JSONDecodeError):
pass
if (mode or "app").strip().lower() != "tab":
# Zweites Chrome mit gleichem Profil = leeres Profil / erneutes Login.
# Offenes Fenster pollt orca-fokus und wechselt die Datei selbst.
if app_already_running():
write_debug(sys.argv, "browser_reuse focus=%s" % file_id)
return
if open_as_app(url):
write_debug(sys.argv, "browser_app url=%s" % url)
return
webbrowser.open(url) webbrowser.open(url)
@@ -319,8 +486,21 @@ def main(argv: list[str] | None = None) -> int:
% (path, upload_name_for(path), output_target() or ""), % (path, upload_name_for(path), output_target() or ""),
) )
file_id = deliver(config["url"], config["token"], path) file_id = deliver(config["url"], config["token"], path)
write_debug(argv, "file_id=%s" % file_id) name = upload_name_for(path)
open_browser(send_page_url(config["url"], file_id)) write_debug(argv, "file_id=%s\nupload_as=%s" % (file_id, name))
mode = str(config.get("browser") or "app").strip().lower()
# App: stabile URL; Tab: Deep-Link mit file=
open_url = (
send_page_url(config["url"], file_id)
if mode == "tab"
else app_page_url(config["url"])
)
open_browser(
open_url,
key=browser_key(name),
file_id=file_id,
mode=mode,
)
except Exception as exc: except Exception as exc:
write_debug(argv, "error=%s" % exc) write_debug(argv, "error=%s" % exc)
show_error(str(exc)) show_error(str(exc))
+17 -6
View File
@@ -7,10 +7,12 @@ import time
from waitress import serve from waitress import serve
from grotti import db, jobs, library, store from grotti import db, jobs, library, logutil, store
from grotti.config import load from grotti.config import load
from web.app import create_app from web.app import create_app
log = logutil.get("grotti.server")
def background(cfg): def background(cfg):
def work(): def work():
@@ -20,10 +22,10 @@ def background(cfg):
jobs.sync_history(conn, cfg) jobs.sync_history(conn, cfg)
deleted = db.prune_run_samples(conn, keep_days=90) deleted = db.prune_run_samples(conn, keep_days=90)
if deleted: if deleted:
print("Proben gelöscht: %s" % deleted, flush=True) log.info("Proben gelöscht: %s", deleted)
conn.commit() conn.commit()
except Exception as exc: except Exception as exc:
print("Abgleich: %s" % exc, flush=True) log.warning("Abgleich: %s", exc)
threading.Thread(target=work, name="grotti-abgleich", daemon=True).start() threading.Thread(target=work, name="grotti-abgleich", daemon=True).start()
@@ -39,14 +41,15 @@ def _retention_loop(cfg, stop_event):
n = db.prune_run_samples(conn, keep_days=90) n = db.prune_run_samples(conn, keep_days=90)
conn.commit() conn.commit()
if n: if n:
print("Retention: %s Proben gelöscht." % n, flush=True) log.info("Retention: %s Proben gelöscht.", n)
except Exception as exc: except Exception as exc:
print("Retention: %s" % exc, flush=True) log.warning("Retention: %s", exc)
stop_event.wait(3600) stop_event.wait(3600)
def main(): def main():
cfg = load() cfg = load()
logutil.setup(cfg.get("log_level") or "INFO")
cfg["data_dir"].mkdir(parents=True, exist_ok=True) cfg["data_dir"].mkdir(parents=True, exist_ok=True)
(cfg["data_dir"] / "eingang").mkdir(exist_ok=True) (cfg["data_dir"] / "eingang").mkdir(exist_ok=True)
db.prepare(cfg["database_url"]) db.prepare(cfg["database_url"])
@@ -64,6 +67,10 @@ def main():
dispatcher.start() dispatcher.start()
if hasattr(send_queue, "start"): if hasattr(send_queue, "start"):
send_queue.start() send_queue.start()
from grotti.daily_report import DailyReportThread
daily = DailyReportThread(cfg["database_url"], cfg, watcher=watcher)
daily.start()
app.extensions["daily_report"] = daily
stop_event = threading.Event() stop_event = threading.Event()
retention = threading.Thread( retention = threading.Thread(
@@ -90,6 +97,10 @@ def main():
send_queue.stop() send_queue.stop()
except Exception: except Exception:
pass pass
try:
daily.stop()
except Exception:
pass
db.close_pool() db.close_pool()
atexit.register(shutdown) atexit.register(shutdown)
@@ -99,7 +110,7 @@ def main():
except (ValueError, OSError): except (ValueError, OSError):
pass pass
print("Grotti: http://%s:%s/" % (cfg["host"], cfg["port"]), flush=True) log.info("Grotti: http://%s:%s/", cfg["host"], cfg["port"])
serve(app, host=cfg["host"], port=cfg["port"], threads=16, channel_timeout=120) serve(app, host=cfg["host"], port=cfg["port"], threads=16, channel_timeout=120)
+2
View File
@@ -48,6 +48,7 @@ class CameraFieldsTest(unittest.TestCase):
moonraker.CAMERA_CACHE.clear() moonraker.CAMERA_CACHE.clear()
fields = moonraker._camera_fields("10.0.0.1", "10.0.0.1") fields = moonraker._camera_fields("10.0.0.1", "10.0.0.1")
self.assertTrue(fields["camera"]) self.assertTrue(fields["camera"])
self.assertIn("/api/v1/kamera/stream?", fields["camera_stream"])
self.assertIsNone(fields["has_camera"]) self.assertIsNone(fields["has_camera"])
def test_known_dead_hides_url(self): def test_known_dead_hides_url(self):
@@ -56,6 +57,7 @@ class CameraFieldsTest(unittest.TestCase):
moonraker.CAMERA_CACHE["10.0.0.2"] = (time.time(), 30, None) moonraker.CAMERA_CACHE["10.0.0.2"] = (time.time(), 30, None)
fields = moonraker._camera_fields("10.0.0.2", "10.0.0.2") fields = moonraker._camera_fields("10.0.0.2", "10.0.0.2")
self.assertEqual(fields["camera"], "") self.assertEqual(fields["camera"], "")
self.assertEqual(fields["camera_stream"], "")
self.assertFalse(fields["has_camera"]) self.assertFalse(fields["has_camera"])
moonraker.CAMERA_CACHE.pop("10.0.0.2", None) moonraker.CAMERA_CACHE.pop("10.0.0.2", None)
+26 -4
View File
@@ -26,8 +26,7 @@ class BedarfTests(unittest.TestCase):
"repeat_total": 2, "repeat_total": 2,
"repeat_done": 0, "repeat_done": 0,
"weight_g": 50.0, "weight_g": 50.0,
"file_material": "PLA", "filaments": [{"type": "PLA", "color": "Schwarz"}],
"file_color": "Schwarz",
"spool_material": "PLA", "spool_material": "PLA",
"spool_color": "Schwarz", "spool_color": "Schwarz",
}, },
@@ -52,8 +51,7 @@ class BedarfTests(unittest.TestCase):
"repeat_total": 1, "repeat_total": 1,
"repeat_done": 0, "repeat_done": 0,
"weight_g": 20.0, "weight_g": 20.0,
"file_material": "PETG", "filaments": [{"type": "PETG", "color": ""}],
"file_color": "",
"spool_material": None, "spool_material": None,
"spool_color": None, "spool_color": None,
}, },
@@ -65,6 +63,30 @@ class BedarfTests(unittest.TestCase):
out = compute(conn) out = compute(conn)
self.assertEqual(out["shortfall"], []) self.assertEqual(out["shortfall"], [])
def test_slicer_hex_matches_named_stock(self):
"""Slice #26A69A vs. Spule „Grau“ — 1 kg reicht, kein Fehlalarm."""
conn = MagicMock()
conn.execute.side_effect = self._rows(
[
{
"id": 1,
"spool_id": None,
"repeat_total": 1,
"repeat_done": 0,
"weight_g": 49.23,
"filaments": [{"type": "PLA", "color": "#26A69A"}],
"spool_material": None,
"spool_color": None,
},
],
[
{"material_type": "PLA", "color": "Grau", "remaining_g": 1000.0},
],
)
out = compute(conn)
self.assertEqual(out["shortfall"], [])
self.assertAlmostEqual(out["needed"][0]["weight_g"], 49.23)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+44
View File
@@ -0,0 +1,44 @@
"""Einfarbig: filament_colour_type strippen; Multicolor unangetastet."""
import unittest
from grotti.parse import gcode_is_multicolor, prepare_rinkhals_gcode, strip_filament_colour_type
class ColourTypeStripTest(unittest.TestCase):
def test_strip_single_color(self):
raw = (
b"; generated by OrcaSlicer 2.3.0\n"
b"; filament_colour = #26A69A\n"
b"; filament_colour_type = 1\n"
b"G28\n"
b"T0\n"
b"G1 X10\n"
)
out = strip_filament_colour_type(raw)
self.assertNotIn(b"filament_colour_type", out)
self.assertIn(b"filament_colour = #26A69A", out)
self.assertFalse(gcode_is_multicolor(raw))
def test_keep_multicolor(self):
raw = (
b"; generated by OrcaSlicer 2.3.0\n"
b"; filament_colour = #111111;#222222\n"
b"; filament_colour_type = 0;1\n"
b"T0\n"
b"G1 X1\n"
b"T1\n"
b"G1 X2\n"
)
self.assertTrue(gcode_is_multicolor(raw))
out = strip_filament_colour_type(raw)
self.assertIn(b"filament_colour_type = 0;1", out)
def test_prepare_strips_single(self):
raw = b"; filament_colour_type = 1\nG28\nT0\n"
out = prepare_rinkhals_gcode(raw)
self.assertNotIn(b"filament_colour_type", out)
if __name__ == "__main__":
unittest.main()
+16
View File
@@ -46,6 +46,22 @@ class EnergyEdTests(unittest.TestCase):
self.assertAlmostEqual(est["print_kwh"], 0.2, places=3) self.assertAlmostEqual(est["print_kwh"], 0.2, places=3)
self.assertAlmostEqual(est["cost"], 0.4, places=3) self.assertAlmostEqual(est["cost"], 0.4, places=3)
def test_ed_parts_split_base_nozzle_bed(self):
samples = [sample_at(i * 30, np=1.0, bp=0.5) for i in range(0, 121)]
est = energy_estimate(3600, {
"watts": 40,
"nozzle_watts": 60,
"bed_watts": 220,
"euro_per_kwh": 0.35,
}, samples)
self.assertEqual(est["source"], "ed")
self.assertAlmostEqual(est["base_kwh"], 0.04, places=3)
self.assertAlmostEqual(est["nozzle_kwh"], 0.06, places=3)
self.assertAlmostEqual(est["bed_kwh"], 0.11, places=3)
keys = {p["key"] for p in est["parts"]}
self.assertEqual(keys, {"base", "nozzle", "bed"})
self.assertAlmostEqual(sum(p["kwh"] for p in est["parts"]), est["kwh"], places=6)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+48
View File
@@ -0,0 +1,48 @@
"""Ops-Alerts: Dedup-Art muss zum gespeicherten Event-kind passen."""
import unittest
from unittest.mock import MagicMock, patch
from grotti.daily_report import push_ops_alerts
class OpsAlertDedupeTests(unittest.TestCase):
def test_bedarf_records_matching_kind(self):
conn = MagicMock()
cfg = {"alerts": {"queue": True}}
shortfall = [{
"material_type": "PETG-CF",
"color": "#161616",
"shortfall_g": 15,
}]
with patch("grotti.daily_report.jetzt_mod.overdue_orders", return_value=[]), \
patch("grotti.daily_report.jetzt_mod.overdue_maintenance", return_value=[]), \
patch("grotti.daily_report.bedarf_mod.compute", return_value={"shortfall": shortfall}), \
patch("grotti.daily_report.store.list_printers", return_value=[]), \
patch("grotti.daily_report.store.recent_event_kind", return_value=False) as recent, \
patch("grotti.daily_report.notify.record") as record:
push_ops_alerts(conn, cfg, printers=[])
recent.assert_called_with(
conn, "bedarf:PETG-CF:#161616", "bedarf", within_s=21600,
)
record.assert_called_once()
args = record.call_args
self.assertEqual(args.args[4], "bedarf")
self.assertIn("15 g fehlen", args.args[5])
def test_bedarf_skips_when_recent(self):
conn = MagicMock()
cfg = {"alerts": {"queue": True}}
shortfall = [{"material_type": "PLA", "color": "", "shortfall_g": 10}]
with patch("grotti.daily_report.jetzt_mod.overdue_orders", return_value=[]), \
patch("grotti.daily_report.jetzt_mod.overdue_maintenance", return_value=[]), \
patch("grotti.daily_report.bedarf_mod.compute", return_value={"shortfall": shortfall}), \
patch("grotti.daily_report.store.list_printers", return_value=[]), \
patch("grotti.daily_report.store.recent_event_kind", return_value=True), \
patch("grotti.daily_report.notify.record") as record:
push_ops_alerts(conn, cfg, printers=[])
record.assert_not_called()
if __name__ == "__main__":
unittest.main()
+77
View File
@@ -0,0 +1,77 @@
"""Filament-Pause-Heuristik aus ACE/mmu und Meldungen."""
import unittest
from grotti.moonraker import filament_out_hint
class FilamentOutHintTests(unittest.TestCase):
def test_message_10107(self):
code, text = filament_out_hint({}, "Report code 10107")
self.assertEqual(code, "message")
self.assertIn("10107", text)
def test_reason_for_pause(self):
code, text = filament_out_hint({"reason_for_pause": "Filament runout"}, "")
self.assertEqual(code, "reason")
self.assertIn("runout", text.lower())
def test_active_filament_empty_needs_active_tool(self):
code, text = filament_out_hint({
"tool": 1,
"gate_status": [1, 1, 1, 1],
"active_filament": {"empty": True},
})
self.assertEqual(code, "active_empty")
self.assertIn("leer", text.lower())
def test_anycubic_empty_without_tool_is_not_runout(self):
# Live A2/Kobra 3: empty=true, tool=-1, alle gate_status 0 — auch beim Druck
code, text = filament_out_hint({
"tool": -1,
"gate": -1,
"is_paused": False,
"gate_status": [0, 0, 0, 0],
"active_filament": {"empty": True, "material": ""},
"reason_for_pause": "",
})
self.assertIsNone(code)
self.assertIsNone(text)
def test_manual_pause_anycubic_not_material(self):
code, text = filament_out_hint({
"tool": -1,
"is_paused": True,
"gate_status": [0, 0, 0, 0],
"active_filament": {"empty": True},
}, "paused")
self.assertIsNone(code)
self.assertIsNone(text)
def test_gate_status_zero_for_tool(self):
code, text = filament_out_hint({
"tool": 2,
"gate_status": [1, 1, 0, 1],
"active_filament": {"type": "PLA"},
})
self.assertEqual(code, "gate_empty")
self.assertIn("3", text)
def test_all_gates_zero_not_empty_signal(self):
# Anycubic: gate_status oft durchgängig 0 — kein Runout-Beweis
code, text = filament_out_hint({
"tool": 0,
"gate_status": [0, 0, 0, 0],
"active_filament": {"type": "PLA", "empty": False},
})
self.assertIsNone(code)
self.assertIsNone(text)
def test_no_hint_for_plain_pause(self):
code, text = filament_out_hint({"tool": 0, "gate_status": [1, 1]}, "paused")
self.assertIsNone(code)
self.assertIsNone(text)
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -28,6 +28,47 @@ class ChooseSpoolTests(unittest.TestCase):
with patch("grotti.jobs.store.spools_for_printer", return_value=[old]): with patch("grotti.jobs.store.spools_for_printer", return_value=[old]):
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", None, ended_at=end)["id"], 1) self.assertEqual(jobs.choose_spool(conn, "h", "PLA", None, ended_at=end)["id"], 1)
def test_prefers_host_spool_over_stale_hint(self):
"""Rollenwechsel: Hint im Regal, neue Rolle am Drucker → Abzug auf die neue."""
end = datetime(2026, 10, 18, 14, 58, tzinfo=timezone.utc)
hinted = {
"id": 4,
"material_type": "PLA",
"printer_host": "",
"created_at": end - timedelta(days=5),
}
current = {
"id": 14,
"material_type": "PLA",
"printer_host": "h",
"created_at": end - timedelta(minutes=10),
}
conn = MagicMock()
with patch("grotti.jobs.store.spools_for_printer", return_value=[current]):
with patch("grotti.jobs.store.get_spool_row", return_value=hinted):
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", 4, ended_at=end)["id"], 14)
def test_keeps_hint_when_no_host_spool(self):
end = datetime(2026, 10, 18, 14, 58, tzinfo=timezone.utc)
hinted = {
"id": 4,
"material_type": "PLA",
"printer_host": "",
"created_at": end - timedelta(days=5),
}
conn = MagicMock()
with patch("grotti.jobs.store.spools_for_printer", return_value=[]):
with patch("grotti.jobs.store.get_spool_row", return_value=hinted):
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", 4, ended_at=end)["id"], 4)
def test_resolve_tracking_spool_prefers_host(self):
conn = MagicMock()
with patch(
"grotti.jobs.resolve_spools_at_host",
return_value=[{"id": 14, "printer_host": "h"}],
):
self.assertEqual(jobs.resolve_tracking_spool(conn, "h", 4), 14)
def test_spool_valid_for_end_naive(self): def test_spool_valid_for_end_naive(self):
end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc) end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
spool = {"created_at": datetime(2026, 9, 1, 12, 0)} spool = {"created_at": datetime(2026, 9, 1, 12, 0)}
+22
View File
@@ -0,0 +1,22 @@
"""Logging-Hilfen."""
import logging
import unittest
from grotti import logutil
class LogutilTests(unittest.TestCase):
def test_parse_level(self):
self.assertEqual(logutil.parse_level("DEBUG"), logging.DEBUG)
self.assertEqual(logutil.parse_level("warn"), logging.WARNING)
self.assertEqual(logutil.parse_level("nope"), logging.INFO)
def test_setup_idempotent(self):
logutil.setup("INFO")
logutil.setup("DEBUG")
self.assertEqual(logging.getLogger().level, logging.DEBUG)
if __name__ == "__main__":
unittest.main()
+129
View File
@@ -0,0 +1,129 @@
"""Sorten-Routing: Profil (Meta/Matte) und Auto-ACE-Map."""
import unittest
from grotti import store
class ProfileMatchTests(unittest.TestCase):
def test_meta_not_matte(self):
self.assertTrue(store._profile_matches("Sunlu PLA Meta", "Sunlu PLA Meta"))
self.assertTrue(store._profile_matches("Sunlu PLA Meta", "Sunlu PLA Meta schwarz"))
self.assertFalse(store._profile_matches("Sunlu PLA Meta", "Sunlu PLA Matte"))
self.assertFalse(store._profile_matches("Sunlu PLA Matte", "Sunlu PLA"))
self.assertFalse(store._profile_matches("Sunlu PLA", "Sunlu PLA Matte"))
self.assertFalse(store._profile_matches("Sunlu PLA", "Sunlu PLA Meta"))
def test_empty_profile_always_ok(self):
self.assertTrue(store._profile_matches("", "Sunlu PLA Meta"))
def test_base_profile_helpers(self):
self.assertFalse(store._profile_has_variant("Sunlu PLA"))
self.assertTrue(store._profile_has_variant("Sunlu PLA Meta"))
self.assertEqual(store._profile_base_type("Sunlu PLA"), "pla")
self.assertEqual(store._profile_base_type("Generic PETG"), "petg")
class MatchSpoolsTests(unittest.TestCase):
def setUp(self):
self.have = [
{
"spool_id": 1,
"material_type": "pla",
"color": "#ff0000",
"remaining_g": 800,
"material_name": "sunlu pla meta",
"ace_slot": 1,
},
{
"spool_id": 2,
"material_type": "pla",
"color": "#000000",
"remaining_g": 500,
"material_name": "sunlu pla matte",
"ace_slot": 2,
},
{
"spool_id": 3,
"material_type": "pla",
"color": "",
"remaining_g": 900,
"material_name": "eryone pla",
"ace_slot": None,
},
]
def test_base_sunlu_pla_matches_plain_pla_not_meta(self):
need = {"profile": "Sunlu PLA", "material_type": "pla", "color": "", "paint_index": 0}
matched = store._match_spools_for_need(need, self.have)
ids = {m["spool_id"] for m in matched}
self.assertEqual(ids, {3}) # Eryone PLA; Meta/Matte nicht
def test_pla_lite_hex_matches_plain_pla(self):
# Orca: Profil „Eryone PLA Lite“, Farbe #26A69A — Lager: Eryone/Sunlu PLA „Grau“
need = {
"profile": "Eryone PLA Lite",
"material_type": "pla",
"color": "#26A69A",
"paint_index": 0,
}
have = [
{
"spool_id": 15,
"material_type": "pla",
"color": "grau",
"remaining_g": 873,
"material_name": "sunlu pla",
"ace_slot": None,
},
{
"spool_id": 1,
"material_type": "pla",
"color": "grau",
"remaining_g": 800,
"material_name": "sunlu pla meta",
"ace_slot": 1,
},
]
matched = store._match_spools_for_need(need, have)
self.assertEqual({m["spool_id"] for m in matched}, {15})
def test_picks_meta_not_matte(self):
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
matched = store._match_spools_for_need(need, self.have)
self.assertEqual(len(matched), 1)
self.assertEqual(matched[0]["spool_id"], 1)
def test_color_soft_among_profile(self):
need = {
"profile": "Sunlu PLA Meta",
"material_type": "pla",
"color": "#00ff00",
"paint_index": 0,
}
# Nur Meta vorhanden, Farbe anders → trotzdem Meta (Farbe soft)
matched = store._match_spools_for_need(need, self.have)
self.assertEqual(matched[0]["spool_id"], 1)
def test_auto_ace_map_assigns_slots(self):
# Ohne DB: Logik über _match + manuell wie auto_ace_map
need = [
{"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "#ff0000", "paint_index": 1},
{"profile": "Sunlu PLA Matte", "material_type": "pla", "color": "#000000", "paint_index": 10},
]
used = set()
mapping = []
for n in need:
matched = store._match_spools_for_need(n, self.have, used)
self.assertTrue(matched)
chosen = matched[0]
used.add(chosen["spool_id"])
mapping.append({"paint_index": n["paint_index"], "ams_index": int(chosen["ace_slot"]) - 1})
self.assertEqual(mapping, [
{"paint_index": 1, "ams_index": 0},
{"paint_index": 10, "ams_index": 1},
])
if __name__ == "__main__":
unittest.main()
+84
View File
@@ -23,6 +23,90 @@ class OrcaSendenHelpersTest(unittest.TestCase):
"http://127.0.0.1:8770/dateien?file=42", "http://127.0.0.1:8770/dateien?file=42",
) )
def test_app_page_url(self):
self.assertEqual(
orca.app_page_url("http://127.0.0.1:8770/"),
"http://127.0.0.1:8770/dateien?orca=1",
)
def test_browser_key_collapses_duplicates(self):
self.assertEqual(
orca.browser_key("1018-1648-Round Clip PLA + PETG(01)_PLA_0.2_30m34s.gcode"),
orca.browser_key("1018-1648-Round_Clip_PLA_+_PETG(01)_PLA_0.2_30m34s-2.gcode"),
)
def test_browser_key_strips_time_prefix(self):
self.assertEqual(
orca.browser_key("1018-1706-Round Clip PLA + PETG(01)_PLA_0.2_30m34s.gcode"),
orca.browser_key("1018-1709-Round_Clip_PLA_+_PETG(01)_PLA_0.2_30m34s.gcode"),
)
self.assertEqual(
orca.browser_key("1018-1709-Round Clip PLA + PETG(01)_PLA_0.2_30m34s.gcode"),
"roundclipplapetg01pla0230m34s",
)
def test_open_browser_debounces(self):
with tempfile.TemporaryDirectory() as tmp:
lock = Path(tmp) / "browser.lock"
focus = Path(tmp) / "focus.json"
with patch.object(orca, "BROWSER_LOCK", lock):
with patch.object(orca, "FOCUS_PATH", focus):
with patch.object(orca, "open_as_app", return_value=False):
with patch.object(orca, "webbrowser") as browser:
orca.open_browser(
"http://127.0.0.1:8770/dateien?file=1",
key="roundclip",
file_id=1,
mode="tab",
)
orca.open_browser(
"http://127.0.0.1:8770/dateien?file=2",
key="roundclip",
file_id=2,
mode="tab",
)
self.assertEqual(browser.open.call_count, 1)
data = json.loads(focus.read_text(encoding="utf-8"))
self.assertEqual(data["file_id"], 2)
def test_open_browser_app_mode(self):
with tempfile.TemporaryDirectory() as tmp:
lock = Path(tmp) / "browser.lock"
focus = Path(tmp) / "focus.json"
with patch.object(orca, "BROWSER_LOCK", lock):
with patch.object(orca, "FOCUS_PATH", focus):
with patch.object(orca, "app_already_running", return_value=False):
with patch.object(orca, "open_as_app", return_value=True) as app:
with patch.object(orca, "webbrowser") as browser:
orca.open_browser(
"http://127.0.0.1:8770/dateien?orca=1",
key="roundclip",
file_id=9,
mode="app",
)
app.assert_called_once()
browser.open.assert_not_called()
def test_open_browser_reuses_running_app(self):
with tempfile.TemporaryDirectory() as tmp:
lock = Path(tmp) / "browser.lock"
focus = Path(tmp) / "focus.json"
with patch.object(orca, "BROWSER_LOCK", lock):
with patch.object(orca, "FOCUS_PATH", focus):
with patch.object(orca, "app_already_running", return_value=True):
with patch.object(orca, "open_as_app", return_value=True) as app:
with patch.object(orca, "webbrowser") as browser:
orca.open_browser(
"http://127.0.0.1:8770/dateien?orca=1",
key="andere-datei",
file_id=11,
mode="app",
)
app.assert_not_called()
browser.open.assert_not_called()
data = json.loads(focus.read_text(encoding="utf-8"))
self.assertEqual(data["file_id"], 11)
def test_find_gcode_arg_last_matching(self): def test_find_gcode_arg_last_matching(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp) folder = Path(tmp)
+171
View File
@@ -0,0 +1,171 @@
"""Sparse paint_index / Bambu-T-Nummern für ACE-Map."""
import json
import unittest
from grotti import acm, parse, usage
PAINT_SPARSE = json.dumps(
[
{"material_type": "PLA", "paint_color": [255, 128, 0], "paint_index": 1},
{"material_type": "PLA", "paint_color": [0, 0, 0], "paint_index": 10},
]
)
class PaintIndexTests(unittest.TestCase):
def test_filaments_keep_slicer_paint_index(self):
rows = parse.filaments_from_paint_info(PAINT_SPARSE)
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0]["paint_index"], 1)
self.assertEqual(rows[0]["type"], "PLA")
self.assertEqual(rows[0]["color"], "#ff8000")
self.assertEqual(rows[1]["paint_index"], 10)
self.assertEqual(rows[1]["color"], "#000000")
def test_build_acm_uses_paint_index_not_array_slot(self):
filaments = parse.filaments_from_paint_info(PAINT_SPARSE)
mapping = [
{"paint_index": 1, "ams_index": 3},
{"paint_index": 10, "ams_index": 2},
]
slots = [
{"index": 2, "type": "PLA", "color": "#f4f4f4"},
{"index": 3, "type": "PLA", "color": "#c23b3b"},
]
payload = acm.build_acm(filaments, mapping, slots)
paints = [item["paint_index"] for item in payload["ams_box_mapping"]]
self.assertEqual(paints, [1, 10])
by_paint = {item["paint_index"]: item for item in payload["ams_box_mapping"]}
self.assertEqual(by_paint[1]["ams_index"], 3)
self.assertEqual(by_paint[10]["ams_index"], 2)
self.assertEqual(by_paint[1]["paint_color"], [255, 128, 0])
self.assertEqual(by_paint[10]["paint_color"], [0, 0, 0])
def test_densify_mapping_and_rewrite_avoids_invalid_filament_id(self):
mapping = [
{"paint_index": 1, "ams_index": 3},
{"paint_index": 10, "ams_index": 2},
]
remap, dense = acm.densify_mapping(mapping)
self.assertEqual(remap, {1: 0, 10: 1})
self.assertEqual(
dense,
[
{"paint_index": 0, "ams_index": 3},
{"paint_index": 1, "ams_index": 2},
],
)
gcode = b"; hdr\nT10\nG1 X0\nT1 ; change\n; referenced_tools = 1,10\n"
out = acm.rewrite_gcode_tools(gcode, remap)
self.assertIn(b"T1\n", out)
self.assertIn(b"T0 ; change", out)
self.assertNotIn(b"T10", out)
self.assertIn(b"; referenced_tools = 0,1", out)
filaments = acm.remap_filaments(
parse.filaments_from_paint_info(PAINT_SPARSE), remap
)
slots = [
{"index": 2, "type": "PLA", "color": "#f4f4f4"},
{"index": 3, "type": "PLA", "color": "#c23b3b"},
]
payload = acm.build_acm(filaments, dense, slots)
paints = [item["paint_index"] for item in payload["ams_box_mapping"]]
self.assertEqual(paints, [0, 1])
self.assertEqual(payload["ams_box_mapping"][0]["paint_color"], [255, 128, 0])
def test_densify_identity_when_already_dense(self):
mapping = [{"paint_index": 0, "ams_index": 1}, {"paint_index": 1, "ams_index": 2}]
remap, dense = acm.densify_mapping(mapping)
self.assertEqual(remap, {})
self.assertEqual(dense, mapping)
def test_align_rewrites_when_ui_map_is_already_dense(self):
"""Regression 10011700: UI schickt 0/1, G-code hat noch T1/T10."""
gcode = b"; hdr\nT10\nG1 X0\nT1 ; change\n; referenced_tools = 1,10\n"
mapping = [
{"paint_index": 0, "ams_index": 3},
{"paint_index": 1, "ams_index": 2},
]
filaments = [
{"type": "PLA", "color": "#ff8000", "paint_index": 0},
{"type": "PLA", "color": "#000000", "paint_index": 1},
]
out, dense, fils, remap = acm.align_tools_for_ace(gcode, mapping, filaments)
self.assertEqual(remap, {1: 0, 10: 1})
self.assertEqual(
dense,
[
{"paint_index": 0, "ams_index": 3},
{"paint_index": 1, "ams_index": 2},
],
)
self.assertIn(b"T1\n", out)
self.assertIn(b"T0 ; change", out)
self.assertNotIn(b"T10", out)
self.assertIn(b"; referenced_tools = 0,1", out)
self.assertEqual([row["paint_index"] for row in fils], [0, 1])
self.assertEqual(fils[0]["color"], "#ff8000")
self.assertEqual(fils[1]["color"], "#000000")
def test_align_with_sparse_ui_map(self):
gcode = b"T10\nT1\n"
mapping = [
{"paint_index": 1, "ams_index": 3},
{"paint_index": 10, "ams_index": 2},
]
out, dense, fils, remap = acm.align_tools_for_ace(
gcode, mapping, parse.filaments_from_paint_info(PAINT_SPARSE)
)
self.assertEqual(remap, {1: 0, 10: 1})
self.assertEqual(dense[0]["ams_index"], 3)
self.assertEqual(dense[1]["ams_index"], 2)
self.assertNotIn(b"T10", out)
self.assertEqual([row["paint_index"] for row in fils], [0, 1])
def test_tools_used_finds_t10(self):
gcode = b"; header\nT10\nG1 X0\nT1 ; change\n"
self.assertEqual(parse.tools_used_in_gcode(gcode), {1, 10})
def test_planned_shares_sparse_paint_falls_back_to_order(self):
meta = {"filament_weight_total": [12.0, 8.0]}
mapping = [
{"paint_index": 1, "ams_index": 0},
{"paint_index": 10, "ams_index": 1},
]
self.assertEqual(usage.planned_shares(meta, mapping, 2), [12.0, 8.0])
def test_profile_name_strips_machine(self):
self.assertEqual(parse.profile_name('Sunlu PLA Meta @Kobra 3'), "Sunlu PLA Meta")
self.assertEqual(parse.profile_name('"Sunlu PLA Matte @Kobra S1"'), "Sunlu PLA Matte")
def test_filaments_of_attaches_settings_id_profiles(self):
# Slot 1 = PLA, Slot 10 = Matte — wie sparse paint_info
profiles = ";".join(
['"Sunlu PLA @Kobra 3"' if i not in (1, 10) else (
'"Sunlu PLA @Kobra 3"' if i == 1 else '"Sunlu PLA Matte @Kobra 3"'
) for i in range(15)]
)
comments = {
"paint_info": PAINT_SPARSE,
"filament_settings_id": profiles,
}
rows = parse.filaments_of(comments)
self.assertEqual(rows[0]["profile"], "Sunlu PLA")
self.assertEqual(rows[1]["profile"], "Sunlu PLA Matte")
def test_filaments_of_single_without_paint(self):
comments = {
"filament_type": "PLA",
"filament_colour": "#00FF00",
"filament_settings_id": '"Sunlu PLA Meta @Kobra 3"',
}
rows = parse.filaments_of(comments)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["profile"], "Sunlu PLA Meta")
self.assertEqual(rows[0]["type"], "PLA")
if __name__ == "__main__":
unittest.main()
+6 -2
View File
@@ -38,13 +38,17 @@ class DispatcherHelpers(unittest.TestCase):
class ResolveSpoolTests(unittest.TestCase): class ResolveSpoolTests(unittest.TestCase):
def test_material_match(self): def test_material_match(self):
from grotti.jobs import assert_material_match from grotti.jobs import assert_material_match
from grotti.errors import GrottiError
file_row = {"filaments": [{"type": "PLA"}]} file_row = {"filaments": [{"type": "PLA"}]}
assert_material_match(file_row, [{"material_type": "PLA"}]) assert_material_match(file_row, [{"material_type": "PLA"}])
with self.assertRaises(GrottiError):
assert_material_match(file_row, [{"material_type": "PETG"}]) assert_material_match(file_row, [{"material_type": "PETG"}])
def test_ace_type_for_pa12(self):
from grotti.catalog import ace_material_type
self.assertEqual(ace_material_type("PA12-CF"), "PA6-CF")
self.assertEqual(ace_material_type("PETG"), "PETG")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+348 -34
View File
@@ -5,13 +5,13 @@ import re
import secrets import secrets
import time import time
import uuid import uuid
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
import markdown import markdown
import psycopg import psycopg
from flask import Flask, abort, g, jsonify, redirect, render_template, request, send_file, session, url_for from flask import Flask, Response, abort, g, jsonify, redirect, render_template, request, send_file, session, stream_with_context, url_for
from markupsafe import Markup from markupsafe import Markup
from psycopg.rows import dict_row from psycopg.rows import dict_row
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
@@ -19,7 +19,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
from grotti import auth as grotti_auth from grotti import auth as grotti_auth
from grotti import config as grotti_config from grotti import config as grotti_config
from grotti import db as grotti_db from grotti import db as grotti_db
from grotti import eject, jobs, library, moonraker, notify, store from grotti import eject, jobs, library, logutil, moonraker, notify, store
from grotti.catalog import color_catalog from grotti.catalog import color_catalog
from grotti.cost import calculate, offer_from_file, order_actuals from grotti.cost import calculate, offer_from_file, order_actuals
from grotti import orders as grotti_orders from grotti import orders as grotti_orders
@@ -32,6 +32,8 @@ from grotti.version import __version__
from grotti import verdict as grotti_verdict from grotti import verdict as grotti_verdict
from grotti.watcher import StateWatcher from grotti.watcher import StateWatcher
log = logutil.get("grotti.web")
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
TOOL_ROOT = ROOT.parent TOOL_ROOT = ROOT.parent
DOCS_DIR = TOOL_ROOT / "docs" DOCS_DIR = TOOL_ROOT / "docs"
@@ -44,6 +46,7 @@ UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$") HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$")
TOKEN_QUERY_PATHS = ( TOKEN_QUERY_PATHS = (
"/api/v1/kamera", "/api/v1/kamera",
"/api/v1/kamera/stream",
"/api/v1/vorschau", "/api/v1/vorschau",
) )
STEER_ACTIONS = { STEER_ACTIONS = {
@@ -76,6 +79,9 @@ def create_app(cfg):
app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024 app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024
app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax" app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
# Orca-App-Fenster schließt oft — Session-Cookie muss überleben (30 Tage, gleitend).
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30)
app.config["SESSION_REFRESH_EACH_REQUEST"] = True
if cfg.get("fqdn", "").startswith("https://"): if cfg.get("fqdn", "").startswith("https://"):
app.config["SESSION_COOKIE_SECURE"] = True app.config["SESSION_COOKIE_SECURE"] = True
app.json.ensure_ascii = False app.json.ensure_ascii = False
@@ -103,6 +109,7 @@ def create_app(cfg):
) )
ADMIN_API_EXACT = { ADMIN_API_EXACT = {
"/api/v1/telegram/test", "/api/v1/telegram/test",
"/api/v1/ntfy/test",
} }
ADMIN_METHODS_ON = { ADMIN_METHODS_ON = {
# Drucker anlegen/ändern/löschen nur Admin # Drucker anlegen/ändern/löschen nur Admin
@@ -135,14 +142,6 @@ def create_app(cfg):
return user return user
def resolve_bearer(presented): def resolve_bearer(presented):
legacy = cfg.get("token") or ""
if legacy and secrets.compare_digest(presented, legacy):
return {
"id": None,
"username": "legacy-token",
"role": "admin",
"auth": "legacy",
}
if not presented.startswith(grotti_auth.TOKEN_PREFIX): if not presented.startswith(grotti_auth.TOKEN_PREFIX):
return None return None
row = store.get_api_token_by_hash(conn(), grotti_auth.hash_token(presented)) row = store.get_api_token_by_hash(conn(), grotti_auth.hash_token(presented))
@@ -250,10 +249,10 @@ def create_app(cfg):
if path == "/api/v1/einstellungen" and request.method == "GET": if path == "/api/v1/einstellungen" and request.method == "GET":
need_admin = False need_admin = False
elif path == "/api/v1/einstellungen" and request.method == "PATCH": elif path == "/api/v1/einstellungen" and request.method == "PATCH":
# Operator darf nur auto_queue schalten # Operator darf nur Automatik-Schalter setzen
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
keys = {k for k, v in payload.items() if v is not None} keys = {k for k, v in payload.items() if v is not None}
need_admin = keys - {"auto_queue"} != set() need_admin = keys - {"auto_queue", "auto_retry"} != set()
elif path.startswith("/api/v1/users"): elif path.startswith("/api/v1/users"):
need_admin = True need_admin = True
elif request.method != "GET": elif request.method != "GET":
@@ -319,7 +318,7 @@ def create_app(cfg):
message = "Diesen Eintrag gibt es schon." message = "Diesen Eintrag gibt es schon."
elif isinstance(err, psycopg.errors.InvalidTextRepresentation): elif isinstance(err, psycopg.errors.InvalidTextRepresentation):
message = "Ungültiger Wert in der Anfrage." message = "Ungültiger Wert in der Anfrage."
print("DB: %s" % err, flush=True) log.warning("DB: %s", err)
return jsonify({"error": message, "code": "db"}), 500 return jsonify({"error": message, "code": "db"}), 500
def body(): def body():
@@ -347,6 +346,24 @@ def create_app(cfg):
require_host(host) require_host(host)
return host return host
_icons_cache = {"html": "", "mtime": None}
def icons_sprite_html():
path = ROOT / "static" / "icons.svg"
try:
mtime = path.stat().st_mtime
except OSError:
return ""
if _icons_cache["html"] and _icons_cache["mtime"] == mtime:
return _icons_cache["html"]
text = path.read_text(encoding="utf-8")
text = re.sub(r"<\?xml[^?]*\?>\s*", "", text)
text = re.sub(r"<!--.*?-->\s*", "", text, count=1, flags=re.DOTALL)
text = text.replace("<svg ", '<svg width="0" height="0" style="position:absolute" aria-hidden="true" ', 1)
_icons_cache["html"] = text
_icons_cache["mtime"] = mtime
return text
@app.context_processor @app.context_processor
def inject_globals(): def inject_globals():
user = auth_user() user = auth_user()
@@ -354,6 +371,7 @@ def create_app(cfg):
"version": __version__, "version": __version__,
"csrf": session.get("csrf") or "", "csrf": session.get("csrf") or "",
"current_user": user, "current_user": user,
"icons_sprite": Markup(icons_sprite_html()),
} }
def page(name, title): def page(name, title):
@@ -408,6 +426,7 @@ def create_app(cfg):
grotti_db.invalidate_user_count() grotti_db.invalidate_user_count()
store.touch_user_login(conn(), row["id"]) store.touch_user_login(conn(), row["id"])
session.clear() session.clear()
session.permanent = True
session["user_id"] = row["id"] session["user_id"] = row["id"]
session["csrf"] = grotti_auth.new_csrf() session["csrf"] = grotti_auth.new_csrf()
store.add_event(conn(), "", "", "auth", "Admin %s angelegt" % username) store.add_event(conn(), "", "", "auth", "Admin %s angelegt" % username)
@@ -429,6 +448,7 @@ def create_app(cfg):
raise GrottiError("Benutzer oder Passwort falsch.", "auth", 401) raise GrottiError("Benutzer oder Passwort falsch.", "auth", 401)
store.touch_user_login(conn(), row["id"]) store.touch_user_login(conn(), row["id"])
session.clear() session.clear()
session.permanent = True
session["user_id"] = row["id"] session["user_id"] = row["id"]
session["csrf"] = grotti_auth.new_csrf() session["csrf"] = grotti_auth.new_csrf()
store.add_event(conn(), "", "", "auth", "Login %s" % row["username"]) store.add_event(conn(), "", "", "auth", "Login %s" % row["username"])
@@ -564,6 +584,7 @@ def create_app(cfg):
ANLEITUNG_ORDER = ( ANLEITUNG_ORDER = (
"Anleitung-Einrichtung", "Anleitung-Einrichtung",
"Anleitung-Installation-Pi",
"Anleitung-Oberflaeche", "Anleitung-Oberflaeche",
"Anleitung-Wand", "Anleitung-Wand",
"Anleitung-Dateien", "Anleitung-Dateien",
@@ -587,6 +608,7 @@ def create_app(cfg):
def anleitung_title(name): def anleitung_title(name):
labels = { labels = {
"Anleitung-Einrichtung": "Einrichtung", "Anleitung-Einrichtung": "Einrichtung",
"Anleitung-Installation-Pi": "Raspberry Pi (.deb)",
"Anleitung-Oberflaeche": "Oberfläche", "Anleitung-Oberflaeche": "Oberfläche",
"Anleitung-Wand": "Druckerwand", "Anleitung-Wand": "Druckerwand",
"Anleitung-Dateien": "Dateien senden", "Anleitung-Dateien": "Dateien senden",
@@ -620,7 +642,8 @@ def create_app(cfg):
@app.get("/kameras") @app.get("/kameras")
def kameras(): def kameras():
return page("kameras.html", "Kameras") """Alte Links → Wand (Kameras sitzen als Snapshot-Thumbs auf den Karten)."""
return redirect("/", code=301)
@app.get("/dateien") @app.get("/dateien")
def dateien(): def dateien():
@@ -628,7 +651,7 @@ def create_app(cfg):
@app.get("/projekte") @app.get("/projekte")
def projekte(): def projekte():
return redirect("/auftraege", code=301) return page("projekte.html", "Projekte")
@app.get("/auftraege") @app.get("/auftraege")
def auftraege_page(): def auftraege_page():
@@ -739,6 +762,10 @@ def create_app(cfg):
def ideen(): def ideen():
return redirect("/auftraege", code=301) return redirect("/auftraege", code=301)
@app.get("/jetzt")
def jetzt_page():
return page("jetzt.html", "Jetzt")
@app.get("/aktivitaet") @app.get("/aktivitaet")
def aktivitaet(): def aktivitaet():
return page("aktivitaet.html", "Aktivität") return page("aktivitaet.html", "Aktivität")
@@ -798,6 +825,7 @@ def create_app(cfg):
ntfy = data.get("ntfy") if isinstance(data.get("ntfy"), dict) else None ntfy = data.get("ntfy") if isinstance(data.get("ntfy"), dict) else None
alerts = data.get("alerts") if isinstance(data.get("alerts"), dict) else None alerts = data.get("alerts") if isinstance(data.get("alerts"), dict) else None
auto_queue = data.get("auto_queue") if "auto_queue" in data else None auto_queue = data.get("auto_queue") if "auto_queue" in data else None
auto_retry = data.get("auto_retry") if "auto_retry" in data else None
fqdn = data.get("fqdn") if "fqdn" in data else None fqdn = data.get("fqdn") if "fqdn" in data else None
watch_dir = data.get("watch_dir") if "watch_dir" in data else None watch_dir = data.get("watch_dir") if "watch_dir" in data else None
return jsonify(grotti_config.save_settings( return jsonify(grotti_config.save_settings(
@@ -807,6 +835,7 @@ def create_app(cfg):
ntfy=ntfy, ntfy=ntfy,
alerts=alerts, alerts=alerts,
auto_queue=auto_queue, auto_queue=auto_queue,
auto_retry=auto_retry,
fqdn=fqdn, fqdn=fqdn,
watch_dir=watch_dir, watch_dir=watch_dir,
)) ))
@@ -821,8 +850,17 @@ def create_app(cfg):
) )
return jsonify({"ok": True}) return jsonify({"ok": True})
@app.post("/api/v1/ntfy/test")
def ntfy_test():
ok = notify.send_ntfy(cfg, "Grotti-Test: ntfy ist verbunden.")
if not ok:
raise GrottiError(
"ntfy nicht erreichbar. Server, Topic und „aktiv“ prüfen.",
"ntfy",
)
return jsonify({"ok": True})
@app.get("/api/v1/drucker") @app.get("/api/v1/drucker")
@app.get("/api/drucker") # veraltet — Alias zu /api/v1/drucker
def printers(): def printers():
rows = store.list_printers(conn()) rows = store.list_printers(conn())
clears = store.bed_clear_map(conn()) clears = store.bed_clear_map(conn())
@@ -908,7 +946,18 @@ def create_app(cfg):
if other and str(other["id"]) != str(printer_id): if other and str(other["id"]) != str(printer_id):
raise GrottiError("Diesen Host gibt es schon.", "host") raise GrottiError("Diesen Host gibt es schon.", "host")
locked = data.get("locked") if "locked" in data else current.get("locked") locked = data.get("locked") if "locked" in data else current.get("locked")
row = store.update_printer(conn(), printer_id, name, host, family, locked=bool(locked)) start_window = data.get("start_window") if "start_window" in data else None
if start_window is not None and not isinstance(start_window, dict):
raise GrottiError("Zeitfenster muss ein Objekt sein.", "start_window")
row = store.update_printer(
conn(),
printer_id,
name,
host,
family,
locked=bool(locked),
start_window=start_window,
)
return jsonify(row) return jsonify(row)
@app.delete("/api/v1/drucker/id/<printer_id>") @app.delete("/api/v1/drucker/id/<printer_id>")
@@ -943,6 +992,40 @@ def create_app(cfg):
abort(404) abort(404)
return image[1], 200, {"Content-Type": image[0], "Cache-Control": "no-store"} return image[1], 200, {"Content-Type": image[0], "Cache-Control": "no-store"}
@app.get("/api/v1/kamera/stream")
def camera_stream():
"""MJPEG vom Drucker durchreichen (kein Frame-Cache)."""
host = require_host(request.args.get("host", ""))
try:
upstream = moonraker.open_camera_stream(host)
except Exception:
abort(404)
ctype = upstream.headers.get("Content-Type") or "multipart/x-mixed-replace; boundary=--"
@stream_with_context
def generate():
try:
while True:
chunk = upstream.read(16384)
if not chunk:
break
yield chunk
finally:
try:
upstream.close()
except Exception:
pass
return Response(
generate(),
status=200,
headers={
"Content-Type": ctype,
"Cache-Control": "no-store",
"X-Accel-Buffering": "no",
},
)
@app.get("/api/v1/vorschau") @app.get("/api/v1/vorschau")
def live_preview(): def live_preview():
host = require_host(request.args.get("host", "")) host = require_host(request.args.get("host", ""))
@@ -976,7 +1059,27 @@ def create_app(cfg):
status = data.get("status") or "idee" status = data.get("status") or "idee"
if status not in PROJECT_STATUS: if status not in PROJECT_STATUS:
raise GrottiError("Unbekannter Status.", "status") raise GrottiError("Unbekannter Status.", "status")
return jsonify(store.create_project(conn(), name, (data.get("notes") or "").strip(), status)), 201 folder = (data.get("folder") or "").strip()
if not folder:
folder = library.default_project_folder(name)
try:
if cfg.get("watch_dir"):
folder = library.ensure_project_folder(cfg, folder)
else:
folder = library.normalize_folder(folder)
except GrottiError as exc:
if exc.code in ("ordner", "watch"):
raise GrottiError(
"%s — Ordner relativ unter dem Slice-Pfad, z. B. Projekte/%s (kein D:\\…)."
% (exc.message.rstrip("."), library.project_slug(name)),
exc.code,
getattr(exc, "status", 400) or 400,
) from exc
raise
row = store.create_project(
conn(), name, (data.get("notes") or "").strip(), status, folder=folder
)
return jsonify(row), 201
@app.get("/api/v1/projekte/<int:project_id>") @app.get("/api/v1/projekte/<int:project_id>")
def project_get(project_id): def project_get(project_id):
@@ -1000,6 +1103,13 @@ def create_app(cfg):
if data["status"] not in PROJECT_STATUS: if data["status"] not in PROJECT_STATUS:
raise GrottiError("Unbekannter Status.", "status") raise GrottiError("Unbekannter Status.", "status")
fields["status"] = data["status"] fields["status"] = data["status"]
if "folder" in data:
folder = (data.get("folder") or "").strip()
if folder and cfg.get("watch_dir"):
folder = library.ensure_project_folder(cfg, folder)
elif folder:
folder = library.normalize_folder(folder)
fields["folder"] = folder
row = store.update_project(conn(), project_id, fields) row = store.update_project(conn(), project_id, fields)
if not row: if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404) raise GrottiError("Das Projekt fehlt.", "projekt", 404)
@@ -1007,15 +1117,95 @@ def create_app(cfg):
@app.delete("/api/v1/projekte/<int:project_id>") @app.delete("/api/v1/projekte/<int:project_id>")
def project_delete(project_id): def project_delete(project_id):
row = store.get_project(conn(), project_id)
if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
# ?force=1 löscht den Projektordner inkl. Inhalt
force = (request.args.get("force") or "").strip() in ("1", "true", "yes")
folder = (row.get("folder") or "").strip()
if folder and cfg.get("watch_dir"):
library.delete_project_disk_folder(cfg, folder, force=force)
if not store.delete_project(conn(), project_id): if not store.delete_project(conn(), project_id):
raise GrottiError("Das Projekt fehlt.", "projekt", 404) raise GrottiError("Das Projekt fehlt.", "projekt", 404)
return "", 204 return "", 204
@app.get("/api/v1/projekte/<int:project_id>/assets")
def project_assets_list(project_id):
row = store.get_project(conn(), project_id)
if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
folder = (row.get("folder") or "").strip()
if not folder:
return jsonify([])
if not cfg.get("watch_dir"):
return jsonify([])
return jsonify(library.list_project_assets(cfg, folder))
@app.post("/api/v1/projekte/<int:project_id>/assets")
def project_assets_upload(project_id):
row = store.get_project(conn(), project_id)
if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
folder = (row.get("folder") or "").strip()
if not folder:
raise GrottiError("Das Projekt hat keinen Ordner.", "ordner")
if not cfg.get("watch_dir"):
raise GrottiError("Kein Slice-Ordner gesetzt — unter Einstellungen festlegen.", "watch")
upload = request.files.get("file")
if not upload or not upload.filename:
raise GrottiError("Keine Datei angehängt.", "datei")
saved = library.save_project_asset(cfg, folder, upload.filename, upload.read())
return jsonify(saved), 201
@app.get("/api/v1/projekte/<int:project_id>/assets/<path:rel>")
def project_assets_download(project_id, rel):
row = store.get_project(conn(), project_id)
if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
folder = (row.get("folder") or "").strip()
if not folder:
raise GrottiError("Das Projekt hat keinen Ordner.", "ordner")
path = library.resolve_project_asset(cfg, folder, rel)
return send_file(path, as_attachment=True, download_name=path.name)
@app.delete("/api/v1/projekte/<int:project_id>/assets/<path:rel>")
def project_assets_delete(project_id, rel):
row = store.get_project(conn(), project_id)
if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
folder = (row.get("folder") or "").strip()
if not folder:
raise GrottiError("Das Projekt hat keinen Ordner.", "ordner")
library.delete_project_asset(cfg, folder, rel)
return "", 204
@app.get("/api/v1/dateien") @app.get("/api/v1/dateien")
def file_list(): def file_list():
library.sync_watch(conn(), cfg, force=False) library.sync_watch(conn(), cfg, force=False)
return jsonify(store.list_files(conn(), watch_dir=cfg.get("watch_dir"))) return jsonify(store.list_files(conn(), watch_dir=cfg.get("watch_dir")))
@app.post("/api/v1/dateien/<int:file_id>/meta")
def file_meta_refresh(file_id):
"""Eine Datei neu parsen — z. B. paint_index für ACE nach Orca-Deep-Link."""
out = library.refresh_one_file(conn(), file_id, watch_dir=cfg.get("watch_dir"))
if not out:
raise GrottiError("Datei nicht gefunden.", "datei", 404)
return jsonify(out)
@app.get("/api/v1/orca-fokus")
def orca_focus():
"""Zuletzt von orca_senden gesetzte Datei — offene Tabs schalten um statt neu zu öffnen."""
path = Path(cfg["data_dir"]) / "orca-senden-focus.json"
if not path.is_file():
return jsonify({})
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return jsonify({})
if not isinstance(data, dict) or data.get("file_id") is None:
return jsonify({})
return jsonify(data)
@app.post("/api/v1/dateien/neu-lesen") @app.post("/api/v1/dateien/neu-lesen")
def file_rescan(): def file_rescan():
result = library.sync_watch(conn(), cfg, force=True) result = library.sync_watch(conn(), cfg, force=True)
@@ -1047,6 +1237,14 @@ def create_app(cfg):
moved = library.move_file(conn(), cfg, file_id, folder) moved = library.move_file(conn(), cfg, file_id, folder)
return jsonify(moved) return jsonify(moved)
@app.post("/api/v1/dateien/<int:file_id>/umbenennen")
def file_rename(file_id):
data = body()
name = (data.get("name") or "").strip()
if not name:
raise GrottiError("Neuer Dateiname fehlt.", "datei")
return jsonify(library.rename_library_file(conn(), cfg, file_id, name))
@app.post("/api/v1/dateien") @app.post("/api/v1/dateien")
def file_upload(): def file_upload():
upload = request.files.get("file") upload = request.files.get("file")
@@ -1130,13 +1328,7 @@ def create_app(cfg):
@app.delete("/api/v1/dateien/<int:file_id>") @app.delete("/api/v1/dateien/<int:file_id>")
def file_delete(file_id): def file_delete(file_id):
row = store.get_file_row(conn(), file_id) library.delete_library_file(conn(), cfg, file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
if row["source"] != "upload":
raise GrottiError("Die Datei kommt aus dem Watch-Ordner und bleibt, solange sie dort liegt.", "watch")
store.delete_file(conn(), file_id)
Path(row["stored_path"]).unlink(missing_ok=True)
return "", 204 return "", 204
@app.post("/api/v1/senden") @app.post("/api/v1/senden")
@@ -1358,6 +1550,62 @@ def create_app(cfg):
}) })
return jsonify(items) return jsonify(items)
@app.get("/api/v1/jetzt")
def jetzt_api():
"""Priorisierte Handlungsliste, laufende Drucke und Farm-ETA."""
from grotti import jetzt as jetzt_mod
send_jobs = []
try:
send_jobs = app.extensions["send_queue"].list_jobs() or []
except Exception:
send_jobs = []
return jsonify(jetzt_mod.build(
conn(),
app.extensions["watcher"].latest or [],
printers=store.list_printers(conn()),
send_jobs=send_jobs,
))
@app.get("/api/v1/prognose")
def prognose_api():
"""Wann welcher Drucker frei ist und wann alles fertig ist."""
from grotti import jetzt as jetzt_mod
running = []
for live in app.extensions["watcher"].latest or []:
state = (live.get("state") or "").lower()
if state not in ("printing", "paused", "heating"):
continue
running.append({
"host": live.get("host") or "",
"name": live.get("name") or "",
"remain": live.get("remain") or 0,
"progress": live.get("progress") or 0,
"filename": live.get("filename") or "",
"state": state,
"free_at": None,
})
eta = jetzt_mod.farm_eta(running)
for item in running:
rem = float(item.get("remain") or 0)
item["free_at"] = jetzt_mod._iso_remain(rem) if rem > 0 else None
waiting = [j for j in store.list_print_queue(conn(), limit=80) if j.get("status") == "waiting"]
return jsonify({
"running": running,
"waiting_count": len(waiting),
"eta": eta,
})
@app.get("/api/v1/suche")
def suche_api():
"""Globale Suche für Ctrl+K."""
q = (request.args.get("q") or "").strip()
if len(q) < 1:
return jsonify({"items": []})
items = store.global_search(conn(), q, limit=20)
return jsonify({"items": items})
@app.post("/api/v1/druck-queue") @app.post("/api/v1/druck-queue")
def print_queue_create(): def print_queue_create():
data = body() data = body()
@@ -1432,8 +1680,8 @@ def create_app(cfg):
c = conn() c = conn()
store.set_bed_needs_clear(c, host, False, "quittiert") store.set_bed_needs_clear(c, host, False, "quittiert")
run_json = None run_json = None
if any(k in data for k in ("verdict", "verdict_reason", "pieces_ok", "pieces_bad")):
run_row = store.run_for_abraeumen_verdict(c, host) run_row = store.run_for_abraeumen_verdict(c, host)
if any(k in data for k in ("verdict", "verdict_reason", "pieces_ok", "pieces_bad")):
if not run_row: if not run_row:
raise GrottiError("Kein Lauf zum Bewerten — Protokoll prüfen.", "lauf", 404) raise GrottiError("Kein Lauf zum Bewerten — Protokoll prüfen.", "lauf", 404)
try: try:
@@ -1446,12 +1694,42 @@ def create_app(cfg):
except ValueError as exc: except ValueError as exc:
raise GrottiError(str(exc), "urteil") from exc raise GrottiError(str(exc), "urteil") from exc
run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n) run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n)
# Live-Lauf schließen: Moonraker-History bleibt oft ohne Endzeit hängen,
# sonst bleibt der Druck im Protokoll ewig „in_progress“ trotz Urteil.
if run_row and (run_row.get("state") or "") in ("in_progress", "printing", "paused"):
closed = store.complete_open_run(c, run_row["id"], state="completed")
if closed:
try:
jobs.apply_deduction(c, closed, cfg["energy"])
except Exception as exc:
log.warning("Abräumen Abzug %s: %s", host, exc)
row = store.get_run_row(c, closed["id"]) or closed
run_json = store.run_out(row)
reprint = None
if bool(data.get("reprint")) and run_row and run_row.get("file_id"):
reprint = store.enqueue_print(
c,
run_row["file_id"],
host,
"",
50,
run_row.get("spool_id"),
[],
False,
False,
)
store.add_event(
c, host, "", "queue",
"Nochmal eingereiht: %s" % (run_row.get("filename") or run_row["file_id"]),
run_row.get("filename") or "",
)
return jsonify({ return jsonify({
"ok": True, "ok": True,
"host": host, "host": host,
"needs_clear": False, "needs_clear": False,
"message": "Platte quittiert.", "message": "Platte quittiert." + (" Nochmal in der Warteschlange." if reprint else ""),
"run": run_json, "run": run_json,
"reprint": reprint,
"verdict_reasons": VERDICT_REASONS, "verdict_reasons": VERDICT_REASONS,
}) })
@@ -1486,7 +1764,12 @@ def create_app(cfg):
if status == "running" and not data.get("resume"): if status == "running" and not data.get("resume"):
raise GrottiError("Nur ein gestoppter Ablauf lässt sich fortsetzen.", "status") raise GrottiError("Nur ein gestoppter Ablauf lässt sich fortsetzen.", "status")
if status == "waiting" and current_row.get("status") not in ("waiting",): if status == "waiting" and current_row.get("status") not in ("waiting",):
# Undo: abgebrochen/entfernt wieder einreihen
if not (data.get("undo") and current_row.get("status") in ("cancelled", "dismissed")):
raise GrottiError("Status lässt sich so nicht setzen.", "status") raise GrottiError("Status lässt sich so nicht setzen.", "status")
fields["message"] = ""
fields["phase"] = ""
fields["phase_started_at"] = None
fields["status"] = status fields["status"] = status
if status == "running" and data.get("resume"): if status == "running" and data.get("resume"):
row = current_row row = current_row
@@ -1544,11 +1827,14 @@ def create_app(cfg):
@app.get("/api/v1/ereignisse") @app.get("/api/v1/ereignisse")
def events_list(): def events_list():
unread = request.args.get("unread") == "1" unread = request.args.get("unread") == "1"
alerts_only = request.args.get("alerts") == "1"
try: try:
limit = max(1, min(1000, int(request.args.get("limit") or 40))) limit = max(1, min(1000, int(request.args.get("limit") or 40)))
except (TypeError, ValueError): except (TypeError, ValueError):
limit = 40 limit = 40
kinds = [k for k in (request.args.get("kind") or "").split(",") if k.strip()] kinds = [k for k in (request.args.get("kind") or "").split(",") if k.strip()]
if alerts_only and not kinds:
kinds = list(store.ALERT_EVENT_KINDS)
since = None since = None
if request.args.get("since"): if request.args.get("since"):
try: try:
@@ -1564,7 +1850,10 @@ def create_app(cfg):
kinds=kinds or None, kinds=kinds or None,
since=since, since=since,
), ),
"unread": store.unread_event_count(conn()), "unread": store.unread_event_count(
conn(),
kinds=list(store.ALERT_EVENT_KINDS) if alerts_only or unread else None,
),
}) })
@app.post("/api/v1/ereignisse/gelesen") @app.post("/api/v1/ereignisse/gelesen")
@@ -1576,7 +1865,7 @@ def create_app(cfg):
ids = [int(item) for item in ids] ids = [int(item) for item in ids]
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise GrottiError("IDs müssen Zahlen sein.", "id") from exc raise GrottiError("IDs müssen Zahlen sein.", "id") from exc
unread = store.mark_events_read(conn(), ids) unread = store.mark_events_read(conn(), ids, kinds=list(store.ALERT_EVENT_KINDS) if not ids else None)
return jsonify({"unread": unread}) return jsonify({"unread": unread})
@app.post("/api/v1/dateien/von-drucker") @app.post("/api/v1/dateien/von-drucker")
@@ -1939,11 +2228,27 @@ def create_app(cfg):
@app.patch("/api/v1/spulen/<int:spool_id>") @app.patch("/api/v1/spulen/<int:spool_id>")
def spool_patch(spool_id): def spool_patch(spool_id):
row = store.update_spool(conn(), spool_id, spool_fields(body(), partial=True, spool_id=spool_id)) data = body()
fields = spool_fields(data, partial=True, spool_id=spool_id)
if "archived" in data or "archived_at" in data:
if data.get("archived") is False or data.get("archived_at") is None:
fields["archived_at"] = None
elif data.get("archived") is True or data.get("archived_at") in (True, "now"):
fields["archived_at"] = "now"
row = store.update_spool(conn(), spool_id, fields)
if not row: if not row:
raise GrottiError("Die Spule fehlt.", "spule", 404) raise GrottiError("Die Spule fehlt.", "spule", 404)
return jsonify(row) return jsonify(row)
@app.post("/api/v1/spulen/<int:spool_id>/leer")
def spool_empty(spool_id):
"""Rest 0, Regal, archivieren — wenn der Rest falsch geschätzt war."""
row = store.mark_spool_empty(conn(), spool_id)
if not row:
raise GrottiError("Die Spule fehlt.", "spule", 404)
store.add_event(conn(), "", "", "spule", "Spule „%s“ als leer markiert" % row["name"])
return jsonify(row)
@app.post("/api/v1/spulen/<int:spool_id>/einlegen") @app.post("/api/v1/spulen/<int:spool_id>/einlegen")
def spool_load(spool_id): def spool_load(spool_id):
"""Spule an Drucker/Platz legen; vorherige Rolle dort geht ins Regal.""" """Spule an Drucker/Platz legen; vorherige Rolle dort geht ins Regal."""
@@ -1953,7 +2258,8 @@ def create_app(cfg):
spool = store.get_spool_row(conn(), spool_id) spool = store.get_spool_row(conn(), spool_id)
if not spool: if not spool:
raise GrottiError("Die Spule fehlt.", "spule", 404) raise GrottiError("Die Spule fehlt.", "spule", 404)
live_run = store.find_live_run(conn(), host) # Auch offene History-Jobs (Pause/Resume nach Moonraker-Split), nicht nur live:
live_run = store.find_active_run(conn(), host)
mm_now = float((live_run or {}).get("filament_used_mm") or 0) mm_now = float((live_run or {}).get("filament_used_mm") or 0)
printing = bool( printing = bool(
live_run and (live_run.get("state") or "") in ("in_progress", "printing", "paused") live_run and (live_run.get("state") or "") in ("in_progress", "printing", "paused")
@@ -2002,6 +2308,8 @@ def create_app(cfg):
updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot}) updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot})
if printing: if printing:
store.open_segment(conn(), live_run["id"], spool_id, slot, mm_now) store.open_segment(conn(), live_run["id"], spool_id, slot, mm_now)
store.assign_run_spool(conn(), live_run["id"], spool_id)
store.update_send_spool(conn(), host, live_run.get("filename") or "", spool_id)
store.add_event( store.add_event(
conn(), conn(),
host, host,
@@ -2147,7 +2455,11 @@ def create_app(cfg):
@app.get("/api/v1/laeufe") @app.get("/api/v1/laeufe")
def run_list(): def run_list():
return jsonify(store.list_runs(conn())) try:
limit = min(1000, max(1, int(request.args.get("limit") or 500)))
except (TypeError, ValueError):
limit = 500
return jsonify(store.list_runs(conn(), limit=limit))
@app.get("/api/v1/laeufe/aktuell") @app.get("/api/v1/laeufe/aktuell")
def run_aktuell(): def run_aktuell():
@@ -2303,6 +2615,7 @@ def create_app(cfg):
host = (request.args.get("host") or "").strip() host = (request.args.get("host") or "").strip()
material = (request.args.get("material") or "").strip() material = (request.args.get("material") or "").strip()
brand = (request.args.get("brand") or "").strip() brand = (request.args.get("brand") or "").strip()
color = (request.args.get("color") or "").strip()
project_id = optional_id(request.args.get("project_id")) project_id = optional_id(request.args.get("project_id"))
return jsonify(store.statistics( return jsonify(store.statistics(
conn(), conn(),
@@ -2311,6 +2624,7 @@ def create_app(cfg):
host=host, host=host,
material=material or "", material=material or "",
brand=brand or "", brand=brand or "",
color=color or "",
project_id=project_id, project_id=project_id,
)) ))
@@ -2388,12 +2702,12 @@ def create_app(cfg):
finally: finally:
events_bus.unsubscribe(q) events_bus.unsubscribe(q)
# Kein Connection-Header: waitress verbietet Hop-by-Hop-Header (PEP 3333).
return Response( return Response(
stream_with_context(generate()), stream_with_context(generate()),
mimetype="text/event-stream; charset=utf-8", mimetype="text/event-stream; charset=utf-8",
headers={ headers={
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", "X-Accel-Buffering": "no",
}, },
) )
+48 -3
View File
@@ -28,13 +28,29 @@ async function api(path, options = {}) {
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
body = JSON.stringify(options.json); body = JSON.stringify(options.json);
} }
const response = await fetch("/api/v1" + path, { const timeoutMs = options.timeoutMs != null ? Number(options.timeoutMs) : 20000;
const ctrl = options.signal ? null : new AbortController();
const timer = ctrl && timeoutMs > 0
? setTimeout(() => ctrl.abort(), timeoutMs)
: 0;
let response;
try {
response = await fetch("/api/v1" + path, {
method, method,
headers, headers,
body, body,
cache: "no-store", cache: "no-store",
credentials: "same-origin", credentials: "same-origin",
signal: options.signal || (ctrl && ctrl.signal),
}); });
} catch (error) {
if (error && error.name === "AbortError") {
throw new Error("Zeitüberschreitung — Server antwortet nicht.");
}
throw error;
} finally {
if (timer) clearTimeout(timer);
}
if (response.status === 401) { if (response.status === 401) {
const here = location.pathname + location.search; const here = location.pathname + location.search;
if (!here.startsWith("/login") && !here.startsWith("/setup")) { if (!here.startsWith("/login") && !here.startsWith("/setup")) {
@@ -63,6 +79,34 @@ function withToken(url) {
return url; return url;
} }
const CAMERA_MODE_KEY = "grotti-camera";
function cameraMode() {
try {
return localStorage.getItem(CAMERA_MODE_KEY) === "stream" ? "stream" : "snapshot";
} catch (error) {
return "snapshot";
}
}
function setCameraMode(mode) {
const value = mode === "stream" ? "stream" : "snapshot";
try { localStorage.setItem(CAMERA_MODE_KEY, value); } catch (error) {}
window.dispatchEvent(new CustomEvent("grotti-camera-mode", { detail: value }));
return value;
}
/** Snapshot oder Stream-URL je nach Browser-Einstellung. bustMs nur für Snapshot. */
function cameraUrl(printer, bustMs) {
if (!printer || !printer.camera) return "";
if (cameraMode() === "stream" && printer.camera_stream) {
return withToken(printer.camera_stream);
}
let url = withToken(printer.camera);
if (bustMs) url += "&t=" + Math.floor(Date.now() / bustMs);
return url;
}
function euro(value) { function euro(value) {
return Number(value || 0).toLocaleString("de-DE", { style: "currency", currency: "EUR" }); return Number(value || 0).toLocaleString("de-DE", { style: "currency", currency: "EUR" });
} }
@@ -231,9 +275,10 @@ function icon(name, cls) {
return `<svg class="ico${cls ? " " + cls : ""}" aria-hidden="true"><use href="#i-${name}"/></svg>`; return `<svg class="ico${cls ? " " + cls : ""}" aria-hidden="true"><use href="#i-${name}"/></svg>`;
} }
function chipHtml(state, label, small) { function chipHtml(state, label, small, iconName) {
const text = label || STATE_LABELS[state] || state || ""; const text = label || STATE_LABELS[state] || state || "";
return `<span class="chip ${escapeHtml(state || "")}${small ? " sm" : ""}">${escapeHtml(text)}</span>`; const ico = iconName ? icon(iconName, "chip-ico") : "";
return `<span class="chip ${escapeHtml(state || "")}${small ? " sm" : ""}">${ico}${escapeHtml(text)}</span>`;
} }
function shownState(printer) { function shownState(printer) {
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+1160 -125
View File
File diff suppressed because it is too large. Load diff
+528
View File
@@ -0,0 +1,528 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Lucide icons, built by scripts/build_icons.py — 111 symbols -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<symbol id="i-activity" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
</symbol>
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</symbol>
<symbol id="i-arrow-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14" />
<path d="m19 12-7 7-7-7" />
</symbol>
<symbol id="i-arrow-up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m5 12 7-7 7 7" />
<path d="M12 19V5" />
</symbol>
<symbol id="i-ban" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="m4.9 4.9 14.2 14.2" />
</symbol>
<symbol id="i-bed" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="12" x="2" y="6" rx="2" />
</symbol>
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.268 21a2 2 0 0 0 3.464 0" />
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" />
</symbol>
<symbol id="i-book" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</symbol>
<symbol id="i-brush" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08" />
<path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z" />
</symbol>
<symbol id="i-bulb" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" />
<path d="M9 18h6" />
<path d="M10 22h4" />
</symbol>
<symbol id="i-calendar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
</symbol>
<symbol id="i-camera" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</symbol>
<symbol id="i-chamber" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</symbol>
<symbol id="i-chart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<path d="M18 17V9" />
<path d="M13 17V5" />
<path d="M8 17v-3" />
</symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</symbol>
<symbol id="i-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15 18-6-6 6-6" />
</symbol>
<symbol id="i-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6" />
</symbol>
<symbol id="i-circle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</symbol>
<symbol id="i-circle-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</symbol>
<symbol id="i-circle-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="m15 9-6 6" />
<path d="m9 9 6 6" />
</symbol>
<symbol id="i-clipboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<path d="M12 11h4" />
<path d="M12 16h4" />
<path d="M8 11h.01" />
<path d="M8 16h.01" />
</symbol>
<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</symbol>
<symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</symbol>
<symbol id="i-cube" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</symbol>
<symbol id="i-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6" />
</symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</symbol>
<symbol id="i-droplet" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z" />
</symbol>
<symbol id="i-edit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
<path d="m15 5 4 4" />
</symbol>
<symbol id="i-euro" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 10h12" />
<path d="M4 14h9" />
<path d="M19 6a7.7 7.7 0 0 0-5.2-2A7.9 7.9 0 0 0 6 12c0 4.4 3.5 8 7.8 8 2 0 3.8-.8 5.2-2" />
</symbol>
<symbol id="i-external" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</symbol>
<symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</symbol>
<symbol id="i-eye-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
<path d="m2 2 20 20" />
</symbol>
<symbol id="i-fan" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z" />
<path d="M12 12v.01" />
</symbol>
<symbol id="i-fan-aux" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.8 19.6A2 2 0 1 0 14 16H2" />
<path d="M17.5 8a2.5 2.5 0 1 1 2 4H2" />
<path d="M9.8 4.4A2 2 0 1 1 11 8H2" />
</symbol>
<symbol id="i-fan-chamber" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 12H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<path d="M6 8h12" />
<path d="M18.3 17.7a2.5 2.5 0 0 1-3.16 3.83 2.53 2.53 0 0 1-1.14-2V12" />
<path d="M6.6 15.6A2 2 0 1 0 10 17v-5" />
</symbol>
<symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</symbol>
<symbol id="i-filter" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</symbol>
<symbol id="i-flame" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />
</symbol>
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</symbol>
<symbol id="i-folder-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
</symbol>
<symbol id="i-gauge" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12 14 4-4" />
<path d="M3.34 19a10 10 0 1 1 17.32 0" />
</symbol>
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</symbol>
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" x2="2" y1="12" y2="12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
<line x1="6" x2="6.01" y1="16" y2="16" />
<line x1="10" x2="10.01" y1="16" y2="16" />
</symbol>
<symbol id="i-hash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="9" y2="9" />
<line x1="4" x2="20" y1="15" y2="15" />
<line x1="10" x2="8" y1="3" y2="21" />
<line x1="16" x2="14" y1="3" y2="21" />
</symbol>
<symbol id="i-heart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
</symbol>
<symbol id="i-history" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M12 7v5l4 2" />
</symbol>
<symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</symbol>
<symbol id="i-image" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</symbol>
<symbol id="i-inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</symbol>
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</symbol>
<symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" />
<path d="m21 2-9.6 9.6" />
<circle cx="7.5" cy="15.5" r="5.5" />
</symbol>
<symbol id="i-keyboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 8h.01" />
<path d="M12 12h.01" />
<path d="M14 8h.01" />
<path d="M16 12h.01" />
<path d="M18 8h.01" />
<path d="M6 8h.01" />
<path d="M7 16h10" />
<path d="M8 12h.01" />
<rect width="20" height="16" x="2" y="4" rx="2" />
</symbol>
<symbol id="i-layers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</symbol>
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</symbol>
<symbol id="i-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12h.01" />
<path d="M3 18h.01" />
<path d="M3 6h.01" />
<path d="M8 12h13" />
<path d="M8 18h13" />
<path d="M8 6h13" />
</symbol>
<symbol id="i-loader" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</symbol>
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</symbol>
<symbol id="i-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</symbol>
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
<line x1="4" x2="20" y1="18" y2="18" />
</symbol>
<symbol id="i-message" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
</symbol>
<symbol id="i-minus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" />
</symbol>
<symbol id="i-monitor" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</symbol>
<symbol id="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</symbol>
<symbol id="i-more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
<circle cx="5" cy="12" r="1" />
</symbol>
<symbol id="i-nozzle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m2 22 1-1h3l9-9" />
<path d="M3 21v-3l9-9" />
<path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.4.4a2.1 2.1 0 1 1-3 3l-3.8-3.8a2.1 2.1 0 1 1 3-3l.4.4Z" />
</symbol>
<symbol id="i-package" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" />
<path d="M12 22V12" />
<path d="m3.3 7 7.703 4.734a2 2 0 0 0 1.994 0L20.7 7" />
<path d="m7.5 4.27 9 5.15" />
</symbol>
<symbol id="i-palette" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
</symbol>
<symbol id="i-panel" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" />
</symbol>
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="14" y="4" width="4" height="16" rx="1" />
<rect x="6" y="4" width="4" height="16" rx="1" />
</symbol>
<symbol id="i-percent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" x2="5" y1="5" y2="19" />
<circle cx="6.5" cy="6.5" r="2.5" />
<circle cx="17.5" cy="17.5" r="2.5" />
</symbol>
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="6 3 20 12 6 21 6 3" />
</symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" />
<path d="M12 5v14" />
</symbol>
<symbol id="i-power" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v10" />
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
</symbol>
<symbol id="i-printer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6" />
<rect x="6" y="14" width="12" height="8" rx="1" />
</symbol>
<symbol id="i-queue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 12h11" />
<path d="M10 18h11" />
<path d="M10 6h11" />
<path d="M4 10h2" />
<path d="M4 6h1v4" />
<path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
</symbol>
<symbol id="i-receipt" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z" />
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8" />
<path d="M12 17.5v-11" />
</symbol>
<symbol id="i-redo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15 14 5-5-5-5" />
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13" />
</symbol>
<symbol id="i-refresh" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</symbol>
<symbol id="i-rotate-ccw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</symbol>
<symbol id="i-save" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" />
<path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7" />
<path d="M7 3v4a1 1 0 0 0 1 1h7" />
</symbol>
<symbol id="i-scan" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
<path d="M7 12h10" />
</symbol>
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</symbol>
<symbol id="i-send" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z" />
<path d="m21.854 2.147-10.94 10.939" />
</symbol>
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
</symbol>
<symbol id="i-sliders" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="21" x2="14" y1="4" y2="4" />
<line x1="10" x2="3" y1="4" y2="4" />
<line x1="21" x2="12" y1="12" y2="12" />
<line x1="8" x2="3" y1="12" y2="12" />
<line x1="21" x2="16" y1="20" y2="20" />
<line x1="12" x2="3" y1="20" y2="20" />
<line x1="14" x2="14" y1="2" y2="6" />
<line x1="8" x2="8" y1="10" y2="14" />
<line x1="16" x2="16" y1="18" y2="22" />
</symbol>
<symbol id="i-smartphone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
<path d="M12 18h.01" />
</symbol>
<symbol id="i-sparkles" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
<path d="M20 3v4" />
<path d="M22 5h-4" />
<path d="M4 17v2" />
<path d="M5 18H3" />
</symbol>
<symbol id="i-spool" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M6 12c0-1.7.7-3.2 1.8-4.2" />
<circle cx="12" cy="12" r="2" />
<path d="M18 12c0 1.7-.7 3.2-1.8 4.2" />
</symbol>
<symbol id="i-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
</symbol>
<symbol id="i-stop" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
</symbol>
<symbol id="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</symbol>
<symbol id="i-sweep" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08" />
<path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z" />
</symbol>
<symbol id="i-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" />
<circle cx="7.5" cy="7.5" r=".5" fill="currentColor" />
</symbol>
<symbol id="i-thermo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" />
</symbol>
<symbol id="i-timer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="10" x2="14" y1="2" y2="2" />
<line x1="12" x2="15" y1="14" y2="11" />
<circle cx="12" cy="14" r="8" />
</symbol>
<symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</symbol>
<symbol id="i-truck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2" />
<path d="M15 18H9" />
<path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14" />
<circle cx="17" cy="18" r="2" />
<circle cx="7" cy="18" r="2" />
</symbol>
<symbol id="i-undo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 14 4 9l5-5" />
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11" />
</symbol>
<symbol id="i-unlock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
</symbol>
<symbol id="i-up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m18 15-6-6-6 6" />
</symbol>
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" x2="12" y1="3" y2="15" />
</symbol>
<symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</symbol>
<symbol id="i-users" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</symbol>
<symbol id="i-weight" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="5" r="3" />
<path d="M6.5 8a2 2 0 0 0-1.905 1.46L2.1 18.5A2 2 0 0 0 4 21h16a2 2 0 0 0 1.925-2.54L19.4 9.5A2 2 0 0 0 17.48 8Z" />
</symbol>
<symbol id="i-wifi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h.01" />
<path d="M2 8.82a15 15 0 0 1 20 0" />
<path d="M5 12.859a10 10 0 0 1 14 0" />
<path d="M8.5 16.429a5 5 0 0 1 7 0" />
</symbol>
<symbol id="i-wifi-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h.01" />
<path d="M8.5 16.429a5 5 0 0 1 7 0" />
<path d="M5 12.859a10 10 0 0 1 5.17-2.69" />
<path d="M19 12.859a10 10 0 0 0-2.007-1.523" />
<path d="M2 8.82a15 15 0 0 1 4.177-2.643" />
<path d="M22 8.82a15 15 0 0 0-11.288-3.764" />
<path d="m2 2 20 20" />
</symbol>
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</symbol>
<symbol id="i-zap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" />
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 34 KiB

+28 -7
View File
@@ -1,27 +1,48 @@
/* Minimaler Service-Worker: App-Shell-Cache für Offline-Hinweis. */ /* Nur Static-Assets cachen — nie HTML-Navigation abfangen (sonst falsche Seite in Chrome). */
const CACHE = "grotti-shell-v1"; const CACHE = "grotti-shell-v3";
const SHELL = ["/", "/static/grotti.css?v=54", "/static/api.js?v=28", "/static/ui.js?v=2"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL).catch(() => undefined)));
self.skipWaiting(); self.skipWaiting();
event.waitUntil(
caches.open(CACHE).then((cache) =>
cache.addAll([
"/static/grotti.css?v=62",
"/static/api.js?v=32",
"/static/ui.js?v=5",
"/static/icons.svg",
"/static/fonts/InterVariable.woff2",
]).catch(() => undefined)
)
);
}); });
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then((keys) => caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
) ).then(() => self.clients.claim())
); );
self.clients.claim();
}); });
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
const req = event.request; const req = event.request;
if (req.method !== "GET") return; if (req.method !== "GET") return;
// Dokument-Navigation immer ans Netz — kein Fallback auf „/“.
if (req.mode === "navigate") return;
const url = new URL(req.url); const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/")) return; if (url.pathname.startsWith("/api/")) return;
if (!url.pathname.startsWith("/static/")) return;
event.respondWith( event.respondWith(
fetch(req).catch(() => caches.match(req).then((hit) => hit || caches.match("/"))) fetch(req)
.then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {});
}
return res;
})
.catch(() => caches.match(req))
); );
}); });
+171
View File
@@ -160,6 +160,7 @@ function promptAbraeumen(hostLabel) {
<div class="field"><label for="ab-ok">Stück gut</label><input id="ab-ok" type="number" min="0" step="1" value="0"></div> <div class="field"><label for="ab-ok">Stück gut</label><input id="ab-ok" type="number" min="0" step="1" value="0"></div>
<div class="field"><label for="ab-bad">Stück schlecht</label><input id="ab-bad" type="number" min="0" step="1" value="1"></div> <div class="field"><label for="ab-bad">Stück schlecht</label><input id="ab-bad" type="number" min="0" step="1" value="1"></div>
</div> </div>
<label class="check"><input type="checkbox" id="ab-reprint"> Gleich nochmal drucken</label>
<div class="actions"> <div class="actions">
<button type="submit" value="ok" class="btn-primary">Quittieren</button> <button type="submit" value="ok" class="btn-primary">Quittieren</button>
<button type="button" class="ghost" id="ab-cancel">Abbrechen</button> <button type="button" class="ghost" id="ab-cancel">Abbrechen</button>
@@ -193,6 +194,7 @@ function promptAbraeumen(hostLabel) {
verdict_reason: verdict.value === "ausschuss" ? dlg.querySelector("#ab-reason").value : "", verdict_reason: verdict.value === "ausschuss" ? dlg.querySelector("#ab-reason").value : "",
pieces_ok: Number(dlg.querySelector("#ab-ok").value) || 0, pieces_ok: Number(dlg.querySelector("#ab-ok").value) || 0,
pieces_bad: Number(dlg.querySelector("#ab-bad").value) || 0, pieces_bad: Number(dlg.querySelector("#ab-bad").value) || 0,
reprint: !!dlg.querySelector("#ab-reprint").checked,
}; };
dlg.remove(); dlg.remove();
resolve(payload); resolve(payload);
@@ -201,8 +203,177 @@ function promptAbraeumen(hostLabel) {
}); });
} }
/**
* Toast mit Rückgängig für umkehrbare Aktionen.
* @param {string} message
* @param {() => void | Promise<void>} onUndo
* @param {number} [ttlMs]
*/
function undoToast(message, onUndo, ttlMs) {
let host = document.querySelector(".toast-host");
if (!host) {
host = document.createElement("div");
host.className = "toast-host";
host.setAttribute("role", "status");
host.setAttribute("aria-live", "polite");
document.body.appendChild(host);
}
const node = document.createElement("div");
node.className = "toast good undo-toast";
node.innerHTML = '<div></div><button type="button">Rückgängig</button>';
node.querySelector("div").textContent = String(message ?? "");
let undone = false;
const btn = node.querySelector("button");
btn.addEventListener("click", async () => {
if (undone) return;
undone = true;
node.remove();
try { await onUndo(); } catch (error) { toast(error.message || String(error), "bad"); }
});
host.appendChild(node);
setTimeout(() => {
if (undone) return;
node.classList.add("out");
setTimeout(() => node.remove(), 260);
}, ttlMs || 6000);
}
/** Globale Suche Ctrl+K / Cmd+K. */
function openCommandPalette() {
const existing = document.getElementById("cmdk-dlg");
if (existing) {
existing.showModal();
existing.querySelector("input")?.focus();
return;
}
const dlg = document.createElement("dialog");
dlg.id = "cmdk-dlg";
dlg.className = "cmdk-dlg";
dlg.innerHTML = `<div class="cmdk-wrap">
<input type="search" placeholder="Drucker, Datei, Spule, Charge, Auftrag…" autocomplete="off">
<ul class="cmdk-list"></ul>
</div>`;
document.body.appendChild(dlg);
const input = dlg.querySelector("input");
const list = dlg.querySelector(".cmdk-list");
let items = [];
let active = 0;
let timer = 0;
function paint() {
list.innerHTML = items.map((item, i) =>
`<li class="${i === active ? "on" : ""}" data-i="${i}">
<span>${escapeHtml(item.title)}</span>
<span class="tiny">${escapeHtml(item.subtitle || "")}</span>
</li>`
).join("") || '<li class="tiny">Nichts gefunden.</li>';
}
async function search(q) {
q = (q || "").trim();
if (!q) { items = []; paint(); return; }
try {
const data = await api("/suche?q=" + encodeURIComponent(q));
items = data.items || [];
} catch (error) {
items = [];
}
active = 0;
paint();
}
function go(item) {
if (!item || !item.href) return;
dlg.close();
location.href = item.href;
}
input.addEventListener("input", () => {
clearTimeout(timer);
timer = setTimeout(() => search(input.value), 180);
});
input.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
active = Math.min(items.length - 1, active + 1);
paint();
} else if (event.key === "ArrowUp") {
event.preventDefault();
active = Math.max(0, active - 1);
paint();
} else if (event.key === "Enter") {
event.preventDefault();
go(items[active]);
} else if (event.key === "Escape") {
dlg.close();
}
});
list.addEventListener("click", (event) => {
const li = event.target.closest("[data-i]");
if (!li) return;
go(items[Number(li.dataset.i)]);
});
dlg.addEventListener("close", () => { /* bleibt im DOM */ });
dlg.showModal();
input.focus();
}
document.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && (event.key === "k" || event.key === "K")) {
event.preventDefault();
openCommandPalette();
}
});
window.undoToast = undoToast;
window.openCommandPalette = openCommandPalette;
window.RUN_STATES = RUN_STATES; window.RUN_STATES = RUN_STATES;
window.setConnectionBanner = setConnectionBanner; window.setConnectionBanner = setConnectionBanner;
window.createPoller = createPoller; window.createPoller = createPoller;
window.promptAbraeumen = promptAbraeumen; window.promptAbraeumen = promptAbraeumen;
window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS; window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS;
/** Listen-Pagination (clientseitig auf gefiltertem Array). */
const DEFAULT_PAGE_SIZE = 40;
function pageSlice(items, page, pageSize) {
const list = Array.isArray(items) ? items : [];
const size = Math.max(1, Number(pageSize) || DEFAULT_PAGE_SIZE);
const total = list.length;
const pages = Math.max(1, Math.ceil(total / size) || 1);
let p = Math.max(1, parseInt(page, 10) || 1);
if (p > pages) p = pages;
const start = (p - 1) * size;
return {
items: list.slice(start, start + size),
page: p,
pages,
total,
pageSize: size,
from: total ? start + 1 : 0,
to: Math.min(start + size, total),
};
}
function renderPager(el, state, onPage) {
if (!el) return;
const s = state || pageSlice([], 1);
if (s.total <= s.pageSize) {
el.hidden = true;
el.innerHTML = "";
return;
}
el.hidden = false;
el.className = "pager";
el.innerHTML =
'<button type="button" class="ghost pager-prev" ' + (s.page <= 1 ? "disabled" : "") + ">Zurück</button>" +
'<span class="pager-info">' + s.from + "–" + s.to + " von " + s.total + "</span>" +
'<button type="button" class="ghost pager-next" ' + (s.page >= s.pages ? "disabled" : "") + ">Weiter</button>";
el.querySelector(".pager-prev").onclick = () => onPage(s.page - 1);
el.querySelector(".pager-next").onclick = () => onPage(s.page + 1);
}
window.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
window.pageSlice = pageSlice;
window.renderPager = renderPager;
+18
View File
@@ -0,0 +1,18 @@
<div class="jetzt-eta" id="jetzt-eta" hidden></div>
<section class="queue-box is-empty" data-box="jetzt" id="jetzt-box">
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-sparkles"/></svg><span class="grow">Jetzt</span><span class="count todo" id="jetzt-count">0</span></h2>
<ul class="queue-list" id="jetzt-list"></ul>
</section>
<section class="run-stack" id="run-box" hidden>
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-play"/></svg><span class="grow">Läuft</span></h2>
<ul class="queue-list" id="run-list"></ul>
</section>
<section class="queue-box" data-box="send" id="send-box" hidden>
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-send"/></svg><span class="grow">Senden</span><span class="count" id="send-count">0</span></h2>
<ul class="queue-list" id="send-list"></ul>
</section>
<section class="queue-box" data-box="alerts" id="alert-box" hidden>
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-bell"/></svg><span class="grow">Meldungen</span><span class="count bad" id="alert-count">0</span></h2>
<ul class="queue-list" id="alert-list"></ul>
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button>&nbsp;<a href="/aktivitaet">alle&nbsp;→</a></div>
</section>
+21 -6
View File
@@ -11,22 +11,27 @@
</div> </div>
</div> </div>
<div class="filter-bar" id="f-range" role="group" aria-label="Zeitraum"> <div class="toolbar" id="f-range" role="group" aria-label="Zeitraum">
<div class="fchips">
<button type="button" class="fchip on" data-range="day">Heute</button> <button type="button" class="fchip on" data-range="day">Heute</button>
<button type="button" class="fchip" data-range="week">7 Tage</button> <button type="button" class="fchip" data-range="week">7 Tage</button>
<button type="button" class="fchip" data-range="all">Alle</button> <button type="button" class="fchip" data-range="all">Alle</button>
</div>
</div> </div>
<div class="filter-bar" id="f-kind" role="group" aria-label="Art"></div> <div class="toolbar" id="f-kind" role="group" aria-label="Art"></div>
<div class="filter-bar" id="f-host" role="group" aria-label="Drucker"></div> <div class="toolbar" id="f-host" role="group" aria-label="Drucker"></div>
<p id="tl-note" class="tiny"></p> <p id="tl-note" class="tiny"></p>
<div class="timeline" id="timeline"> <div class="timeline" id="timeline">
<div class="skeleton" style="height:4rem"></div> <div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div> <div class="skeleton" style="height:4rem"></div>
</div> </div>
<div class="pager" id="tl-pager" hidden></div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script> <script>
let tlPage = 1;
let tlItems = [];
const KINDS = [ const KINDS = [
["queue", "Warteschlange"], ["queue", "Warteschlange"],
["ablauf", "Ablauf"], ["ablauf", "Ablauf"],
@@ -109,14 +114,19 @@ function itemHtml(ev) {
function render(items) { function render(items) {
const box = document.getElementById("timeline"); const box = document.getElementById("timeline");
const note = document.getElementById("tl-note"); const note = document.getElementById("tl-note");
if (!items.length) { const pager = document.getElementById("tl-pager");
tlItems = items || [];
if (!tlItems.length) {
box.innerHTML = `<div class="card empty-state">${icon("activity")}<p>Keine Einträge${filter.range === "day" ? " heute" : ""}.</p>${ box.innerHTML = `<div class="card empty-state">${icon("activity")}<p>Keine Einträge${filter.range === "day" ? " heute" : ""}.</p>${
filter.range !== "all" ? `<button type="button" class="ghost" data-range-jump="all">Alle anzeigen</button>` : ""}</div>`; filter.range !== "all" ? `<button type="button" class="ghost" data-range-jump="all">Alle anzeigen</button>` : ""}</div>`;
note.textContent = ""; note.textContent = "";
renderPager(pager, null, () => {});
return; return;
} }
const paged = pageSlice(tlItems, tlPage, DEFAULT_PAGE_SIZE);
tlPage = paged.page;
const groups = []; const groups = [];
for (const ev of items) { for (const ev of paged.items) {
const label = dayLabel(ev.created_at); const label = dayLabel(ev.created_at);
if (!groups.length || groups[groups.length - 1].label !== label) groups.push({ label, items: [] }); if (!groups.length || groups[groups.length - 1].label !== label) groups.push({ label, items: [] });
groups[groups.length - 1].items.push(ev); groups[groups.length - 1].items.push(ev);
@@ -124,7 +134,11 @@ function render(items) {
box.innerHTML = groups.map((g) => box.innerHTML = groups.map((g) =>
`<section class="tl-day"><h3>${escapeHtml(g.label)} · ${g.items.length}</h3><ol class="tl-list">${g.items.map(itemHtml).join("")}</ol></section>` `<section class="tl-day"><h3>${escapeHtml(g.label)} · ${g.items.length}</h3><ol class="tl-list">${g.items.map(itemHtml).join("")}</ol></section>`
).join(""); ).join("");
note.textContent = items.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : ""; note.textContent = tlItems.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : "";
renderPager(pager, paged, (p) => {
tlPage = p;
render(tlItems);
});
} }
async function load() { async function load() {
@@ -135,6 +149,7 @@ async function load() {
if (filter.host) q.set("host", filter.host); if (filter.host) q.set("host", filter.host);
try { try {
const data = await api("/ereignisse?" + q.toString()); const data = await api("/ereignisse?" + q.toString());
tlPage = 1;
render(data.items || []); render(data.items || []);
} catch (err) { } catch (err) {
document.getElementById("timeline").innerHTML = `<div class="strip bad">${icon("alert")}<span>${escapeHtml(err.message)}</span></div>`; document.getElementById("timeline").innerHTML = `<div class="strip bad">${icon("alert")}<span>${escapeHtml(err.message)}</span></div>`;
+124 -18
View File
@@ -29,6 +29,7 @@
<thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead> <thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead>
<tbody id="order-rows"></tbody> <tbody id="order-rows"></tbody>
</table> </table>
<div class="pager" id="order-pager" hidden></div>
</div> </div>
</section> </section>
<section class="card"> <section class="card">
@@ -55,9 +56,9 @@
</div> </div>
<div class="field"><label for="order-notes">Notiz</label><textarea id="order-notes"></textarea></div> <div class="field"><label for="order-notes">Notiz</label><textarea id="order-notes"></textarea></div>
<div class="actions"> <div class="actions">
<button type="submit">Merken</button> <button type="submit" class="btn-primary">Speichern</button>
<button type="button" class="ghost" id="order-new">Neu</button> <button type="button" class="ghost" id="order-new">Neu</button>
<button type="button" class="warn" id="order-delete" hidden>Löschen</button> <button type="button" class="btn-danger" id="order-delete" hidden>Löschen</button>
</div> </div>
</form> </form>
<p id="order-note" class="tiny"></p> <p id="order-note" class="tiny"></p>
@@ -81,11 +82,16 @@
<div class="actions"><button type="submit">Position hinzufügen</button></div> <div class="actions"><button type="submit">Position hinzufügen</button></div>
</form> </form>
<details class="section-gap"> <details class="section-gap">
<summary>Kunde anlegen</summary> <summary>Kunde anlegen / bearbeiten</summary>
<form id="customer-form"> <form id="customer-form">
<input type="hidden" id="cust-id">
<div class="field"><label for="cust-name">Name</label><input id="cust-name" required></div> <div class="field"><label for="cust-name">Name</label><input id="cust-name" required></div>
<div class="field"><label for="cust-email">E-Mail</label><input id="cust-email" type="email"></div> <div class="field"><label for="cust-email">E-Mail</label><input id="cust-email" type="email"></div>
<button type="submit">Kunde speichern</button> <div class="actions">
<button type="submit" class="btn-primary">Kunde speichern</button>
<button type="button" class="ghost" id="cust-edit-current">Aktuellen laden</button>
<button type="button" class="btn-danger" id="cust-delete" hidden>Kunde löschen</button>
</div>
</form> </form>
</details> </details>
</section> </section>
@@ -130,7 +136,14 @@ function paintActuals(order) {
const el = document.getElementById("order-actuals"); const el = document.getElementById("order-actuals");
const a = order.actuals; const a = order.actuals;
if (!a) { el.textContent = ""; return; } if (!a) { el.textContent = ""; return; }
el.textContent = "Ist: " + euro(a.cost_total) + " · " + (hours(a.duration_s) || "0 min") + " · Ausschuss " + a.pieces_bad + " Stk"; const bits = [
"Ist: " + euro(a.cost_total),
hours(a.duration_s) || "0 min",
"Ausschuss " + a.pieces_bad + " Stk",
];
if (a.cost_per_part != null) bits.push(euro(a.cost_per_part) + "/Teil");
if (a.yield_percent != null) bits.push("Yield " + Math.round(a.yield_percent) + " %");
el.textContent = bits.join(" · ");
} }
function fillOrder(order) { function fillOrder(order) {
@@ -169,17 +182,55 @@ function renderItems(items) {
tr.children[0].textContent = item.file_name || ("#" + (item.file_id || "—")); tr.children[0].textContent = item.file_name || ("#" + (item.file_id || "—"));
tr.children[1].textContent = item.qty; tr.children[1].textContent = item.qty;
tr.children[2].textContent = item.done_ok + (item.done_bad ? " (−" + item.done_bad + ")" : ""); tr.children[2].textContent = item.done_ok + (item.done_bad ? " (−" + item.done_bad + ")" : "");
const tools = document.createElement("div");
tools.className = "queue-tools";
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
btn.className = "ghost tiny-btn"; btn.className = "ghost tiny-btn";
btn.textContent = "Einreihen"; btn.textContent = "Einreihen";
btn.disabled = !item.file_id || !current; btn.disabled = !item.file_id || !current;
btn.addEventListener("click", () => einreihen(item.id)); btn.addEventListener("click", () => einreihen(item.id));
tr.children[3].appendChild(btn); const edit = document.createElement("button");
edit.type = "button";
edit.className = "ghost tiny-btn";
edit.textContent = "Ändern";
edit.addEventListener("click", () => editItem(item));
const del = document.createElement("button");
del.type = "button";
del.className = "btn-danger tiny-btn";
del.textContent = "Löschen";
del.addEventListener("click", (event) => {
armButton(event.currentTarget, "Position löschen?", async () => {
await api("/auftraege/positionen/" + item.id, { method: "DELETE" });
toast("Position gelöscht.");
const full = await api("/auftraege/" + current.id);
fillOrder(full);
});
});
tools.append(btn, edit, del);
tr.children[3].appendChild(tools);
body.appendChild(tr); body.appendChild(tr);
}); });
} }
async function editItem(item) {
const qty = prompt("Stückzahl", String(item.qty || 1));
if (qty == null) return;
const price = prompt("Preis/Stk €", String(item.unit_price || 0));
if (price == null) return;
try {
await api("/auftraege/positionen/" + item.id, {
method: "PATCH",
json: { qty: Number(qty) || 1, unit_price: Number(price) || 0 },
});
const full = await api("/auftraege/" + current.id);
fillOrder(full);
toast("Position gespeichert.");
} catch (error) {
showNote("order-note", error);
}
}
async function einreihen(itemId) { async function einreihen(itemId) {
if (!current) return; if (!current) return;
try { try {
@@ -196,32 +247,52 @@ async function einreihen(itemId) {
} }
} }
let orderPage = 1;
function renderList() { function renderList() {
const body = document.getElementById("order-rows"); const body = document.getElementById("order-rows");
body.innerHTML = ""; body.innerHTML = "";
const pager = document.getElementById("order-pager");
if (!orders.length) { if (!orders.length) {
body.innerHTML = '<tr><td colspan="3" class="empty">Noch kein Auftrag.</td></tr>'; body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="ghost" id="order-empty-new">Ersten Auftrag anlegen →</button></p></div></td></tr>';
document.getElementById("order-empty-new")?.addEventListener("click", () => fillOrder(null));
renderPager(pager, null, () => {});
return; return;
} }
orders.forEach((order) => { const selected = document.getElementById("order-id").value;
const paged = pageSlice(orders, orderPage, DEFAULT_PAGE_SIZE);
orderPage = paged.page;
paged.items.forEach((order) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.className = "pick"; tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
tr.innerHTML = "<td></td><td></td><td></td>"; tr.innerHTML = "<td></td><td></td><td></td>";
tr.children[0].textContent = order.title; tr.children[0].textContent = order.title;
tr.children[1].textContent = STATUS_LABEL[order.status] || order.status; const st = order.status || "";
const chipState = st === "druckt" ? "printing" : (st === "fertig" || st === "geliefert" || st === "bezahlt") ? "done" : st === "bestaetigt" ? "waiting" : "idle";
tr.children[1].innerHTML = chipHtml(chipState, STATUS_LABEL[st] || st);
tr.children[2].textContent = (order.progress && order.progress.label) || ""; tr.children[2].textContent = (order.progress && order.progress.label) || "";
tr.addEventListener("click", async () => { tr.addEventListener("click", async () => {
try {
const full = await api("/auftraege/" + order.id); const full = await api("/auftraege/" + order.id);
fillOrder(full); fillOrder(full);
renderList();
} catch (error) {
showNote("order-note", error);
}
}); });
body.appendChild(tr); body.appendChild(tr);
}); });
renderPager(pager, paged, (p) => {
orderPage = p;
renderList();
});
} }
async function loadList() { async function loadList() {
const st = document.getElementById("filter-status").value; const st = document.getElementById("filter-status").value;
const q = st ? "?status=" + encodeURIComponent(st) : ""; const q = st ? "?status=" + encodeURIComponent(st) : "";
orders = await api("/auftraege" + q); orders = await api("/auftraege" + q);
orderPage = 1;
renderList(); renderList();
} }
@@ -253,7 +324,7 @@ document.getElementById("order-form").addEventListener("submit", async (event) =
? await api("/auftraege/" + id, { method: "PATCH", json: payload }) ? await api("/auftraege/" + id, { method: "PATCH", json: payload })
: await api("/auftraege", { method: "POST", json: payload }); : await api("/auftraege", { method: "POST", json: payload });
showNote("order-note", null); showNote("order-note", null);
document.getElementById("order-note").textContent = "Gemerkt."; document.getElementById("order-note").textContent = "Gespeichert.";
await loadList(); await loadList();
fillOrder(saved); fillOrder(saved);
} catch (error) { } catch (error) {
@@ -296,25 +367,60 @@ document.getElementById("item-form").addEventListener("submit", async (event) =>
document.getElementById("customer-form").addEventListener("submit", async (event) => { document.getElementById("customer-form").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
try { const id = document.getElementById("cust-id").value;
const row = await api("/kunden", { const payload = {
method: "POST",
json: {
name: document.getElementById("cust-name").value, name: document.getElementById("cust-name").value,
email: document.getElementById("cust-email").value, email: document.getElementById("cust-email").value,
}, };
}); try {
const row = id
? await api("/kunden/" + id, { method: "PATCH", json: payload })
: await api("/kunden", { method: "POST", json: payload });
if (id) {
customers = customers.map((c) => (c.id === row.id ? row : c));
} else {
customers.push(row); customers.push(row);
}
fillCustomers(); fillCustomers();
document.getElementById("order-customer").value = row.id; document.getElementById("order-customer").value = row.id;
document.getElementById("cust-id").value = "";
document.getElementById("cust-name").value = ""; document.getElementById("cust-name").value = "";
document.getElementById("cust-email").value = ""; document.getElementById("cust-email").value = "";
toast("Kunde angelegt."); document.getElementById("cust-delete").hidden = true;
toast(id ? "Kunde gespeichert." : "Kunde angelegt.");
} catch (error) { } catch (error) {
showNote("order-note", error); showNote("order-note", error);
} }
}); });
document.getElementById("cust-edit-current").addEventListener("click", () => {
const id = document.getElementById("order-customer").value;
const row = customers.find((c) => String(c.id) === String(id));
if (!row) {
toast("Zuerst einen Kunden wählen.", "bad");
return;
}
document.getElementById("cust-id").value = row.id;
document.getElementById("cust-name").value = row.name || "";
document.getElementById("cust-email").value = row.email || "";
document.getElementById("cust-delete").hidden = false;
});
document.getElementById("cust-delete").addEventListener("click", (event) => {
const id = document.getElementById("cust-id").value;
if (!id) return;
armButton(event.currentTarget, "Kunde löschen?", async () => {
await api("/kunden/" + id, { method: "DELETE" });
customers = customers.filter((c) => String(c.id) !== String(id));
fillCustomers();
document.getElementById("cust-id").value = "";
document.getElementById("cust-name").value = "";
document.getElementById("cust-email").value = "";
document.getElementById("cust-delete").hidden = true;
toast("Kunde gelöscht.");
});
});
loadBase().catch((error) => showNote("order-note", error)); loadBase().catch((error) => showNote("order-note", error));
</script> </script>
{% endblock %} {% endblock %}
+81 -1
View File
@@ -29,6 +29,84 @@ function sourceLabel(source, estimate) {
return source || ""; return source || "";
} }
const COST_PART_COLORS = {
filament: "#0d9488",
base: "#94a3b8",
nozzle: "#ea580c",
bed: "#2563eb",
dryer: "#16a34a",
};
function kwhLabel(value) {
if (value == null || value === "") return "—";
return (Number(value) || 0).toLocaleString("de-DE", { maximumFractionDigits: 3 }) + " kWh";
}
function costPieSvg(parts) {
const items = (parts || []).filter((p) => Number(p.cost) > 0);
const total = items.reduce((sum, p) => sum + Number(p.cost || 0), 0);
if (!items.length || total <= 0) return "";
const cx = 42;
const cy = 42;
const r = 36;
let angle = -Math.PI / 2;
const slices = items.map((p) => {
const share = Number(p.cost) / total;
const sweep = share * Math.PI * 2;
const x1 = cx + r * Math.cos(angle);
const y1 = cy + r * Math.sin(angle);
angle += sweep;
const x2 = cx + r * Math.cos(angle);
const y2 = cy + r * Math.sin(angle);
const large = sweep > Math.PI ? 1 : 0;
const color = COST_PART_COLORS[p.key] || "#64748b";
if (share >= 0.999) {
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${color}"/>`;
}
return `<path d="M ${cx} ${cy} L ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)} Z" fill="${color}"/>`;
}).join("");
return `<svg class="energy-pie" viewBox="0 0 84 84" role="img" aria-label="Kostenanteile">${slices}</svg>`;
}
function costBreakdownHtml(energy, filamentCost) {
const fil = Number(filamentCost) || 0;
const energyParts = (energy && energy.parts) || [];
const parts = [];
if (fil > 1e-6) {
parts.push({ key: "filament", label: "Filament", kwh: null, cost: fil });
}
energyParts.forEach((p) => parts.push(p));
if (!parts.length) return "";
const energyEuro = Number((energy && energy.cost) || 0);
const totalEuro = fil + energyEuro;
const src = energy && energy.source === "ed" ? "Strom aus ED" : (energyParts.length ? "Strom pauschal" : "");
const rate = energy && energy.euro_per_kwh != null
? Number(energy.euro_per_kwh).toLocaleString("de-DE", { style: "currency", currency: "EUR", maximumFractionDigits: 3 }) + "/kWh"
: "";
const rows = parts.map((p) => {
const color = COST_PART_COLORS[p.key] || "#64748b";
return `<tr>
<td><span class="energy-swatch" style="background:${color}"></span>${escapeHtml(p.label)}</td>
<td class="num">${kwhLabel(p.kwh)}</td>
<td class="num">${euro(p.cost)}</td>
</tr>`;
}).join("");
const sumKwh = energy && energy.kwh != null ? kwhLabel(energy.kwh) : "—";
const hint = [src, rate].filter(Boolean).join(" · ");
return `<div class="energy-break">
<div class="energy-break-chart">${costPieSvg(parts)}</div>
<div class="energy-break-table">
<div class="lib-head"><h3>Kosten</h3><span class="tiny">${escapeHtml(hint)}</span></div>
<div class="table-wrap"><table>
<thead><tr><th>Anteil</th><th class="num">kWh</th><th class="num">€</th></tr></thead>
<tbody>${rows}
<tr class="energy-total"><td>Summe</td><td class="num">${sumKwh}</td><td class="num">${euro(totalEuro)}</td></tr>
</tbody>
</table></div>
</div>
</div>`;
}
function render(data) { function render(data) {
const run = data.run; const run = data.run;
const name = (run.filename || "").split("/").pop() || "ohne Datei"; const name = (run.filename || "").split("/").pop() || "ohne Datei";
@@ -80,10 +158,12 @@ function render(data) {
<p>Dauer <b>${hours(run.duration_s)}</b>${run.estimated_s ? " · Soll " + hours(run.estimated_s) : ""}</p> <p>Dauer <b>${hours(run.duration_s)}</b>${run.estimated_s ? " · Soll " + hours(run.estimated_s) : ""}</p>
<p>Material <b>${grams(used)}</b> von ${grams(planned)}${planned ? " (" + (diff > 0 ? "+" : "") + diff + " g)" : ""} <p>Material <b>${grams(used)}</b> von ${grams(planned)}${planned ? " (" + (diff > 0 ? "+" : "") + diff + " g)" : ""}
${run.weight_source ? ' · <span class="tag">' + sourceLabel(run.weight_source) + "</span>" : ""}</p> ${run.weight_source ? ' · <span class="tag">' + sourceLabel(run.weight_source) + "</span>" : ""}</p>
<p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})</p> <p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})${run.cost_per_part != null ? " · <b>" + euro(run.cost_per_part) + "</b>/Teil" : ""}</p>
${run.pieces_ok || run.pieces_bad ? `<p>Stück <b>${run.pieces_ok || 0}</b> gut${run.pieces_bad ? " · " + run.pieces_bad + " schlecht" : ""}</p>` : ""}
${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""} ${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""}
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""} ${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
</div> </div>
${costBreakdownHtml(energy, run.filament_cost)}
</div> </div>
<div class="card"> <div class="card">
<h2>Spulen</h2> <h2>Spulen</h2>
+327 -69
View File
@@ -18,12 +18,16 @@
<div class="send-layout"> <div class="send-layout">
<section class="card lib-card" id="lib-card"> <section class="card lib-card" id="lib-card">
<div class="lib-head"> <div class="card-head lib-head">
<svg class="ico head-ico"><use href="#i-folder-open"/></svg>
<h2>Bibliothek</h2> <h2>Bibliothek</h2>
<span class="count" id="lib-count">0</span> <span class="count-pill" id="lib-count">0</span>
</div> </div>
<div class="lib-tools"> <div class="toolbar lib-tools">
<div class="search-wrap">
<svg class="ico"><use href="#i-search"/></svg>
<input id="file-search" type="search" placeholder="Name, Material, Projekt…" autocomplete="off"> <input id="file-search" type="search" placeholder="Name, Material, Projekt…" autocomplete="off">
</div>
<select id="file-filter-family"> <select id="file-filter-family">
<option value="">Alle Maschinen</option> <option value="">Alle Maschinen</option>
<option value="kobra_3">Kobra 3</option> <option value="kobra_3">Kobra 3</option>
@@ -53,6 +57,7 @@
<div class="skeleton" style="height:4rem"></div> <div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div> <div class="skeleton" style="height:4rem"></div>
</div> </div>
<div class="pager" id="lib-pager" hidden></div>
<p class="drop-hint">Dateien hierher ziehen zum Hochladen.</p> <p class="drop-hint">Dateien hierher ziehen zum Hochladen.</p>
</section> </section>
@@ -63,27 +68,26 @@
</div> </div>
<div class="flow-step" id="step-file"> <div class="flow-step" id="step-file">
<h3><span class="num-dot">1</span>Datei</h3> <h3><span class="step-num">1</span>Datei</h3>
<div id="picked"><div class="picked-empty"><svg class="ico"><use href="#i-file"/></svg><span>Datei aus der Bibliothek wählen.</span></div></div> <div id="picked"><div class="picked-empty"><svg class="ico"><use href="#i-file"/></svg><span>Datei aus der Bibliothek wählen.</span></div></div>
</div> </div>
<div class="flow-step" id="step-target"> <div class="flow-step" id="step-target">
<h3><span class="num-dot">2</span>Drucker</h3> <h3><span class="step-num">2</span>Drucker</h3>
<div class="targets" id="targets"></div> <div class="targets" id="targets"></div>
<p class="tiny" id="target-hint"></p> <p class="tiny" id="target-hint"></p>
</div> </div>
<div class="flow-step ok" data-pane="send" id="step-mode"> <div class="flow-step ok" data-pane="send" id="step-mode">
<h3><span class="num-dot">3</span>Wann</h3> <h3><span class="step-num">3</span>Start</h3>
<div class="segmented" id="mode" role="radiogroup"> <div class="segmented" id="mode" role="radiogroup">
<button type="button" data-mode="now" class="on">Jetzt<small>sofort starten</small></button> <button type="button" data-mode="this" class="on">Dieser Drucker<small>frei → start, sonst Warteschlange</small></button>
<button type="button" data-mode="free">Wenn frei<small>auf diesem Drucker</small></button> <button type="button" data-mode="next">Nächster passender<small>Grotti wählt nach Sorte</small></button>
<button type="button" data-mode="next">Nächster freier<small>Grotti wählt</small></button>
</div> </div>
</div> </div>
<div class="flow-step ok" data-pane="ablauf" id="step-ablauf" hidden> <div class="flow-step ok" data-pane="ablauf" id="step-ablauf" hidden>
<h3><span class="num-dot">3</span>Schritte</h3> <h3><span class="step-num">3</span>Schritte</h3>
<p class="tiny">Drucken → abkühlen → schieben → Mitte tasten. Kiste vor dem Bett.</p> <p class="tiny">Drucken → abkühlen → schieben → Mitte tasten. Kiste vor dem Bett.</p>
<div id="ablauf-preview" class="ablauf-preview"></div> <div id="ablauf-preview" class="ablauf-preview"></div>
<div class="sweep-preview-wrap" id="sweep-preview-wrap" hidden> <div class="sweep-preview-wrap" id="sweep-preview-wrap" hidden>
@@ -185,7 +189,7 @@ let ablaufDefaults = {
check: { max_delta_mm: 3.0, timeout_min: 2, x: 125, y: 125, ref_x: 25, ref_y: 125, z_clear: 20 }, check: { max_delta_mm: 3.0, timeout_min: 2, x: 125, y: 125, ref_x: 25, ref_y: 125, z_clear: 20 },
}; };
let ablaufSteps = []; let ablaufSteps = [];
const sel = { fileId: null, hosts: new Set(), mode: "now", tab: "send" }; const sel = { fileId: null, hosts: new Set(), mode: "this", tab: "send" };
let aceHostsKey = ""; let aceHostsKey = "";
/* ---------- 3D-Pfad ---------- */ /* ---------- 3D-Pfad ---------- */
@@ -198,6 +202,25 @@ function ensureViewer() {
return pathViewer; return pathViewer;
} }
let toolpathGen = 0;
function scheduleToolpath(file, progress, deferMs) {
const gen = ++toolpathGen;
const run = () => {
if (gen !== toolpathGen) return;
showToolpath(file, progress);
};
if (deferMs > 0) {
if (window.requestIdleCallback) {
requestIdleCallback(run, { timeout: Math.max(800, deferMs) });
} else {
setTimeout(run, deferMs);
}
return;
}
run();
}
async function showToolpath(file, progress) { async function showToolpath(file, progress) {
const card = document.getElementById("viewer-card"); const card = document.getElementById("viewer-card");
const caption = document.getElementById("viewer-caption"); const caption = document.getElementById("viewer-caption");
@@ -217,6 +240,7 @@ async function showToolpath(file, progress) {
meta.textContent = "Pfad wird gelesen …"; meta.textContent = "Pfad wird gelesen …";
try { try {
const data = await api("/dateien/" + file.id + "/pfad?tp=8"); const data = await api("/dateien/" + file.id + "/pfad?tp=8");
if (String(sel.fileId) !== String(file.id)) return;
viewer.setPath(data); viewer.setPath(data);
viewerFileId = file.id; viewerFileId = file.id;
meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte"; meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte";
@@ -258,7 +282,9 @@ function saveSendMemory() {
function loadSendMemory() { function loadSendMemory() {
try { try {
const data = JSON.parse(localStorage.getItem(SEND_KEY) || "{}"); const data = JSON.parse(localStorage.getItem(SEND_KEY) || "{}");
if (["now", "free", "next"].includes(data.mode)) sel.mode = data.mode; let mode = data.mode;
if (mode === "now" || mode === "free") mode = "this";
if (["this", "next"].includes(mode)) sel.mode = mode;
if (["send", "ablauf"].includes(data.tab)) sel.tab = data.tab; if (["send", "ablauf"].includes(data.tab)) sel.tab = data.tab;
if (Array.isArray(data.hosts)) sel.hosts = new Set(data.hosts); if (Array.isArray(data.hosts)) sel.hosts = new Set(data.hosts);
} catch (error) { /* ignore */ } } catch (error) { /* ignore */ }
@@ -305,10 +331,22 @@ function slotTitle(host, slot) {
return bits.join(" · "); return bits.join(" · ");
} }
function paintIndexOf(filament, fallback) {
const raw = filament && filament.paint_index;
if (raw === 0 || raw === "0") return 0;
const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
function filamentLabel(filament, index) { function filamentLabel(filament, index) {
const kind = filament.type || "Filament"; const paint = paintIndexOf(filament, index);
const kind = filament.profile || filament.type || "Filament";
const color = filament.color ? " · " + filament.color : ""; const color = filament.color ? " · " + filament.color : "";
return "Farbe " + (index + 1) + " · " + kind + color; return "T" + paint + " · " + kind + color;
}
function canStartNow(p) {
return isFree(p) && !p.needs_clear;
} }
function spoolsOnHost(host) { function spoolsOnHost(host) {
@@ -322,6 +360,39 @@ function spoolWarnText() {
const maps = collectAceMaps(); const maps = collectAceMaps();
const hosts = selectedHosts(); const hosts = selectedHosts();
const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean)); const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean));
const fileProfiles = ((file.filaments || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean));
const PROFILE_VARIANTS = ["meta", "matte", "matt", "silk", "plus", "hf", "cf"];
function profileHasVariant(want) {
return PROFILE_VARIANTS.some((v) => new RegExp("\\b" + v + "\\b", "i").test(want || ""));
}
function profileBaseType(want) {
const m = String(want || "").toLowerCase().match(/\b(pla|petg|abs|asa|tpu|pa|pc|hips|pva)\b/);
return m ? m[1] : "";
}
function profileOk(spoolRows) {
if (!fileProfiles.length) return true;
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
const types = spoolRows.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
if (!names.length && !types.length) return true;
return fileProfiles.every((want) => {
if (names.some((n) => {
if (n === want) return true;
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, "");
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
return !PROFILE_VARIANTS.includes(first);
})) return true;
// Basis-Profil (nur „… PLA“): Typ reicht, Marke egal — keine Meta/Matte-Spule
if (profileHasVariant(want)) return false;
const kind = profileBaseType(want);
if (!kind) return true;
return spoolRows.some((s) => {
const t = (s.material_type || "").toLowerCase();
const n = (s.name || "").toLowerCase();
return t === kind && !profileHasVariant(n);
});
});
}
for (const host of hosts) { for (const host of hosts) {
const name = (printers.find((p) => p.host === host) || {}).name || host; const name = (printers.find((p) => p.host === host) || {}).name || host;
const mapping = maps[host] || []; const mapping = maps[host] || [];
@@ -330,18 +401,23 @@ function spoolWarnText() {
let left = 0; let left = 0;
let any = false; let any = false;
const types = []; const types = [];
const mappedSpools = [];
mapping.forEach((item) => { mapping.forEach((item) => {
const slot = Number(item.ams_index) + 1; const slot = Number(item.ams_index) + 1;
const spool = onHost.find((s) => Number(s.ace_slot) === slot); const spool = onHost.find((s) => Number(s.ace_slot) === slot);
if (spool) { if (spool) {
any = true; any = true;
left += Number(spool.remaining_g) || 0; left += Number(spool.remaining_g) || 0;
mappedSpools.push(spool);
if (spool.material_type) types.push(spool.material_type.toLowerCase()); if (spool.material_type) types.push(spool.material_type.toLowerCase());
} }
}); });
if (!any) return name + ": keine Grotti-Spule auf den gewählten ACE-Plätzen — Abzug entfällt."; if (!any) return name + ": keine Grotti-Spule auf den gewählten ACE-Plätzen — Abzug entfällt.";
if (left + 0.5 < need) return "ACE auf " + name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + "."; if (left + 0.5 < need) return "ACE auf " + name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) { if (!profileOk(mappedSpools)) {
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
}
if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", ACE " + types.join("/") + ")."; return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", ACE " + types.join("/") + ").";
} }
continue; continue;
@@ -355,8 +431,11 @@ function spoolWarnText() {
if (left + 0.5 < need) { if (left + 0.5 < need) {
return name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + "."; return name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
} }
if (!profileOk(external)) {
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
}
const types = external.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean); const types = external.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) { if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", Drucker " + types.join("/") + ")."; return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", Drucker " + types.join("/") + ").";
} }
} }
@@ -428,16 +507,17 @@ function renderAceMaps(force) {
title.className = "ace-printer-name"; title.className = "ace-printer-name";
title.textContent = printer.name; title.textContent = printer.name;
block.appendChild(title); block.appendChild(title);
paints.forEach((filament, paintIndex) => { paints.forEach((filament, uiIndex) => {
const paint = paintIndexOf(filament, uiIndex);
const row = document.createElement("label"); const row = document.createElement("label");
row.className = "ace-row"; row.className = "ace-row";
const swatch = document.createElement("span"); const swatch = document.createElement("span");
swatch.className = "ace-swatch"; swatch.className = "ace-swatch";
if (filament.color) swatch.style.background = filament.color; if (filament.color) swatch.style.background = filament.color;
const text = document.createElement("span"); const text = document.createElement("span");
text.textContent = filamentLabel(filament, paintIndex); text.textContent = filamentLabel(filament, uiIndex);
const select = document.createElement("select"); const select = document.createElement("select");
select.dataset.paint = String(paintIndex); select.dataset.paint = String(paint);
select.innerHTML = '<option value="">Platz wählen</option>'; select.innerHTML = '<option value="">Platz wählen</option>';
printer.slots.forEach((slot) => { printer.slots.forEach((slot) => {
const option = document.createElement("option"); const option = document.createElement("option");
@@ -448,16 +528,30 @@ function renderAceMaps(force) {
const hex = (spool && spool.colors && spool.colors[0] && spool.colors[0].hex) || slot.color || ""; const hex = (spool && spool.colors && spool.colors[0] && spool.colors[0].hex) || slot.color || "";
if (hex) option.style.borderLeft = "4px solid " + hex; if (hex) option.style.borderLeft = "4px solid " + hex;
const spoolType = (spool && spool.material_type) || slot.type || ""; const spoolType = (spool && spool.material_type) || slot.type || "";
// Vorauswahl: gleiche Sorte (Grotti-Spule bevorzugt), sonst Platz in Reihenfolge const spoolName = ((spool && spool.name) || "").toLowerCase();
if (!select.dataset.picked && filament.type && spoolType const wantProfile = (filament.profile || "").toLowerCase();
// Vorauswahl: Katalog-Sorte exakt (Meta ≠ Matte), sonst grober Typ
let profileHit = false;
if (wantProfile && spoolName) {
if (spoolName === wantProfile) profileHit = true;
else if (spoolName.startsWith(wantProfile) && !/[a-z0-9]/i.test(spoolName.charAt(wantProfile.length) || "")) {
const rest = spoolName.slice(wantProfile.length).replace(/^[\s·\-_|/]+/, "");
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
if (!["meta", "matte", "matt", "silk", "plus", "hf", "cf"].includes(first)) profileHit = true;
}
}
if (!select.dataset.picked && profileHit) {
option.selected = true;
select.dataset.picked = "1";
} else if (!select.dataset.picked && !wantProfile && filament.type && spoolType
&& filament.type.toLowerCase() === String(spoolType).toLowerCase()) { && filament.type.toLowerCase() === String(spoolType).toLowerCase()) {
option.selected = true; option.selected = true;
select.dataset.picked = "1"; select.dataset.picked = "1";
} }
select.appendChild(option); select.appendChild(option);
}); });
if (!select.dataset.picked && printer.slots[paintIndex]) { if (!select.dataset.picked && printer.slots[uiIndex]) {
select.value = String(printer.slots[paintIndex].index); select.value = String(printer.slots[uiIndex].index);
} }
select.addEventListener("change", updateSpoolWarn); select.addEventListener("change", updateSpoolWarn);
row.appendChild(swatch); row.appendChild(swatch);
@@ -634,8 +728,9 @@ function fillFolderFilter() {
} }
function moveSelectHtml(file) { function moveSelectHtml(file) {
if (!hasWatchDir || file.source !== "watch") return ""; if (!hasWatchDir) return "";
const current = folderKey(file); if (file.source !== "watch" && file.source !== "upload") return "";
const current = file.source === "upload" ? "" : folderKey(file);
const opts = [ const opts = [
`<option value="" selected disabled>Verschieben…</option>`, `<option value="" selected disabled>Verschieben…</option>`,
`<option value="__root__">Wurzel</option>`, `<option value="__root__">Wurzel</option>`,
@@ -697,8 +792,8 @@ function sliceOf(file) {
} }
function materialText(file) { function materialText(file) {
const types = [...new Set(((file && file.filaments) || []).map((f) => f.type).filter(Boolean))]; const labels = [...new Set(((file && file.filaments) || []).map((f) => f.profile || f.type).filter(Boolean))];
return types.join("+"); return labels.join("+");
} }
function layerHeightText(file) { function layerHeightText(file) {
@@ -745,6 +840,39 @@ function infoPopupHtml(file) {
</details>`; </details>`;
} }
function placeLibInfoPop(details) {
const pop = details.querySelector(".lib-info-pop");
const anchor = details.querySelector("summary");
if (!pop || !anchor) return;
const margin = 6;
const r = anchor.getBoundingClientRect();
pop.style.left = "0px";
pop.style.top = "0px";
const pw = pop.offsetWidth;
const ph = pop.offsetHeight;
let left = r.right - pw;
left = Math.max(margin, Math.min(left, window.innerWidth - pw - margin));
let top = r.bottom + margin;
if (top + ph > window.innerHeight - margin && r.top - margin - ph >= margin) {
top = r.top - margin - ph;
} else {
top = Math.min(top, Math.max(margin, window.innerHeight - ph - margin));
}
pop.style.left = left + "px";
pop.style.top = top + "px";
}
function closeOpenLibInfo(except) {
document.querySelectorAll("details.lib-info[open]").forEach((node) => {
if (node !== except) node.removeAttribute("open");
});
}
function repositionOpenLibInfo() {
const open = document.querySelector("details.lib-info[open]");
if (open) placeLibInfoPop(open);
}
function thumbHtml(file) { function thumbHtml(file) {
return file.preview return file.preview
? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">` ? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">`
@@ -753,15 +881,17 @@ function thumbHtml(file) {
function libItemHtml(file) { function libItemHtml(file) {
const project = projects.find((p) => String(p.id) === String(file.project_id)); const project = projects.find((p) => String(p.id) === String(file.project_id));
const del = file.source === "upload" const rename = `<button type="button" class="icon-btn" data-rename="${file.id}" title="Umbenennen">${icon("edit")}</button>`;
? `<button type="button" class="icon-btn" data-delete="${file.id}" title="Hochgeladene Datei löschen">${icon("x")}</button>` : ""; const del = `<button type="button" class="icon-btn" data-delete="${file.id}" title="Datei löschen">${icon("trash")}</button>`;
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}"> return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
${thumbHtml(file)} ${thumbHtml(file)}
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span> <span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
<span class="side">${moveSelectHtml(file)}${infoPopupHtml(file)}${del}</span> <span class="side">${moveSelectHtml(file)}${infoPopupHtml(file)}${rename}${del}</span>
</div>`; </div>`;
} }
let libPage = 1;
function renderLibrary() { function renderLibrary() {
const list = document.getElementById("lib-list"); const list = document.getElementById("lib-list");
const shown = filteredFiles(); const shown = filteredFiles();
@@ -769,15 +899,19 @@ function renderLibrary() {
document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length; document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length;
if (!files.length) { if (!files.length) {
list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>'; list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>';
renderPager(document.getElementById("lib-pager"), null, () => {});
return; return;
} }
if (!shown.length) { if (!shown.length) {
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>'; list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
renderPager(document.getElementById("lib-pager"), null, () => {});
return; return;
} }
const paged = pageSlice(shown, libPage, DEFAULT_PAGE_SIZE);
libPage = paged.page;
const parts = []; const parts = [];
let lastGroup = null; let lastGroup = null;
shown.forEach((file) => { paged.items.forEach((file) => {
let group = null; let group = null;
if (sort === "material") group = materialKey(file) || "Ohne Material"; if (sort === "material") group = materialKey(file) || "Ohne Material";
else if (sort === "folder") group = folderLabel(folderKey(file)); else if (sort === "folder") group = folderLabel(folderKey(file));
@@ -788,6 +922,10 @@ function renderLibrary() {
parts.push(libItemHtml(file)); parts.push(libItemHtml(file));
}); });
list.innerHTML = parts.join(""); list.innerHTML = parts.join("");
renderPager(document.getElementById("lib-pager"), paged, (p) => {
libPage = p;
renderLibrary();
});
} }
/* ---------- Flow rechts ---------- */ /* ---------- Flow rechts ---------- */
@@ -878,17 +1016,25 @@ function updateSendButton() {
let note = ""; let note = "";
if (!file) problem = "Erst eine Datei wählen."; if (!file) problem = "Erst eine Datei wählen.";
else if (file.kind === "3mf") problem = "Die 3MF hat keinen G-code. Mit G-code slicen."; else if (file.kind === "3mf") problem = "Die 3MF hat keinen G-code. Mit G-code slicen.";
if (sel.mode === "now") { if (sel.mode === "this") {
if (!problem && !chosen.length) problem = "Einen oder mehrere Drucker wählen."; if (!problem && !chosen.length) problem = "Einen oder mehrere Drucker wählen.";
const busy = chosen.filter((p) => !isFree(p)); const ready = chosen.filter(canStartNow);
if (!problem && busy.length) note = namesText(busy) + " " + (busy.length === 1 ? "ist" : "sind") + " nicht frei. „Wenn frei“ reiht stattdessen ein."; const later = chosen.filter((p) => !canStartNow(p));
text = chosen.length ? "Jetzt starten auf " + namesText(chosen) : "Jetzt starten"; if (ready.length && !later.length) {
} else if (sel.mode === "free") { text = "Jetzt starten auf " + namesText(ready);
text = chosen.length ? "Einreihen für " + namesText(chosen) : "Einreihen: nächster freier " + familyOfSelection(); note = "";
note = chosen.length ? "Startet von selbst, sobald der Drucker bereit und die Platte frei ist." : "Ohne Drucker: der nächste freie passende übernimmt."; } else if (!ready.length && later.length) {
text = "Einreihen für " + namesText(later);
note = "Startet, sobald der Drucker bereit und die Platte frei ist.";
} else if (ready.length && later.length) {
text = "Starten / einreihen auf " + namesText(chosen);
note = namesText(ready) + " sofort, " + namesText(later) + " in die Warteschlange.";
} else { } else {
text = "Einreihen: nächster freier " + familyOfSelection(); text = "Starten";
note = "Grotti nimmt den ersten freien Drucker mit passender Maschine."; }
} else {
text = "Einreihen: nächster passender " + familyOfSelection();
note = "Grotti wählt den nächsten freien Drucker mit passender Sorte (z. B. PLA Meta ≠ Matte).";
} }
const mismatch = chosen.filter((p) => familyMismatch(p, file)); const mismatch = chosen.filter((p) => familyMismatch(p, file));
if (!problem && mismatch.length && !document.getElementById("send-override").checked) { if (!problem && mismatch.length && !document.getElementById("send-override").checked) {
@@ -898,7 +1044,7 @@ function updateSendButton() {
button.disabled = !!problem || !!sendPoll; button.disabled = !!problem || !!sendPoll;
hint.textContent = problem || note; hint.textContent = problem || note;
hint.classList.toggle("bad", !!problem); hint.classList.toggle("bad", !!problem);
targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : ""; targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti nach Sorte." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
} }
function updateAblaufButton() { function updateAblaufButton() {
@@ -967,7 +1113,7 @@ function selectFile(id) {
} }
document.querySelectorAll("#lib-list .lib-item").forEach((n) => n.classList.toggle("on", n.dataset.file === String(id))); document.querySelectorAll("#lib-list .lib-item").forEach((n) => n.classList.toggle("on", n.dataset.file === String(id)));
refreshFlow(); refreshFlow();
showToolpath(file, 0); scheduleToolpath(file, 0, 0);
if (window.matchMedia("(max-width: 1080px)").matches) { if (window.matchMedia("(max-width: 1080px)").matches) {
document.querySelector(".send-panel").scrollIntoView({ behavior: "smooth", block: "start" }); document.querySelector(".send-panel").scrollIntoView({ behavior: "smooth", block: "start" });
} }
@@ -1011,19 +1157,72 @@ async function load() {
renderLibrary(); renderLibrary();
renderTabs(); renderTabs();
refreshFlow(); refreshFlow();
const file = selectedFile(); let file = selectedFile();
if (file) showToolpath(file, 0); // Orca/Anycubic: Metadaten oft ohne paint_index → ACE zeigt T0/T1 statt T1/T10
// Deep-Link von Orca /orca_senden: Senden in den Blick, Optionen offen für ACE if (file && file.id && filamentsNeedPaintIndex(file.filaments)) {
if (new URLSearchParams(location.search).get("file") && file) { try {
const fresh = await api("/dateien/" + file.id + "/meta", { method: "POST" });
if (fresh && fresh.id) {
const idx = files.findIndex((row) => String(row.id) === String(fresh.id));
if (idx >= 0) files[idx] = fresh;
else files.unshift(fresh);
file = selectedFile();
renderLibrary();
refreshFlow();
}
} catch (error) { /* manuell: Ordner neu lesen */ }
}
const q = new URLSearchParams(location.search);
const deepLink = !!(q.get("file") || q.get("orca"));
// Deep-Link / Orca-App: ACE/Senden zuerst — 3D-Pfad friert sonst den Tab ein
if (file) scheduleToolpath(file, 0, deepLink ? 900 : 0);
if (deepLink) {
sel.tab = "send"; sel.tab = "send";
renderTabs(); renderTabs();
const opts = document.getElementById("options"); const opts = document.getElementById("options");
if (opts) opts.open = true; if (opts) opts.open = true;
const panel = document.querySelector(".send-panel"); const panel = document.querySelector(".send-panel");
if (panel) panel.scrollIntoView({ behavior: "smooth", block: "start" }); if (panel) panel.scrollIntoView({ behavior: "smooth", block: "start" });
if (file) lastOrcaFocusId = String(file.id);
if (q.get("orca")) pullOrcaFocus();
} }
} }
let lastOrcaFocusId = null;
async function pullOrcaFocus() {
try {
const data = await api("/orca-fokus");
if (!data || data.file_id == null) return;
const id = String(data.file_id);
const age = Date.now() / 1000 - Number(data.ts || 0);
if (age > 180) return;
const already = id === String(sel.fileId) && selectedFile();
if (id === lastOrcaFocusId && already) return;
const switched = lastOrcaFocusId != null && id !== lastOrcaFocusId;
lastOrcaFocusId = id;
if (already) return;
let file = files.find((row) => String(row.id) === id);
if (!file) {
files = await api("/dateien");
renderLibrary();
file = files.find((row) => String(row.id) === id);
}
if (file) {
selectFile(file.id);
sel.tab = "send";
renderTabs();
if (switched) toast("Neue Slice-Datei ausgewählt.", "good");
}
} catch (error) { /* nächster Tick */ }
}
function filamentsNeedPaintIndex(filaments) {
const rows = filaments || [];
if (rows.length < 2) return false;
return rows.some((item) => !item || item.paint_index == null);
}
async function refreshPrinters() { async function refreshPrinters() {
if (document.hidden) return; if (document.hidden) return;
try { try {
@@ -1101,17 +1300,12 @@ async function watchJobs(ids) {
await tick(); await tick();
} }
async function doSend() { async function postSenden(hosts, whenFree) {
const button = document.getElementById("send-go"); return api("/senden", {
const whenFree = sel.mode !== "now";
button.disabled = true;
document.getElementById("send-note").textContent = "";
try {
const payload = await api("/senden", {
method: "POST", method: "POST",
json: { json: {
file_id: Number(sel.fileId), file_id: Number(sel.fileId),
hosts: selectedHosts(), hosts,
confirm: true, confirm: true,
override_machine: document.getElementById("send-override").checked, override_machine: document.getElementById("send-override").checked,
bed_leveling: document.getElementById("send-bed-level").checked, bed_leveling: document.getElementById("send-bed-level").checked,
@@ -1119,14 +1313,23 @@ async function doSend() {
when_free: whenFree, when_free: whenFree,
}, },
}); });
}
async function doSend() {
const button = document.getElementById("send-go");
button.disabled = true;
document.getElementById("send-note").textContent = "";
try {
const projectId = document.getElementById("send-project")?.value; const projectId = document.getElementById("send-project")?.value;
if (projectId && sel.fileId) { if (projectId && sel.fileId) {
try { try {
await api("/dateien/" + sel.fileId, { method: "PATCH", json: { project_id: Number(projectId) } }); await api("/dateien/" + sel.fileId, { method: "PATCH", json: { project_id: Number(projectId) } });
} catch (error) { /* Projektbindung optional */ } } catch (error) { /* Projektbindung optional */ }
} }
if (sel.mode === "next") {
const payload = await postSenden([], true);
const jobs = payload.jobs || []; const jobs = payload.jobs || [];
if (whenFree || payload.mode === "when_free") {
document.getElementById("send-progress").hidden = true; document.getElementById("send-progress").hidden = true;
toast((jobs.length > 1 ? jobs.length + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good"); toast((jobs.length > 1 ? jobs.length + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good");
updateSendButton(); updateSendButton();
@@ -1134,9 +1337,35 @@ async function doSend() {
refreshPrinters(); refreshPrinters();
return; return;
} }
showJobsProgress(jobs);
const chosen = selectedPrinters();
const ready = chosen.filter(canStartNow).map((p) => p.host);
const later = chosen.filter((p) => !canStartNow(p)).map((p) => p.host);
let queued = 0;
let nowJobs = [];
if (later.length) {
const payload = await postSenden(later, true);
queued += (payload.jobs || []).length;
}
if (ready.length) {
const payload = await postSenden(ready, false);
nowJobs = payload.jobs || [];
}
if (queued && !nowJobs.length) {
document.getElementById("send-progress").hidden = true;
toast((queued > 1 ? queued + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good");
updateSendButton();
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
refreshPrinters();
return;
}
if (queued && nowJobs.length) {
toast(queued + " eingereiht, " + nowJobs.length + " startet jetzt.", "good");
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
}
showJobsProgress(nowJobs);
if (typeof refreshSendQueue === "function") refreshSendQueue(); if (typeof refreshSendQueue === "function") refreshSendQueue();
await watchJobs(jobs.map((job) => job.id)); await watchJobs(nowJobs.map((job) => job.id));
} catch (error) { } catch (error) {
updateSendButton(); updateSendButton();
toast(error.message || String(error), "bad"); toast(error.message || String(error), "bad");
@@ -1361,11 +1590,46 @@ async function doAblauf() {
/* ---------- Ereignisse ---------- */ /* ---------- Ereignisse ---------- */
document.getElementById("lib-list").addEventListener("toggle", (event) => {
const details = event.target;
if (!(details instanceof HTMLDetailsElement) || !details.classList.contains("lib-info")) return;
if (details.open) {
closeOpenLibInfo(details);
placeLibInfoPop(details);
}
}, true);
document.getElementById("lib-list").addEventListener("scroll", repositionOpenLibInfo, { passive: true });
window.addEventListener("resize", repositionOpenLibInfo);
document.addEventListener("pointerdown", (event) => {
if (event.target.closest("details.lib-info")) return;
closeOpenLibInfo();
});
document.getElementById("lib-list").addEventListener("click", (event) => { document.getElementById("lib-list").addEventListener("click", (event) => {
if (event.target.closest(".lib-move")) { if (event.target.closest(".lib-move")) {
event.stopPropagation(); event.stopPropagation();
return; return;
} }
const ren = event.target.closest("[data-rename]");
if (ren) {
event.stopPropagation();
const file = files.find((f) => String(f.id) === String(ren.dataset.rename));
if (!file) return;
const next = window.prompt("Neuer Dateiname:", file.name);
if (next == null) return;
const trimmed = String(next).trim();
if (!trimmed || trimmed === file.name) return;
api("/dateien/" + file.id + "/umbenennen", { method: "POST", json: { name: trimmed } })
.then(async (saved) => {
toast("Umbenannt in „" + (saved.name || trimmed) + "“.", "good");
await load();
if (saved && saved.id) selectFile(saved.id);
})
.catch((error) => toast(error.message || String(error), "bad"));
return;
}
const del = event.target.closest("[data-delete]"); const del = event.target.closest("[data-delete]");
if (del) { if (del) {
event.stopPropagation(); event.stopPropagation();
@@ -1425,6 +1689,7 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => {
const node = document.getElementById(id); const node = document.getElementById(id);
if (!node) return; if (!node) return;
const onChange = () => { const onChange = () => {
libPage = 1;
saveLibMemory(); saveLibMemory();
renderLibrary(); renderLibrary();
}; };
@@ -1441,7 +1706,7 @@ document.getElementById("targets").addEventListener("click", (event) => {
toast("Abläufe gehen nur auf Kobra 3.", "bad"); toast("Abläufe gehen nur auf Kobra 3.", "bad");
return; return;
} }
if (sel.tab === "send" && sel.mode === "next") sel.mode = "free"; if (sel.tab === "send" && sel.mode === "next") sel.mode = "this";
if (sel.hosts.has(host)) sel.hosts.delete(host); if (sel.hosts.has(host)) sel.hosts.delete(host);
else sel.hosts.add(host); else sel.hosts.add(host);
saveSendMemory(); saveSendMemory();
@@ -1475,19 +1740,11 @@ document.getElementById("send-bed-level").addEventListener("change", updateOptio
document.getElementById("send-override").addEventListener("change", () => { refreshFlow(); }); document.getElementById("send-override").addEventListener("change", () => { refreshFlow(); });
document.getElementById("send-project")?.addEventListener("change", updateOptionsSummary); document.getElementById("send-project")?.addEventListener("change", updateOptionsSummary);
document.getElementById("send-go").addEventListener("click", (event) => { document.getElementById("send-go").addEventListener("click", () => {
const button = event.currentTarget;
const warn = spoolWarnText();
if (sel.mode === "now") {
armButton(button, warn ? "Warnung — trotzdem starten?" : "Wirklich starten? Nochmal klicken", doSend);
} else if (warn) {
armButton(button, "Warnung — trotzdem einreihen?", doSend);
} else {
doSend(); doSend();
}
}); });
document.getElementById("ablauf-go").addEventListener("click", (event) => { document.getElementById("ablauf-go").addEventListener("click", () => {
armButton(event.currentTarget, "Ablauf wirklich einreihen? Nochmal klicken", doAblauf); doAblauf();
}); });
document.getElementById("ablauf-add-print").addEventListener("click", () => addStep(defaultPrintStep())); document.getElementById("ablauf-add-print").addEventListener("click", () => addStep(defaultPrintStep()));
@@ -1587,5 +1844,6 @@ loadSendMemory();
loadAblaufMemory(); loadAblaufMemory();
load().catch((error) => toast(error.message || String(error), "bad")); load().catch((error) => toast(error.message || String(error), "bad"));
setInterval(refreshPrinters, 5000); setInterval(refreshPrinters, 5000);
setInterval(pullOrcaFocus, 2000);
</script> </script>
{% endblock %} {% endblock %}
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more