Grotti 2.0.0: eigenes Repo, Umbenennung von Farm.

This commit is contained in:
Dominic Gundel committed 2026-10-15 08:45:56 +02:00
commit b2e6443c68
69 files changed
+18719

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
# Zugangsdaten und Dateiablage bleiben lokal
config.json
data/
.venv/
__pycache__/
*.pyc
_tmp_*
+10
View File
@@ -0,0 +1,10 @@
# Grotti — Hinweise für Agenten
Grotti ist ein eigenständiges Repo. Alles, was das Tool betrifft, bleibt hier.
- Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits.
- Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
- Version in `grotti/version.py` nach SemVer: Patch für Fehlerbehebung, Minor für Funktion, Major für Bruch an API oder Datenbankschema. Beim Release `[Unreleased]` in `[x.y.z] – Datum` umbenennen. Git-Tag `vX.Y.Z`.
- Technische Doku liegt in `docs/` und wird unter `/doku/<name>` gerendert. Neue Erkenntnisse zum Tool dort ergänzen.
- 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`.
+145
View File
@@ -0,0 +1,145 @@
# Changelog
Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionen nach [SemVer](https://semver.org/lang/de/).
## [2.0.0] – 2026-10-15
### Neu
- Einstellungen: **FQDN** (öffentliche Basis-URL) für QR-Codes auf Spulen-Etiketten; leer = Request-Host.
### Geändert
- Umbenennung Farm → **Grotti**, eigenes Repo.
- Python-Paket `farm/` → `grotti/`, Ausnahme `GrottiError`, Standard-Datenbank `grotti`.
- Oberfläche, CSS (`grotti.css`), G-code-Viewer und `localStorage`-Schlüssel (mit Fallback von `farm-display`).
- Doku-URLs ohne `Farm-`-Präfix (`/doku/Abraeumen`, …).
- Git-Tags `vX.Y.Z` statt `farm-vX.Y.Z`.
### Behoben
- Schieben: nach hinten nicht mehr mit `G0 Z2 F3000` (Vollgas) senken — langsam `F400`; vor dem Senken Z ≥ ~15 mm prüfen.
- Abkühlen: Slice-Ende hebt Z nicht — Park relativ **Z+40**, gekappt gegen `toolhead.axis_maximum` Z. Hub wird geprüft; sonst Abbruch vor Schieben.
## [1.4.0] – 2026-10-15
### Neu
- Anzeige **Desktop / Regal (Touch)** in Einstellungen (nur dieser Browser, `localStorage`); Shortcut `?shelf=1`.
- Seitenmenü einklappbar (Icon-Rail).
- Bibliothek: G-code-Footer-Metadaten (`slice_meta`: Schicht, Düse, Temps, …); Chips + Info-Popup.
- Warteschlange zeigt Direktstarts unter „Läuft“ (wie Sidebar „Läuft jetzt“).
- Cockpit: Bauraumtemperatur und zusätzliche Lüfter wenn Moonraker sie liefert; Icons für Düse/Bett/Lüfter.
- Ablauf: Three.js-Draufsicht der Schiebe-Bahn.
- Seitenfuß: © by dominic@gundel.dev.
- Interne Notizen zu Markt, Recht und Homepage unter `intern/` (nicht unter `/doku`).
- Doku: Voraussetzungen (Moonraker, Kobra 3 und Kobra S1) und Haftung (Entwurf).
- Statische Homepage-Vorschau `site/index.html`. Die Wand unter `/` bleibt.
- Doku Abräumen: Slice-Ende Kobra 3 Present `X250 Y220` + `M84`; Farm entfernt `M84` bei Ablauf mit Cool/Sweep.
### Geändert
- Cockpit ohne leere Kamera-Fläche wenn Kamera zu oder fehlt (`cam-collapsed`, eine Spalte).
- Schieben: Default/Limit **Y hinten 250**; Tasten-Grenze vorübergehend **3,0 mm** (Düsen-Klumpen).
### Behoben
- Druckwarteschlange startete nur **einen** Drucker pro Tick (~8 s). Mehrere freie Drucker starten jetzt im selben Durchlauf parallel (Upload je Host im Worker).
- Nach Druckstart kurz „Platte belegt“: `needs_clear` wurde schon beim Start gesetzt. Jetzt erst bei Druckende (Watcher); Streifen bleibt während Heizen/Queue ausgeblendet.
- Abräumen robust: `M84` aus Upload streichen; Idle während Druck/Cool; nach M84 `SET_KINEMATIC` nur mit echter XYZ; Schieben erst Y hinten dann Z; kein Extra-Mitten-Pass; Tasten mit Mesh/`Result is z=` (keine Fake-Z).
- Abkühlen-Park: Retry + Meldung; Anzeige zeigt wieder Warte-Temps (nicht nur „Mitte“).
- Druckstart: Worker weg → erneut starten; kein Jump auf Cool bei stale `complete`; kurzes Moonraker-`cancelled` beim LeviQ zählt nicht; `keep_motors`/`row`-Bug behoben.
## [1.3.0] – 2026-10-14
### Neu
- Cockpit: **Objekte ausschließen** (Klipper Exclude-Object) — Polygone im 3D-Pfad, Liste, Zwei-Klick → `EXCLUDE_OBJECT` (Whitelist, nur printing/paused). Ohne `[exclude_object]` oder DEFINEs bleibt das Panel aus.
- Seite **Warteschlange**: wartend, laufend, Fehler und Historie; Priorität und Abbrechen.
- Automatik-Schalter und Drucker „gesperrt“ (Einstellungen): kein neuer „wenn frei“-Start.
- Spulen an den Ort: Unique-Platz je Drucker, Auflösung beim Start, Material-Prüfung Datei vs. Rolle, „Einlegen“/„Ins Regal“, Material-Liste nach Ort.
- Senden zeigt eingelegte Rollen je Ziel-Drucker statt freiem Spulen-Dropdown; Projektwahl im Flow.
- `ace_map` und Bettnetz in `sends` für „Nochmal“; Protokoll-Select sortiert nach Drucker.
- three.js lokal; Toolpath-Cache unter `data/toolpath/`; Tests unter `tests/`.
- Watcher: Protokoll-Abgleich und Spulenabzug nach „fertig“; ACE-Abzug anteilig je Platz.
- Seitenleiste „Aufgaben“: Drucker mit belegter Platte, die kein Ablauf räumt, mit Knopf „geräumt“ (Zwei-Klick). `GET /api/v1/aufgaben`.
- Gestoppter Ablauf lässt sich in der Seitenleiste ab dem Schritt fortsetzen, an dem er stand (▶).
- Druckerkarte zeigt einen gestoppten Ablauf als roten Streifen mit „Weiter ab …“ und „Verwerfen“.
### Geändert
- 3D-Pfad: freieres Orbit, Zoom delta-basiert (Chrome), Schwenken/Pinch, Doppelklick-Reset; gedruckt leicht verdickt, Rest transparent; Fortschritt nach Datei-`progress`; Toolpath `version` 5 (segmenttreu, `E.39`/`Z.6`); Bettgitter 10 mm mit mm-Sprites und Legende.
- UI-Polish: konsistentes Icon-Set (Lucide-Stil), kürzere Microcopy, einheitliche Seitenköpfe/Buttons, kompakte Tabellen, Formular-Feldraster, Empty-States, ruhigere Sidebar und Mobile-Nav.
- **Material**: Reiter Spulen / Sorten. Spulen-Liste ohne dauerhaft offenes Formular. Sorten CRUD per API. Ort zeigt Drucker + ACE/extern. Scan im Seitenkopf.
- Bestätigungen überall Zwei-Klick (`armButton`), Rückmeldungen über Toast. Wortwahl „offline“ / „Auslastung“ vereinheitlicht.
- Ablauf-Standard: Drucken → Abkühlen → Schieben → **Tasten** (Mesh aus, relativ +5 mm, `PROBE` Mitte, kein `G28 Z`, danach Lift `z_clear`).
- Drucker-Details als Cockpit: Kamera, Status, Steuerung, 3D-Pfad, Bettnetz; zuklappbar (`farm-desk-closed`).
### Behoben
- Cockpit: Kamera-Polling ließ `no-cam` und Layout ruckeln; 3D-Orbit sprang zurück. Kamera lädt vor und behält das letzte Bild; Pfad setzt die Ansicht nur beim ersten Laden zurück.
- Toolpath-Parser ignorierte `E.39`/`Z.6` ohne führende Null — Extrusion/Infill fehlten fast komplett.
- Ablauf hing nach Farm-Neustart bei „tastet Mitte…“ / „räumt ab…“: Worker fehlte, Phase blieb stehen — Schieben/Tasten starten erneut.
- Kameras-Seite holte `live=0` und zeigte alle Drucker als „weg“.
- Steuerung setzte Flow immer auf 100 %; jetzt `extrude_factor` vom Drucker.
- E-Stop nur noch bei aktivem Druck, Zwei-Klick, Hinweis auf Strom aus/an.
- Ablauf: Phase `starting` vor Upload, Timeout wenn der Druck nicht anläuft; lange Schritte in Worker-Threads.
- „Nächster freier“ übernahm die ACE-Map eines anderen Druckers.
- Drucker Host ändern/löschen zieht Queue, Ereignisse und `bed_status` mit.
- Live-Zustand kommt vom Watcher (eine Quelle).
- Summary „frei“ ignorierte belegte Platte; Queue-Status von außen nicht mehr frei auf `done` setzbar.
- „Läuft jetzt“ zeigte nur Queue-Aufträge; Direktstarts stehen mit Fortschritt dabei.
- Abkühlen: Idle-Timeout / `SET_KINEMATIC_POSITION`, Anzeige mit Zielwerten, Düse zur Mitte, `M107` nach Schieben.
- MQTT-Druckstart-Timeout vs. `info/query` taskid; Prüfen/Timeouts/Bettnetz; Karten- und Sidebar-Randfälle.
## [1.1.0] – 2026-10-13
### Neu
- Live-Leiste in der Sidebar: „Läuft jetzt“, „Wartet“, „Alerts“ und „Senden“ mit Zählern, scrollbar, immer sichtbar.
- Druckerkarte zeigt laufenden Ablauf mit Schrittleiste (Drucken, Abkühlen, Schieben, Prüfen), Wiederholung und Abbrechen; wartende Jobs je Drucker.
- Seite **Aktivität**: alle Ereignisse als Zeitstrahl, Filter nach Drucker, Art und Zeitraum.
- Ereignisse für jeden Ablauf-Schritt, jede Wiederholung, geräumte Fehler und Abbrüche (Art `ablauf`). Telegram dafür abschaltbar (Standard aus).
- Senden als Flow: Datei, Ziel (Drucker-Chips mit Live-Zustand), Start (Jetzt, Wenn frei, Nächster freier) mit Zwei-Klick-Bestätigung. Ablauf als eigener Reiter.
- Version im Sidebar-Fuß, `GET /api/v1/version`, dieser Changelog und die Technik-Doku unter `/doku`.
- Designsystem: Zustandsfarben, Chips, Schrittleiste, Kacheln, Temperaturbalken, Hinweise, Toasts.
- Ereignisse auch für Druckstart, Pause, Fortsetzen (Art `start`), Abbruch (Art `abbruch`), „wieder erreichbar“, Einreihen und Abbrechen von Hand.
- `GET /api/v1/ereignisse` filtert nach `host`, `kind` (kommagetrennt) und `since`.
- Dateien hochladen per Ziehen auf die Bibliothek.
- Handbuch unter `/doku` mit Inhaltsverzeichnis; `README.md` für Einrichtung und API.
### Geändert
- Druckerkarte neu geordnet: Fertig-Uhrzeit groß, Fortschritt mit Prozent, drei Kacheln statt Zahlenzeile, IP und Mainsail im Detailteil.
- Technik-Doku liegt jetzt im Tool unter `docs/`; das Tool ist vom übrigen Repo unabhängig.
- Alerts in der Sidebar verlinken auf die Aktivität. Ablauf-Schritte und Starts zählen nicht als Alert (gleich als gelesen gespeichert).
- Telegram-Schalter „Ablauf-Schritte“ und „Druckstart und Pause“ in den Einstellungen; Abbrüche laufen unter „Fehler“.
- Die Queue räumt einen hängenden Fehler nur noch, wenn für den Drucker wirklich ein Auftrag wartet.
- Auf dem Handy wird „Wartet“ ausgeblendet, solange nichts wartet.
### Behoben
- Queue und Ablauf waren in der Sidebar unter dem Bildschirmrand abgeschnitten.
- Ereignisse gingen durch die 30-s-Entprellung verloren; entprellt wird nur noch Telegram.
- Leeres `homed_axes` im Leerlauf galt als Fehler und blockierte Abläufe; hängender `print_stats=error` wird vor dem Start geräumt.
## [1.0.0] – 2026-10-13
Erster festgehaltener Stand.
### Neu
- Druckerwand mit Live-Zustand über Moonraker, Kamera, Bettnetz, 3D-Pfad und leichter Steuerung (Temperatur, Speed, Flow, Lüfter, E-Stop).
- Dateien: Bibliothek mit Suche und Filter, Upload, Ordnerüberwachung, Metadaten aus G-code und 3MF.
- Senden im Hintergrund mit Fortschritt an mehrere Drucker, ACE-Zuordnung mit `.acm`, Bettnetz vor dem Druck per Anycubic-MQTT.
- Warteschlange „wenn frei“ mit Priorität; Ablauf für Kobra 3 (Drucken, Abkühlen, Schieben, Prüfen, mehrfach).
- „Platte geräumt“ als Quittierung, die „wenn frei“ freigibt.
- Material, Spulen, ACE-Plätze, Etiketten; Projekte mit Kalkulation; Protokoll mit Spulenabzug und „Nochmal“.
- Einstellungen: Druckernamen, Telegram-Alerts, Energie.
### Behoben
- Hängender Fehlerzustand: „Fehler löschen“ statt Firmware-Restart (GoKlipper deadlockt bei `FIRMWARE_RESTART`).
- Heizung aus nach Druckfehler.
- Rinkhals-Kopfzeile vor dem Upload, damit die Kobra die Datei nicht doppelt liest.
- Nach Abbruch zusätzlich MQTT-Stop, sonst blieb der Anycubic-Task aktiv (10101).
+102
View File
@@ -0,0 +1,102 @@
# Grotti
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.
Der Name kommt von der Mühle Grotti: sie mahlt ohne Pause, was man ihr aufträgt.
Die Drucker laufen mit Rinkhals (Kobra 3, Kobra S1). Grotti spricht sie über Moonraker an.
Aktuelle Version: siehe `grotti/version.py`, Änderungen in [CHANGELOG.md](CHANGELOG.md).
## Voraussetzungen
- Python 3.11
- PostgreSQL (eine leere Datenbank `grotti` reicht, das Schema legt Grotti selbst an)
- Drucker im selben Netz mit Moonraker auf Port 80
## Einrichten
```powershell
cd D:\Projekte\Grotti
python -m venv .venv
.\.venv\Scripts\pip install -r requirements.txt
copy config.example.json config.json
```
`config.json` anpassen:
| Schlüssel | Bedeutung |
| --- | --- |
| `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. |
| `database_url` | PostgreSQL-Verbindung. |
| `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. |
| `watch_dir` | Slice-Ordner, der beobachtet wird. Leer = nur Upload. |
| `token` | Wenn gesetzt, braucht die API `Authorization: Bearer <token>`. |
| `fqdn` | Öffentliche Basis-URL für Spulen-Etiketten-QR. Leer = Request-Host. |
| `energy` | Watt und Strompreis für die Kosten im Protokoll. |
| `telegram`, `alerts` | Bot und welche Ereignisse als Nachricht kommen. |
| `auto_queue` | Wenn `false`, startet die Warteschlange nichts Neues. |
Die Drucker stehen beim ersten Start in `drucker.json` und landen dann in der Datenbank. Danach werden sie unter Einstellungen gepflegt.
```json
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
```
Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen.
## Starten
```powershell
.\.venv\Scripts\python.exe server.py
```
Dann <http://127.0.0.1:8770/> öffnen. Handbuch in der Oberfläche unter **Doku**.
`config.json` und `data/` sind lokal und nicht im Git.
## Ordner
| Pfad | Inhalt |
| --- | --- |
| `server.py` | Start, Datenbank vorbereiten, Hintergrund-Threads |
| `grotti/` | Kern: Moonraker, Warteschlange (`print_queue.py`), Senden (`send_queue.py`), Abräumen (`eject.py`), Zustandswächter (`watcher.py`), Ereignisse und Telegram (`notify.py`), Datenbank (`store.py`, `schema.sql`) |
| `web/` | Flask-App (`app.py`), Seiten (`templates/`), CSS und JS (`static/`) |
| `docs/` | Technische Notizen, gerendert unter `/doku/<name>` |
| `CHANGELOG.md` | Änderungen je Version |
## API
Alles unter `/api/v1`, JSON. Die wichtigsten Endpunkte:
| Methode und Pfad | Zweck |
| --- | --- |
| `GET /version` | Version |
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag |
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter |
| `GET /dateien`, `POST /dateien` | Bibliothek, Upload |
| `POST /senden` | Datei an Drucker. `confirm: true` ist Pflicht. `when_free: true` reiht ein. |
| `GET /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange, Abbrechen (`status: cancelled`), Ablauf fortsetzen (`resume`) |
| `POST /spulen/<id>/einlegen` | Spule an Drucker/Platz; alte Rolle dort ins Regal |
| `POST /ablauf` | Ablauf für Kobra 3 einreihen |
| `GET /ereignisse?host=&kind=&since=&limit=` | Aktivität |
| `POST /ereignisse/gelesen` | Alerts als gelesen |
| `GET /spulen`, `GET /laeufe`, `GET /projekte` | Material, Protokoll, Projekte |
## Sicherheit
- Druckstarts brauchen `confirm: true`. Die Oberfläche fragt per Zwei-Klick.
- Keine freie G-code-Konsole.
- Kein `FIRMWARE_RESTART`, GoKlipper hängt sich dabei auf. Fehler werden über „Fehler löschen“ geräumt.
## Versionen und Changelog
- Versionierung nach [SemVer](https://semver.org/lang/de/): Patch für Fehlerbehebung, Minor für neue Funktion, Major für Bruch an API oder Datenbankschema.
- Jede Änderung kommt in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
- Beim Release: Version in `grotti/version.py` setzen, `[Unreleased]` in `[x.y.z] – Datum` umbenennen, Git-Tag `vX.Y.Z`.
- Ändert sich `web/static/grotti.css` oder `api.js`, in `layout.html` den `?v=` hochzählen.
## Repo
Eigenes Repository: <https://git.gundel.dev/dg/grotti>. Doku zum Tool gehört nach `docs/`.
+31
View File
@@ -0,0 +1,31 @@
{
"host": "127.0.0.1",
"port": 8770,
"data_dir": "data",
"watch_dir": "",
"database_url": "postgresql://[email protected]:5432/grotti",
"token": "",
"fqdn": "",
"auto_queue": true,
"energy": {
"watts": 120,
"euro_per_kwh": 0.35,
"dryer_watts": 0,
"dry_hours": 0,
"waste_percent": 0,
"utilization": 50
},
"telegram": {
"enabled": false,
"bot_token": "",
"chat_id": ""
},
"alerts": {
"fertig": true,
"fehler": true,
"offline": true,
"queue": true,
"ablauf": false,
"start": false
}
}
+42
View File
@@ -0,0 +1,42 @@
# ACE-Farben in Grotti (Moonraker)
Stand **12.10.2026**: An Kobra 3 **da26** (`10.10.1.60`) liefert Moonraker die ACE-Spulenfarben unter **`mmu`**, nicht unter `filament_hub` (steht oft nicht in `objects/list`).
- Relevante Felder: `gate_color` (Hex `AARRGGBB` oder `RRGGBB`), `gate_material`, optional `gate_status` bzw. Listen pro Platz.
- Grotti liest das in `grotti/moonraker.py` (`extract_slots`) und zeigt die Farben als Swatches auf der Druckerwand.
- Fallback: `filament_hub` / `filament_hubs[].slots` mit `color` und `type`, falls vorhanden.
## Senden: welches Material welcher Slot
12.10.2026: In `Dateien` bei Druckern mit Slots die Slice-Farbe (`paint_index`) einem ACE-Platz (`ams_index`, 0-basiert) zuordnen. Grotti schreibt `Dateiname.gcode.acm` und lädt sie vor `print/start` hoch. Ohne Sidecar: auf `da26` oft HTTP 400 „cannot parse the file“. Siehe [Grotti-Senden-Queue](Grotti-Senden-Queue).
## 12.10. Abbruch `tulip pla.gcode` auf da26 — kein ACE-Blink
Beobachtung: Material ging initial zum Extruder, ACE-Slot blinkte nicht, kein Nachschub vom ACE. Andere Kobra 3 (92ee/6e41) drucken dieselbe Datei von der externen Spule weiter; nicht angefasst.
Ursache (Moonraker-Log `da26`, lesend):
- Job `000009` (tot-printing, 0 mm Filament) und `00000A` (~695 s Druck, ~658 mm) beide `cancelled` auf `10.10.1.60`.
- Bei jedem MQTT-Start: `mmu_ace.patch_print_data` → `detected_tools unknown … skipping auto-feed (no trailer found)`. Ohne Trailer/Tools kein Auto-Feed → kein Slot-Blinken, kein ACE-Nachschub.
- Metadaten-Extrakt für die Datei scheitert (`Unable to extract metadata` / `mmu_ace_metadata` abgebrochen).
- Grotti-Sidecar `tulip pla.gcode.acm`: `paint_index 0 → ams_index 1` (Farbe Datei `#000000`, Slot-Farbe `002B49`). Parallel liegt `tulip pla.acm` mit Identity-Map T0–T3 → Plätze 0–3. MQTT-Payload nutzte die 4er-Map, nicht Grotti-1er-Map.
- Start ohne `bed_leveling`: Grotti übergibt `ams_settings` nur als `.acm`; Moonraker baut die MQTT-`ams_settings` selbst. `auto_leveling: 0`.
- Initialer Extruder-Feed ohne Blink: Restfilament im Pfad / Extrusion aus dem Schlauch, nicht ACE-Auswahl. Nach Abbruch: `mmu.gate`/`tool` = −1, `active_filament` leer; vier Plätze weiter belegt.
Grotti-DB `sends`: da26 zweimal `tulip pla.gcode` (ohne persistiertes `ace_map`; Mapping nur in der Queue im Speicher und in der `.acm` auf dem Drucker). Queue-API leer nach Abschluss.
Nächster Start auf da26: gewünschten ACE-Platz in Grotti setzen (bei schwarzer Slice-Farbe zuletzt Platz 1 = dunkelblau). Alte `tulip pla.acm` nicht mit Grotti-`.gcode.acm` vermischen. Ohne erfolgreichen Trailer/Metadaten bleibt Auto-Feed aus — dann blinkt der ACE weiterhin nicht.
## Fix Grotti (12.10.2026, nach Diagnose)
Anycubic erwartet die Sidecar als **gleichen Basename + `.acm`**: Start `tulip pla.gcode` → `tulip pla.gcode.acm`. Grotti lädt Grotti-Map zusätzlich nach `tulip pla.acm` hoch (Überschreiben), damit eine alte Identity-Map nicht gewinnt.
Mit gesetzter ACE-Zuordnung startet Grotti den Druck per **MQTT** und setzt `ams_box_mapping` aus Grotti-Map — nicht mehr nur über Moonraker-`print/start` (dort Identity-Fallback / falsche `.acm`). Bettnetz-Checkbox steuert weiter nur `auto_leveling`.
13.10.2026: Fehlt `; referenced_tools` beim Senden mit ACE-Zuordnung, hängt Grotti ihn an — aus T-Befehlen, sonst aus `paint_info` / der gewählten `paint_index`-Map (Einzelfarbe oft `0`). Zusätzlich die Rinkhals-Kopfzeile `; processed by MmuAcePatcher`. Warnung nur noch, wenn danach immer noch kein Trailer da ist.
## 13.10.2026 — MQTT 10101 trotz Moonraker `cancelled` (da26 / Gridfinity)
Nach Grotti-Send `Gridfinity_bin_1x1x6u_PLA_0.2_49m32s.gcode` an `da26` (`10.10.1.60`): Moonraker `print_stats.state` = `cancelled`, Anycubic-MQTT-Task blieb aktiv → nachfolgender Start mit **code 10101** („已存在打印任务“ / Print task already exists). Grotti `abbrechen` traf nur Moonraker `/printer/print/cancel`, nicht den Anycubic-Task.
Klärung: MQTT `action: stop`, `taskid: "-1"` auf Topic `…/slicer/printer/{modelId}/{deviceId}/print` (Rinkhals-Docs). Report `state: stopping`, code 200; danach MQTT-Info `data.state: free`. Grotti `abbrechen` macht seither Moonraker-cancel **und** best-effort MQTT-stop (`anycubic_lan.cancel_print`). Wenn MQTT-stop nicht greift: Display abbrechen oder Reboot als letzte Option.
+81
View File
@@ -0,0 +1,81 @@
# Grotti: Abräumen und weiterdrucken (Kobra 3)
Stand **13.10.2026**.
Nach dem Druck kann Grotti auf der **Kobra 3** Bett und Düse abkühlen und die Teile mit dem Extruderkopf nach vorn vom Bett schieben. Danach startet der nächste Schritt oder die nächste Wiederholung. Die **Kobra S1** ist geschlossen — Abräumen dort ist noch offen.
## 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.
- **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.
## Oberfläche
Unter **Dateien** erscheint die Karte **Ablauf**, sobald mindestens eine Kobra 3 gewählt ist. Der Nutzer baut die Schritte selbst:
- **Drucken** — Datei, Bettnetz (LeviQ) standardmäßig an
- **Abkühlen** — Heizung aus, Lüfter an, Düse in die Bettmitte, warten bis Bett/Düse unter den Grenzen
- **Schieben** — Bahn mit einstellbaren Zahlen, danach Lüfter aus
- **Tasten** — kein `G28 Z`. **Mit aktivem LeviQ-Netz** (kein `BED_MESH_CLEAR`): `Result` = Höhe über der lokalen Platte. Bezugspunkt + Tastpunkt (x/y) einstellbar. Maß: letztes `Result is z=` aus dem Store. Über 1 mm = Restteil.
Darunter **wie oft hintereinander**. Einreihen legt einen Eintrag in die Warteschlange „Wenn frei“. **Schieben testen** fährt die Bahn auf einer freien Kobra 3 (Bestätigung „Bett ist leer“). Werte merkt sich der Browser lokal.
Vor dem Bett braucht es eine Kiste. Der Weg nach vorn muss frei sein.
Standardreihenfolge: `Drucken → Abkühlen → Schieben → Tasten` (dann Wiederholung, LeviQ am Druckstart). Altes 3×3-Prüfen (`probe_count` / `mesh_*`) wird übersprungen und in der Schrittleiste nicht mehr gezeigt.
## Standardbahn (editierbar)
- Abkühlen: Heizung aus, Idle verlängern (Stepper an), Park Bettmitte. Z zuerst relativ **+40 mm** (gegen Z-max gekappt), dann X125/Y125. Hub wird geprüft.
- Schieben: hoch → Y hinten (`y_from` Default **250**) → Z noch klar? → **langsam** auf `z_push` (`F400`, nie F3000). Z prüfen; bei Abweichung Abbruch. Nur die X-Spuren aus der Config. `M107`.
- Tasten: Mesh an. Absolut auf Freiraum, ein `PROBE` am Tastpunkt, `Result is z=`. Kein `SET Z=100` / Bezug-Null (hat die Koordinate nur verfälscht). Z zu tief nach Leerlauf → Abbruch, Idle soll Stepper an lassen.
Y klein ist vorn. Das Wischpad liegt hinten (~Y 260) und bleibt außerhalb der Bahn. Grotti klemmt Eingaben (X 0–250, Y 0–235, Z-Schieben 0,6–30, Z-Umsetzen bis 80, Tempo 5–80 mm/s).
## Technik
- Kein Makro auf dem Drucker (`printer.cfg` schreibt Rinkhals neu). Grotti schickt G-code per Moonraker `/printer/gcode/script`.
- Modul `grotti/eject.py`, Steuerung in `print_queue.py`.
- Ablauf = eine Queue-Zeile mit `steps` (JSON), `step_index`, `repeat_total`, `repeat_done`, `phase`.
- Nach dem Druck kein `G28` (Z-Home würde in die Teile fahren). Slice-Ende Present nur `X250 Y220` — **kein Z-Hub**. Abkühlen: Heizung aus, Lüfter an, relativ Z+40 (≤ Z-max), dann X 125 Y 125. Schieben: anheben, Spur hinten ansetzen, langsam senken, nach vorn, hinten parken, `M107`.
- Abräumen braucht gehomed Achsen. Slice-Ende der Kobra 3 enthält `M84; disable motors` (nach Present) — danach sofort unhomed. Bei Ablauf mit Cool/Sweep entfernt Grotti `M84`/`M18` beim Upload. Zusätzlich: `SET_IDLE_TIMEOUT` schon während des Drucks und alle 120 s beim Abkühlen; wenn trotzdem unhomed, `SET_KINEMATIC_POSITION` mit der **echten** letzten Toolhead-XYZ (Present), nie erfundene Z. Kein automatisches `G28` bei belegtem Bett. „Schieben testen“ (Bett leer bestätigt) homt mit `G28`.
Gestoppter Ablauf: Seitenleiste ▶ setzt beim gespeicherten `step_index` fort (`PATCH /druck-queue/<id>` mit `status: running, resume: true`).
- Tasten: Mesh aus, Rand-PROBE + Z-Null (Retract 2 mm), Mitte-PROBE, letztes `Result is z=` gegen `max_delta_mm`. Nie `G28 Z`. Nie Toolhead-Z nach dem Retract. Fortsetzen bei gemeldetem Z unter Bett erlaubt (nur relativ hoch).
- Druckfehler oder Abbruch stoppt den Ablauf — kein Weiterschieben.
## Trockenlauf (Crash-Fix prüfen)
Bett **leer**, ein Drucker reicht. Ziel: Cool-Hub und Anfahren hinten testen, **ohne** `G0 Z{z_push}`.
`PROBE` fährt nur bis Trigger — nicht wie `G0 Z2` zwangsweise auf Koordinate. Trotzdem **nicht** mit Z8 starten (nach dem Crash zu unheimlich).
**Lauf A — sicher (zuerst):**
1. `G28`, Düse wischen.
2. `G90` → `G0 Z40 F1800` → Present `X250 Y220`.
3. Cool-Park: relativ Z+40 (≤ Z-max), Mitte. Erwartung: Z ≈ 80, Mitte.
4. `sweep_go_back_high` (hoch + Y hinten). Erwartung: hinten, Z klar oben.
5. Statt Senken: **`PROBE`**. Erwartung: `Result is z=` typisch &lt; 1 mm; sofort `G0 Z40`.
6. Stop. Keine Y-Bahn, kein `z_push`.
**Lauf B — niedrige „letzte Schicht“ (nur wenn A sauber):**
Wie A, aber Schritt 2 mit `G0 Z15` (nicht Z8). Cool +40 → Z ≈ 55. Wieder nur PROBE, nie `G0 Z2`.
Optional ganz am Ende: einmal `sweep_lower` mit `F400` + `assert_clear`, Bett leer — nur nach A+B ok.
Grotti-Schalter „Trockenlauf / PROBE statt z_push“ noch nicht gebaut — bis dahin manuell oder `_tmp_`-Skript mit `eject.*_script`.
## Grenzen
- Nur Familie `kobra_3`.
- Teile höher als die Umsetzhöhe trifft die Düse auf dem Weg nach hinten.
- Festsitzendes Material (z. B. PETG) schiebt die Düse nicht zuverlässig ab.
## Backlog (später)
Ideen liegen in Grotti unter **Ideen** (Status „später“), nicht nur hier:
- **Abdeckungen gegen Verkanten** — Führung/Rutsche vorn, damit geschobene Teile nicht an Bettkante, Rahmen oder in der Kiste verkanten und den nächsten Lauf blockieren.
- **Kobra S1** — eigener Abräum-Ablauf (geschlossen, Tür).
Erledigt: Antasten / Bettnetz vergleichen (Schritt **Prüfen**, 13.10.2026).
+25
View File
@@ -0,0 +1,25 @@
# Chargen, Etiketten und 3D-Viewer
Stand 14.10.2026.
## Spulen-Chargen
Jede Spule in Grotti bekommt automatisch eine `chargen_nr` der Form `S-YYYYMMDD-0001`. Derselbe Wert steht in `code` und auf dem Etikett. Optional: `hersteller_charge` vom Rollenetikett.
Scannen: auf Material den Code tippen oder per USB-Handscanner (Tastatur + Enter). Lookup `GET /api/v1/spulen/by-code/<code>`. QR auf dem Etikett öffnet `/material?spool=<id>`.
Etikett: `/etikett/<id>`, Druck über den Browser. QR per qrcode.js (CDN). Basis-URL für den QR kommt aus Einstellungen → **FQDN** (`config.json` Key `fqdn`); leer = Host der aktuellen Anfrage. Beispiel: `https://grotti.example.de/material?spool=12`.
## 3D-Pfad
`GET /api/v1/dateien/<id>/pfad` liefert einen gedünnten Toolpath (bis ca. 120 000 Punkte, plus `layer_breaks`, Cache-`version` 5) aus dem lokalen G-code. Die Dateien-Seite und das Cockpit zeichnen ihn mit Three.js.
Darstellung: nur Extrusionswege. Preview = orange. Live: gedruckt leicht verdickt grün, offen grau; Schnitt nach `progress`. Parser akzeptiert `E.39`/`Z.6`. Bett: Gitter 10 mm, mm-Sprites, Legende.
**Exclude-Object** (nur Klipper/Moonraker): Status aus `exclude_object`. Polygone im Viewer; Liste unter dem Pfad; Zwei-Klick → `POST .../steuern` Action `exclude_object` mit `confirm`. Fehlt Modul/DEFINEs → Panel aus. BambuLab später eigener Adapter.
Cockpit-Live: Kamera-Frames werden unsichtbar vorgeladen (letztes Bild bleibt), `no-cam`-Layout nur ohne Kamera-URL. 3D-Orbit bleibt beim 5‑s-Poll erhalten (`setPath` nur beim ersten Laden / Dateiwechsel).
Auf der Wand: Caption `Dateiname · % · Schicht a/b`, sonst „Datei nicht in Grotti“.
Bedienung: Orbit / Zoom / Schwenken / Doppelklick-Reset; Tippen auf Polygon wählt das Objekt.
+20
View File
@@ -0,0 +1,20 @@
# Grotti-Dateien Metadaten
`stored_path` liegt nur in der DB und in Rohzeilen (`get_file_row`, `list_file_rows`). Die API-Antwort (`file_out` / `GET /api/v1/dateien`) enthält den Pfad bewusst nicht.
`POST /api/v1/dateien/neu-lesen` liest Watch-Ordner neu und ruft `refresh_file_meta` auf Rohzeilen auf — nicht auf `list_files`, sonst `KeyError: 'stored_path'`.
## Was der Parser liest
Kopfkommentare **und** Footer (letzte ~128 KB). Anycubic/Orca legen Gewicht, Zeit und Prozesswerte oft ans Dateiende.
In der Bibliothek gespeichert:
| Feld | Bedeutung |
|------|-----------|
| `filaments` | Typ/Farbe (`paint_info` oder `filament_type`/`filament_colour`) |
| `weight_g` / `estimated_s` | Filamentgewicht und Druckzeit |
| `printer_family` | aus `printer_model` |
| `slice_meta` | jsonb: `layer_height`, `layers`, `max_z_mm`, `nozzle_mm`, `temp_nozzle`, `temp_bed`, `slicer` |
Anzeige: Material, Zeit, Gewicht, Schichthöhe, Maschine, Projekt immer in der Zeile; Rest im Info-Popup bzw. unter der gewählten Datei. Nach Parser-Update: **Ordner neu lesen**.
+55
View File
@@ -0,0 +1,55 @@
# Grotti: Alltag und Oberfläche
Stand **14.10.2026**, v1.4.0.
## Design
- Dunkles UI mit Orange-Akzent, Lucide-ähnliche Stroke-Icons (SVG-Sprite in `layout.html`).
- Kurze Seitenuntertitel; Technik-/Config-Hinweise liegen in der Doku, nicht als Fließtext in den Formularen.
- Einheitliche Seitenköpfe, Toolbar-Filter, kompakte Tabellen, Formularfelder mit kleinen Labels, Empty-States mit Icon.
- **Anzeige pro Browser** (Einstellungen → Dieser Browser): `Desktop` oder `Regal (Touch)` in `localStorage grotti-display`. Shortcut `?shelf=1` / `?shelf=0`. Regal: größere Controls, kompakte Nav (Drucker/Kameras/Warteschlange), Sidebar default eingeklappt.
- Seitenmenü einklappbar (Icon-Rail, `grotti-nav-collapsed`). Unter 760 px weiter Bottom-Nav.
## Seitenleiste
- Hauptmenü: Drucker, Dateien, Kameras, Aktivität, Warteschlange, Protokoll, Material, Projekte. Darunter Einstellungen, Ideen, Doku (mit Icons); Fuß **© by dominic@gundel.dev** und Version (Changelog).
- Live-Leiste, eigene Scrollfläche, Boxen auf- und zuklappbar (Zustand in `localStorage` `grotti-live-closed`):
- **Läuft jetzt**: laufende Aufträge und Abläufe mit Schrittleiste, Durchlauf `n/m`, aktueller Phase und ×-Abbrechen; auch Direktstarts ohne Queue.
- **Wartet**: wartende Aufträge mit Ziel, Priorität hoch/runter, Abbrechen; die letzten Fehler mit Fortsetzen/ausblenden.
- **Aufgaben**: Platte belegt ohne Ablauf — „geräumt“ per Zwei-Klick.
- **Senden**: laufende Uploads mit Fortschritt.
- **Alerts**: ungelesene wichtige Ereignisse, „gelesen“, Link auf Aktivität.
- Aktualisierung: Senden 2 s, Queue 4 s, Aufgaben/Alerts 8 s.
## Wand
- Karte je Drucker: Zustandschip, Fertig-Uhrzeit groß, Fortschrittsbalken, drei Kacheln (Rest, Schicht, Läuft), Temperaturbalken Düse/Bett mit Ziel, Datei. Klick auf die Karte öffnet das Cockpit. Pause, Abbrechen, Fehler löschen und Platte geräumt bleiben auf der Karte (Zwei-Klick).
- Laufender Ablauf als Streifen mit Schrittleiste und Abbrechen. Wartende Aufträge für diesen Drucker als Hinweis.
- Streifen „Fehler“ mit **Fehler löschen** und „Platte belegt“ mit **Platte geräumt** (während eines Ablaufs ausgeblendet, der räumt selbst).
- **Cockpit** (Hash `/#<host>`): Kamera (Frame-Vorladen; zugeklappt oder ohne Kamera → eine Spalte, kein leeres 16:10), Status, Temps inkl. Bauraum wenn vorhanden, Lüfter-Icons, Steuerung, 3D-Pfad (Exclude-Object), Bettnetz. E-Stop nur bei printing/paused/heating, Zwei-Klick. Live vom Watcher.
## Dateien und Senden
- Links Bibliothek: Suche, Filter Maschine/Projekt, Vorschaubild; Zeile zeigt Material, Zeit, Gewicht, Schichthöhe, Maschine, Projekt; Details per Info-Popup. Metadaten aus G-code-Kopf **und** Footer. Hochladen / Ordner neu lesen.
- Rechts der Flow: Datei → Drucker (Live-Zustand, eingelegte Rolle) → Wann (Jetzt / Wenn frei / Nächster freier) oder Reiter Ablauf.
- Kein freies Spulen-Dropdown: Abzug nimmt die Rolle(n) am Ziel-Drucker zur Startzeit. ACE: Zuordnung Datei-Farbe → Platz. Warnung bei Material-Mismatch oder zu wenig Rest.
- Startknopf mit Zwei-Klick bei „Jetzt“. Auswahl in `localStorage` `grotti-send-v1`.
## Material
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf.
- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal.
- Sorten: Profile (Dichte, mm³/s, Preis, Maschinen). Anlegen/ändern/löschen; „Spule damit anlegen“ wechselt zum Spulen-Reiter.
## Warteschlange
- Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen.
- Unter **Läuft** / **Alle aktiv** auch Direktstarts aus `/laeuft` (ohne Queue-Zeile), mit Fortschritt und Link zur Wand.
## Aktivität
- Zeitstrahl nach Tagen. Filter in der URL. Ungelesene Alerts markiert.
## Kameras und Handy
- Kamerakacheln mit Live-Zustand (`live=1`). Snapshot-Cache 2 s. Unter 760 px: Schnellmenü Drucker / Dateien / Aktivität.
+31
View File
@@ -0,0 +1,31 @@
# Haftung
**Entwurf.** Kein anwaltlich geprüfter Text. Vor einem Verkauf mit Preis, Abo oder Shop kommen Impressum, Datenschutz und AGB dazu. Diese Seiten nennen keinen Preis.
Stand **14.10.2026**. Was die Drucker können müssen, steht unter [Voraussetzungen](Voraussetzungen.md).
## Grundsatz
Grotti flasht nicht, schickt keine Firmware und beschreibt keine Signatur-Umgehung, keinen Root-Exploit und keine Umgehung einer Downgrade-Sperre.
Der Nutzer richtet seinen eigenen Drucker ein, bevor er Grotti nutzt. Verlinkt wird nur die Installationsseite von Rinkhals: <https://github.com/rinkhals-community/Rinkhals>. Die Warnung, die der Hersteller selbst einblendet, bleibt seine.
Anycubic schließt ungenehmigte Änderungen von der Garantie aus. Rinkhals ist ein Community-Aufsatz; die ursprüngliche Software bleibt darunter, der Aufsatz lässt sich wieder entfernen. Ob Anycubic einen Fall annimmt, entscheidet Anycubic.
Creality lässt beim offiziellen Root den Hardware-Service stehen und gibt dafür keine Software-Unterstützung. Fremde Firmware ist von der Garantie ausgenommen (<https://wiki.creality.com/en/warranty-policy>).
Bambu Lab deckt Schäden durch inoffizielle Firmware nicht ab. Der Entwicklermodus ist eine eigene Funktion von Bambu Lab, mit der Warnung, die Bambu Lab selbst einblendet.
Die gesetzliche Mängelhaftung für den Drucker bleibt beim Verkäufer des Druckers. Ein Disclaimer Grotti wischt Verletzung, Vorsatz und grobe Fahrlässigkeit nicht weg.
Unbeaufsichtigtes Drucken, Hitze und Brandgefahr stehen vor dem ersten Start, nicht im Kleingedruckten.
Es gibt kein Auftrags-Flashen und keine vorgeflashten Drucker.
## Vor dem ersten Start
Düse und Bett werden heiß. Den ersten Lauf behält der Nutzer im Blick. Der Satz dazu steht oben, nicht erst hier: Unbeaufsichtigtes Drucken, Hitze und Brandgefahr stehen vor dem ersten Start, nicht im Kleingedruckten.
## Was Grotti beim Start schon verlangt
Druckstarts brauchen eine Bestätigung (`confirm: true`). Es gibt keine freie G-code-Konsole und kein `FIRMWARE_RESTART`.
+50
View File
@@ -0,0 +1,50 @@
# Grotti Ops: Queue, Alerts, Steuerung
Stand 14.10.2026.
## Einstellungen
Seite `/einstellungen`: Druckernamen/Host/Familie/gesperrt (PostgreSQL `printers`, `drucker.json` nur Seed), Automatik (`auto_queue` in `config.json`), Telegram, Energie. Live-API und Wand nutzen die DB-Namen. Gesperrte Drucker und `auto_queue: false` verhindern neue „wenn frei“-Starts; laufende Abläufe laufen weiter.
## Live-Quelle
`StateWatcher` pollt die Drucker (ca. 10 s). `GET /drucker?live=1` und der Print-Queue-Dispatcher lesen diesen Stand. Nur wenn der Cache älter als ~60 s / 30 s ist, wird einmal direkt abgefragt. Lange Ablauf-Schritte (Upload, Schieben, Tasten) laufen in Worker-Threads.
## 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`.
## Spulen
Ort = Regal oder Drucker (+ `ace_slot`). Unique-Index `(printer_host, ace_slot)`. Beim Start löst Grotti die Spule(n) aus Host + `ace_map` auf; Materialtyp der Datei muss passen. `sends` speichert `ace_map` und `bed_leveling`. Nach „fertig“ synct der Watcher das Protokoll und zieht Spulen ab (ACE anteilig).
## Telegram / Ereignisse
Jedes Ereignis landet in `events` und auf der Seite `/aktivitaet`. Telegram ist pro Art abschaltbar und wird je Host und Art entprellt (Watcher 60 s, Queue 30 s). Das Speichern wird nie entprellt. Gemeinsamer Weg: `notify.record()`.
| Art | Woher | Alert | Telegram-Standard |
| --- | --- | --- | --- |
| `fertig` | Watcher, `printing`→`complete` | nein, Platte unter „Aufgaben“ | an |
| `fehler` | Watcher, Zustand `error` | ja | an |
| `abbruch` | Watcher, `printing`/`paused`→`cancelled`/`standby` | ja | wie `fehler` |
| `offline` | Watcher, ≥2 Polls weg; auch „wieder erreichbar“ | ja | an |
| `spule` | Watcher, Rest unter Job-Gewicht | ja | an |
| `queue` | Dispatcher: Queue-Fehler, Ablauf gestoppt | ja | an |
| `start` | Watcher: Druck startet, Pause, Fortsetzen | nein | aus |
| `ablauf` | Dispatcher: Start, Schritte, Wiederholung, fertig; App: eingereiht, von Hand abgebrochen | nein | aus |
„Alert“ heißt: ungelesen, steht in der Sidebar. `start`, `ablauf` und `fertig` werden gleich als gelesen gespeichert (`store.QUIET_EVENT_KINDS`), damit unter Alerts nur Probleme stehen.
„Aufgaben“ (Sidebar, `GET /api/v1/aufgaben`): Drucker mit `bed_status.needs_clear`, ohne laufenden Queue-Eintrag und nicht am Drucken. Knopf „geräumt“ ruft `POST /api/v1/drucker/<host>/abraeumen` mit `confirm: true` (Zwei-Klick), fährt nichts.
API: `GET /api/v1/ereignisse?limit=&host=&kind=a,b&since=<ISO>&unread=1`, `POST /api/v1/ereignisse/gelesen`.
Bei `print_stats.state=error` ist `message` auf Anycubic oft leer. Grotti ergänzt dann nur aus dem G-code-Log (echte `Must home`-/Fehlerzeilen) oder „Druck sofort abgebrochen“. Leeres `homed_axes` allein gilt nicht als Fehler — Idle schaltet die Stepper ab, das ist normal.
Nach Fehler: Watcher schaltet Bett/Düse/Lüfter aus (nicht weiter heizen). Vor Start und zwischen Ablauf-Schritten: hängender `print_stats=error` wird per `SDCARD_RESET_FILE` geräumt und der Druck gestartet. Kein Homing-Zwang vorher — der Slice-Start macht `G28`. Nur mitten im Druck (`print_active`) bricht ein error den Ablauf ab.
## Steuerung
Wand-Details: Düse/Bett, Speed/Flow/Fan, E-Stop und Exclude-Object über `POST /api/v1/drucker/<host>/steuern` (Whitelist, keine freie Konsole; `exclude_object` braucht `confirm` + `name`). Abbruch weiterhin Moonraker-cancel + MQTT-stop.
**Kein Firmware-Restart:** Auf GoKlipper/Rinkhals deadlocked `FIRMWARE_RESTART` gklib bis Power-Cycle ([Rinkhals #32](https://github.com/jbatonnet/Rinkhals/issues/32)). Grotti lehnt den Befehl ab. `printer.cfg` nur über Display-Reboot oder Strom. Hängenden `print_stats=error`: Wand **Fehler löschen** (`SDCARD_RESET_FILE` + Heizung aus).
+76
View File
@@ -0,0 +1,76 @@
# Grotti: Senden und Start dauert
Stand **14.10.2026**. Spulen kommen vom Ziel-Drucker zur Startzeit (`ace_map` + eingelegte Rolle), nicht aus einem freien Dropdown. `sends` speichert `ace_map` und Bettnetz für „Nochmal“.
## Warum Sekunden / Minuten
Über Grotti-App (`Dateien` → Starten) läuft der Aufruf nicht nur lokal: die Datei wird als **ein synchroner Multipart-POST** an Moonraker hochgeladen (`/server/files/upload`), danach startet `/printer/print/start`. Der HTTP-Request endet erst, wenn Moonraker die Datei geschrieben und oft Metadaten/MMU-Preprocess angeschoben hat — bis dahin wirkt die Queue „still“.
Messung 12.10.2026, `tulip pla.gcode` (~**50,4 MB** in `data/eingang`, typisch daneben ~2 MB): Upload auf `da26` (`10.10.1.60`) **~67 s** laut Moonraker-Log (`201 POST … 67106 ms`), plus kurze `.acm`-Sidecar (~87 ms) und Start. Gesamter Queue-Job ~**96 s**. Kleine Dateien bleiben im Sekundenbereich.
13.10.2026: Die lange Pause bei „50/50 MB“ ist Rinkhals `mmu_ace_metadata.py`. Der liest die ganze Datei auf der Kobra, bevor der Upload-HTTP antwortet, und schreibt sie bei gefundenen Werkzeugen noch einmal um. Grotti setzt vorher die Kopfzeile `; processed by MmuAcePatcher`. Bei Prusa, Super, Orca, Bambu oder AnycubicSlicerNext und Werkzeugen (`T0`, `MMU_CHANGE_TOOL`) hängt sie `; referenced_tools = …` an — dieselbe Zeile, die der Drucker sonst selbst schreibt. Danach überspringt er diesen Durchlauf. Stehen die Bytes, wechselt die Anzeige auf „liest die Datei…“. Grotti neu starten (`use_reloader=False`).
Vorher prüft Grotti Online-Status und ob schon gedruckt wird — laufende Drucke werden nicht abgebrochen. Die Rinkhals-Touch-UI können wir nicht umbauen; Fortschritt betrifft Grotti unter `:8770`.
## Queue (Grotti)
Ab 12.10.2026: Start legt Hintergrund-Aufträge an (`pending` → `running` → `done`/`error`). Pro gewähltem Drucker ein Queue-Job; mehrere Worker. UI pollt ~500 ms. Beim Hochladen zeigt die Queue jetzt **MB-Fortschritt** (urllib liest den Body gestreamt); Timeout skaliert mit Dateigröße (ca. 180–600 s). Bestätigung „Druck wirklich starten“ bleibt Pflicht. Nach Python-Änderung Grotti neu starten (`use_reloader=False`).
## Slice-Ordner / Upload
`watch_dir` in `config.json` zeigt auf den Slice-Ordner der Bibliothek. Ohne Ordner geht Hochladen über die Dateien-Seite trotzdem.
## ACE / `.acm`
Drucker mit ACE/MMU-Plätzen (`mmu.gate_color` / `gate_material`, Grotti-`slots`) brauchen vor `print/start` eine Sidecar-Datei `*.gcode.acm`. In der UI unter Optionen → ACE-Zuordnung (Datei-Farbe → Platz). Ohne passende `.acm` lehnt der Drucker oft mit „cannot parse the file“ ab — Details hier, nicht als Banner in der App:
```json
{"use_ams":true,"ams_box_mapping":[{"paint_index":0,"paint_color":[r,g,b],"material_type":"PLA","ams_index":1,"ams_color":[r,g,b]}]}
```
`paint_index` = Farbe in der Slice-Datei (0-basiert), `ams_index` = ACE-Platz 0-basiert. Externe Spule / keine Slots: kein `.acm`. Keine Community-Makros (`ACE_SET_SLOT` / `G9111`).
AnycubicSlicerNext schreibt die Farben oft als `; paint_info: [{...}]` statt `filament_type` — Grotti liest das seit 12.10.2026 mit.
## Bettnetz / auto_leveling beim Start (12.10.2026)
Rinkhals `kobra.py` `mqtt_print_file` baut den Anycubic-MQTT-Start so (Topic `…/slicer/printer/{modelId}/{deviceId}/print`, lokal `127.0.0.1:2883`):
```json
{
"type": "print",
"action": "start",
"msgid": "<uuid>",
"timestamp": 1660201929871,
"data": {
"filename": "job.gcode",
"filepath": "/",
"taskid": "123456",
"task_mode": 1,
"filetype": 1,
"task_settings": {
"auto_leveling": 1,
"vibration_compensation": 0,
"flow_calibration": 0
}
}
}
```
`auto_leveling` ist **0/1** (Integer). `1` löst die Anycubic-Nivellierfolge beim Jobstart aus (LeviQ / Netz messen) — nicht über G-code `LEVIQ_*` / `BED_MESH_CALIBRATE` / `SAVE_CONFIG` von Grotti. Ohne Grotti-Flag kommt der Wert aus der **globalen** App-Property `40-moonraker` → `mqtt_print_auto_leveling` (Default `False`). Extra-Felder an Moonraker `POST /printer/print/start` werden ignoriert. App-Property setzen geht nur per SSH (`set_app_property 40-moonraker mqtt_print_auto_leveling True`), keine HTTP-API.
### Grotti-Verhalten
| Checkbox / ACE | Startweg |
| --- | --- |
| kein Bettnetz, keine ACE-Map | Upload + Moonraker `print/start` → Rinkhals MQTT (globale `auto_leveling`, oft 0) |
| ACE-Map gesetzt | Upload + `.acm` (Basename + Stamm-Variante) + **Grotti-MQTT** mit `ams_settings` aus der Map; `auto_leveling` nur 1 wenn Bettnetz an |
| Bettnetz an | wie ACE-Map-Pfad, `auto_leveling:1` |
Credentials holt Grotti ohne SSH: `GET :18910/info` → signiertes `POST :18910/ctrl` → AES-Decrypt (Username/Password/`deviceId`). Port **2883** ist vom PC zu; **9883** (TLS, Cert-Check aus) ist offen. Mit Grotti-MQTT liegt `ams_box_mapping` explizit in der Payload (kein Moonraker-Identity-Fallback). Ohne `; referenced_tools`-Trailer: Warnung in UI/Queue, kein Fake-Trailer.
**Abbruch:** Grotti `abbrechen` = Moonraker `/printer/print/cancel` **plus** MQTT `action: stop` / `taskid: "-1"` (gleicher Topic wie Start). Nur Moonraker-cancel lässt den Anycubic-Task stehen → nächster MQTT-Start mit **10101**. 13.10.2026: Gridfinity auf da26 so hängen geblieben, per MQTT-stop freigemacht (`data.state: free`).
**Start-Report verspätet:** 13.10.2026 ~21:35, A2 (`10.10.1.59`), Ablauf 2/5 direkt nach der Bettnetz-Prüfung. Innerhalb von 30 s kam kein `print/report`, der Druck lief trotzdem an. `info/query` (Topic `…/slicer/printer/{modelId}/{deviceId}/info`, Antwort auf `…/info/report`) zeigte `project.task_id` = Grotti-`taskid`, `state: auto_leveling`. Moonraker meldet in dieser Phase noch `print_stats.state: complete` vom Vorauftrag. Grotti prüft deshalb bei einem Start-Timeout bis zu dreimal `info/query`. Steht dort die eigene `taskid`, gilt der Start als erfolgreich (`anycubic_lan.print_info`).
Fallback ohne MQTT: global in Rinkhals Touch → Manage apps → 40-moonraker → Auto leveling (gilt dann für alle Moonraker-Starts inkl. Mainsail/Orca).
+39
View File
@@ -0,0 +1,39 @@
# Voraussetzungen
Stand **14.10.2026**.
Grotti steuert Drucker, die im eigenen Netz schon Moonraker sprechen. Bewährt ist das auf der Kobra 3 und der Kobra S1, nachdem Rinkhals läuft. Moonraker liegt auf Port 80. Den Aufsatz installiert der Nutzer selbst. Grotti liefert keine Firmware und keine Schraubanleitung.
Verlinkt wird nur die Installationsseite von Rinkhals: <https://github.com/rinkhals-community/Rinkhals>. Die Warnung, die der Hersteller selbst einblendet, bleibt seine.
Andere Drucker nur, wenn Moonraker dort schon erreichbar ist. Dafür gibt es keine eigene Anleitung. Bambu Lab, Prusa und Creality Cloud bleiben bei deren eigenen Programmen.
Der Ablauf Abkühlen, Schieben, Tasten ist für die Kobra 3. Die Kobra S1 ist geschlossen, Abräumen dort ist offen.
Einrichtung der Software (Python, PostgreSQL, Start) steht im [README](../README.md).
## Vor dem ersten Start
Unbeaufsichtigtes Drucken, Hitze und Brandgefahr stehen vor dem ersten Start, nicht im Kleingedruckten. Düse und Bett werden heiß. Den ersten Lauf behält der Nutzer im Blick.
## Grundsatz
**Entwurf.** Kein anwaltlich geprüfter Text. Vor einem Verkauf mit Preis, Abo oder Shop kommen Impressum, Datenschutz und AGB dazu. Diese Seiten nennen keinen Preis.
Grotti flasht nicht, schickt keine Firmware und beschreibt keine Signatur-Umgehung, keinen Root-Exploit und keine Umgehung einer Downgrade-Sperre.
Der Nutzer richtet seinen eigenen Drucker ein, bevor er Grotti nutzt. Verlinkt wird nur die Installationsseite von Rinkhals: <https://github.com/rinkhals-community/Rinkhals>. Die Warnung, die der Hersteller selbst einblendet, bleibt seine.
Anycubic schließt ungenehmigte Änderungen von der Garantie aus. Rinkhals ist ein Community-Aufsatz; die ursprüngliche Software bleibt darunter, der Aufsatz lässt sich wieder entfernen. Ob Anycubic einen Fall annimmt, entscheidet Anycubic.
Creality lässt beim offiziellen Root den Hardware-Service stehen und gibt dafür keine Software-Unterstützung. Fremde Firmware ist von der Garantie ausgenommen (<https://wiki.creality.com/en/warranty-policy>).
Bambu Lab deckt Schäden durch inoffizielle Firmware nicht ab. Der Entwicklermodus ist eine eigene Funktion von Bambu Lab, mit der Warnung, die Bambu Lab selbst einblendet.
Die gesetzliche Mängelhaftung für den Drucker bleibt beim Verkäufer des Druckers. Ein Disclaimer Grotti wischt Verletzung, Vorsatz und grobe Fahrlässigkeit nicht weg.
Unbeaufsichtigtes Drucken, Hitze und Brandgefahr stehen vor dem ersten Start, nicht im Kleingedruckten.
Es gibt kein Auftrags-Flashen und keine vorgeflashten Drucker.
Ausführlich derselbe Hinweis: [Haftung](Haftung.md).
+9
View File
@@ -0,0 +1,9 @@
# Grotti-Wand: Bettnetz
Stand **13.10.2026**.
Die Heatmap im Drucker-Cockpit (unter dem Kamerabild, neben dem 3D-Pfad) zeigt das **aktuelle** Moonraker-Objekt `bed_mesh` je Drucker (`probed_matrix`, sonst `mesh_matrix`), nicht ein Netz zum laufenden G-code.
Drei Drucker mit derselben Datei (z. B. Gridfinity) haben deshalb **drei verschiedene** Netze — eines pro Bett. Die Zeile am 3D-Viewer ist der Auftragspfad (`3D-Pfad · …`), nicht die Netz-Identität.
Grotti merkt sich das zuletzt gelesene Netz im Speicher. Offline oder leeres `bed_mesh` → Anzeige mit Hinweis „offline gespeichert“ und Uhrzeit der letzten erfolgreichen Abfrage. Neu nivellieren am Drucker aktualisiert das Cockpit beim nächsten Poll (~5 s).
+18
View File
@@ -0,0 +1,18 @@
[
{
"name": "Kobra 3 da26",
"host": "10.10.1.60"
},
{
"name": "Kobra 3 92ee",
"host": "10.10.1.59"
},
{
"name": "Kobra 3 6e41",
"host": "10.10.1.34"
},
{
"name": "Kobra S1",
"host": "10.10.1.88"
}
]
View File
Whitespace-only changes.
+111
View File
@@ -0,0 +1,111 @@
"""Anycubic ACE Sidecar (.acm) für Moonraker-Start."""
from __future__ import annotations
import json
import re
# Rinkhals mmu_ace: ohne `; referenced_tools = …` kein Auto-Feed / Nachschub.
TRAILER_WARN = "ACE-Nachschub braucht Slicer-Metadaten/Trailer"
def color_channels(value):
if isinstance(value, (list, tuple)) and len(value) >= 3:
out = []
for channel in value[:3]:
if isinstance(channel, bool) or not isinstance(channel, (int, float)):
return []
out.append(max(0, min(255, int(round(float(channel))))))
return out
if value in (None, ""):
return []
text = str(value).strip()
if text.startswith("#"):
text = text[1:]
text = re.sub(r"[^0-9A-Fa-f]", "", text)
if len(text) == 8:
# Moonraker mmu.gate_color: RRGGBBAA
text = text[:6]
if len(text) != 6:
return []
try:
return [int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16)]
except ValueError:
return []
def acm_filename(gcode_name):
"""Sidecar neben dem Start-Dateinamen: `job.gcode` → `job.gcode.acm`."""
name = (gcode_name or "job.gcode").replace("\\", "/").split("/")[-1]
if name.lower().endswith(".acm"):
return name
return name + ".acm"
def acm_upload_names(gcode_name):
"""Primärer Basename wie Start-filename, plus Stamm-Variante zum Überschreiben.
Anycubic/Rinkhals erwarten typisch `name.gcode.acm`. Parallel kann eine
ältere Identity-Map als `name.acm` liegen und Grotti-Map verdrängen.
"""
primary = acm_filename(gcode_name)
names = [primary]
base = (gcode_name or "job.gcode").replace("\\", "/").split("/")[-1]
if base.lower().endswith(".acm"):
base = base[: -len(".acm")]
if base.lower().endswith(".gcode"):
alt = base[: -len(".gcode")] + ".acm"
if alt.lower() != primary.lower():
names.append(alt)
return names
def normalize_mapping(raw):
if not isinstance(raw, list):
return []
out = []
seen = set()
for item in raw:
if not isinstance(item, dict):
continue
try:
paint = int(item.get("paint_index"))
ams = int(item.get("ams_index"))
except (TypeError, ValueError):
continue
if paint < 0 or ams < 0 or ams > 7 or paint in seen:
continue
seen.add(paint)
out.append({"paint_index": paint, "ams_index": ams})
return out
def build_acm(filaments, mapping, slots):
filaments = filaments or []
slots = slots or []
slot_by_index = {int(slot.get("index")): slot for slot in slots if isinstance(slot.get("index"), int)}
entries = []
for item in normalize_mapping(mapping):
paint = item["paint_index"]
ams = item["ams_index"]
filament = filaments[paint] if 0 <= paint < len(filaments) else {}
slot = slot_by_index.get(ams) or {}
paint_color = color_channels(filament.get("color")) or [0, 0, 0]
ams_color = color_channels(slot.get("color")) or paint_color
material = (filament.get("type") or slot.get("type") or "PLA").strip() or "PLA"
entries.append(
{
"paint_index": paint,
"paint_color": paint_color,
"material_type": material,
"ams_index": ams,
"ams_color": ams_color,
}
)
if not entries:
return None
return {"use_ams": True, "ams_box_mapping": entries}
def acm_bytes(payload):
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
+347
View File
@@ -0,0 +1,347 @@
"""Anycubic LAN: Credentials über :18910, Start/Stop über MQTTS :9883.
Entspricht Rinkhals kobra.py mqtt_print_file, aber mit pro-Job task_settings
(statt globaler App-Property mqtt_print_auto_leveling). Stop: action stop,
taskid -1 (Rinkhals MQTT-Docs) — nötig, weil Moonraker-cancel den Anycubic-Task
nicht räumt (sonst 10101).
"""
from __future__ import annotations
import hashlib
import json
import random
import ssl
import string
import time
import uuid
import urllib.error
import urllib.request
from base64 import b64decode
import paho.mqtt.client as mqtt
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
CRED_TTL = 300
_CRED_CACHE = {}
class LanError(Exception):
def __init__(self, message):
super().__init__(message)
self.message = message
def _info(host):
try:
with urllib.request.urlopen("http://%s:18910/info" % host, timeout=5) as response:
return json.load(response)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
raise LanError("LAN-Info vom Drucker nicht erreichbar (:18910).") from exc
def _ctrl(host, token, did="grotti"):
ts = str(int(time.time() * 1000))
nonce = "".join(random.choice(string.ascii_letters + string.digits) for _ in range(6))
sign = hashlib.md5(
(hashlib.md5(token[:16].encode()).hexdigest() + ts + nonce).encode()
).hexdigest()
url = "http://%s:18910/ctrl?ts=%s&nonce=%s&sign=%s&did=%s" % (
host, ts, nonce, sign, did,
)
try:
request = urllib.request.Request(url, data=b"", method="POST")
with urllib.request.urlopen(request, timeout=8) as response:
payload = json.load(response)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
raise LanError("LAN-Credentials vom Drucker nicht erreichbar (:18910/ctrl).") from exc
if payload.get("code") != 200 or not isinstance(payload.get("data"), dict):
raise LanError("LAN-Credentials abgelehnt.")
return payload["data"]
def _decrypt(info_token, ctrl_data):
key = info_token[16:32].encode("ascii")
iv_raw = ctrl_data.get("token") or ""
iv = iv_raw.encode("ascii")[:16] if isinstance(iv_raw, str) else b""
if len(iv) != 16:
raise LanError("LAN-Credentials ungültig (IV).")
try:
blob = b64decode(ctrl_data["info"])
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
decryptor = cipher.decryptor()
raw = decryptor.update(blob) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder()
plain = unpadder.update(raw) + unpadder.finalize()
data = json.loads(plain.decode("utf-8"))
except Exception as exc:
raise LanError("LAN-Credentials lassen sich nicht lesen.") from exc
if not data.get("deviceId") or not data.get("username") or not data.get("password"):
raise LanError("LAN-Credentials unvollständig.")
return data
def credentials(host):
now = time.time()
cached = _CRED_CACHE.get(host)
if cached and now - cached[0] < CRED_TTL:
return cached[1]
info = _info(host)
token = info.get("token") or ""
if len(token) < 32:
raise LanError("LAN-Info ohne Token.")
data = _decrypt(token, _ctrl(host, token))
data["modelId"] = str(info.get("modelId") or data.get("modeId") or data.get("modelId") or "")
if not data["modelId"]:
raise LanError("LAN-Info ohne modelId.")
_CRED_CACHE[host] = (now, data)
return data
def _rgba(value):
if isinstance(value, (list, tuple)) and len(value) >= 3:
channels = []
for channel in value[:3]:
if isinstance(channel, bool) or not isinstance(channel, (int, float)):
return [0, 0, 0, 255]
channels.append(max(0, min(255, int(round(float(channel))))))
alpha = 255
if len(value) >= 4 and isinstance(value[3], (int, float)) and not isinstance(value[3], bool):
alpha = max(0, min(255, int(round(float(value[3])))))
return channels + [alpha]
return [0, 0, 0, 255]
def normalize_ams(ams_settings):
if not isinstance(ams_settings, dict):
return None
mapping = ams_settings.get("ams_box_mapping")
if not isinstance(mapping, list) or not mapping:
return None
entries = []
for item in mapping:
if not isinstance(item, dict):
continue
try:
paint = int(item.get("paint_index"))
ams = int(item.get("ams_index"))
except (TypeError, ValueError):
continue
material = (item.get("material_type") or "PLA").strip() or "PLA"
entries.append({
"paint_index": paint,
"ams_index": ams,
"paint_color": _rgba(item.get("paint_color")),
"ams_color": _rgba(item.get("ams_color") or item.get("paint_color")),
"material_type": material,
})
if not entries:
return None
return {"use_ams": True, "ams_box_mapping": entries}
def _report_error(body, default_message):
code = body.get("code")
if code == 10107:
return "Filament gerissen. Bitte neues laden. (code 10107)"
if code == 10101:
return (
"Alter Druckauftrag noch aktiv (Anycubic-MQTT). "
"Bitte zuerst abbrechen. (code 10101)"
)
message = str(body.get("msg") or default_message)
return "%s%s" % (message, (" (code %s)" % code) if code else "")
def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Druckbefehl."):
"""Publish auf slicer/…/print, warte auf print/report."""
cred = credentials(host)
model_id = cred["modelId"]
device_id = cred["deviceId"]
payload = json.dumps(request)
topic_print = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/print" % (model_id, device_id)
topic_report = "anycubic/anycubicCloud/v1/printer/public/%s/%s/print/report" % (
model_id, device_id,
)
state = {"done": False, "error": None, "report": None}
def on_connect(client, userdata, flags, reason_code, properties=None):
if getattr(reason_code, "is_failure", False):
state["error"] = "MQTT-Verbindung abgelehnt (%s)." % reason_code
state["done"] = True
return
client.subscribe(topic_report)
client.publish(topic_print, payload=payload, qos=1)
def on_message(client, userdata, msg):
try:
body = json.loads(msg.payload.decode("utf-8", "replace"))
except (json.JSONDecodeError, UnicodeError):
return
state["report"] = body
report = str(body.get("state") or "")
action = str(request.get("action") or "")
if action == "start" and report in ("failed", "stoped", "stopped"):
state["error"] = _report_error(body, "Druckstart abgelehnt")
elif action == "stop" and report in ("failed",):
state["error"] = _report_error(body, "Druckabbruch abgelehnt")
# start: heating / leveling / printing / preheating = angenommen
# stop: stoped / stopped / done = angenommen
state["done"] = True
client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
protocol=mqtt.MQTTv311,
client_id="grotti-%s" % uuid.uuid4().hex[:10],
)
client.username_pw_set(cred["username"], cred["password"])
client.tls_set(cert_reqs=ssl.CERT_NONE)
client.tls_insecure_set(True)
client.on_connect = on_connect
client.on_message = on_message
try:
client.connect(host, 9883, keepalive=30)
except OSError as exc:
raise LanError("MQTT am Drucker nicht erreichbar (:9883).") from exc
deadline = time.time() + max(5, int(timeout))
try:
while not state["done"]:
if time.time() > deadline:
state["error"] = timeout_message
break
client.loop(timeout=0.25)
finally:
try:
client.disconnect()
except Exception:
pass
if state["error"]:
raise LanError(state["error"])
return state["report"]
def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=30):
"""MQTT-Start wie Rinkhals, mit task_settings.auto_leveling 1/0."""
name = (filename or "").replace("\\", "/").split("/")[-1]
if not name:
raise LanError("Dateiname fehlt.")
data = {
"filename": name,
"filepath": "/",
"taskid": str(random.randint(0, 1000000)),
"task_mode": 1,
"filetype": 1,
"task_settings": {
"auto_leveling": 1 if auto_leveling else 0,
"vibration_compensation": 0,
"flow_calibration": 0,
},
}
ams = normalize_ams(ams_settings)
if ams:
data["ams_settings"] = ams
request = {
"type": "print",
"action": "start",
"msgid": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000),
"data": data,
}
timeout_message = "Timeout beim MQTT-Druckstart."
try:
_mqtt_print(host, request, timeout=timeout, timeout_message=timeout_message)
except LanError as exc:
# Nach Bettnetz-Prüfung kam das Start-Report schon > 30 s zu spät, der Druck lief trotzdem.
if exc.message != timeout_message:
raise
for attempt in range(3):
project = (print_info(host) or {}).get("project") or {}
if str(project.get("task_id") or "") == data["taskid"]:
break
time.sleep(5)
else:
raise
return {"filename": name, "auto_leveling": bool(auto_leveling)}
def print_info(host, timeout=10):
"""MQTT info/query, liefert data aus info/report (project.task_id, state …)."""
cred = credentials(host)
model_id = cred["modelId"]
device_id = cred["deviceId"]
topic_query = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/info" % (model_id, device_id)
topic_report = "anycubic/anycubicCloud/v1/printer/public/%s/%s/info/report" % (
model_id, device_id,
)
payload = json.dumps({
"type": "info",
"action": "query",
"msgid": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000),
"data": None,
})
state = {"data": None}
def on_connect(client, userdata, flags, reason_code, properties=None):
if getattr(reason_code, "is_failure", False):
return
client.subscribe(topic_report)
client.publish(topic_query, payload=payload, qos=1)
def on_message(client, userdata, msg):
try:
body = json.loads(msg.payload.decode("utf-8", "replace"))
except (json.JSONDecodeError, UnicodeError):
return
if isinstance(body.get("data"), dict):
state["data"] = body["data"]
client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
protocol=mqtt.MQTTv311,
client_id="grotti-%s" % uuid.uuid4().hex[:10],
)
client.username_pw_set(cred["username"], cred["password"])
client.tls_set(cert_reqs=ssl.CERT_NONE)
client.tls_insecure_set(True)
client.on_connect = on_connect
client.on_message = on_message
try:
client.connect(host, 9883, keepalive=30)
except OSError:
return None
deadline = time.time() + max(3, int(timeout))
try:
while state["data"] is None and time.time() < deadline:
client.loop(timeout=0.25)
finally:
try:
client.disconnect()
except Exception:
pass
return state["data"]
def cancel_print(host, timeout=15):
"""MQTT-Stop wie Rinkhals Docs: action stop, taskid -1 (Anycubic-Task)."""
request = {
"type": "print",
"action": "stop",
"msgid": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000),
"data": {"taskid": "-1"},
}
report = _mqtt_print(
host,
request,
timeout=timeout,
timeout_message="Timeout beim MQTT-Druckabbruch.",
)
return {"mqtt": "stop", "report": report}
+257
View File
@@ -0,0 +1,257 @@
"""Sorten aus den Filamentprofilen und die Farbvorauswahl."""
MATERIALS = [
{
"name": "Sunlu PLA",
"brand": "Sunlu",
"material_type": "PLA",
"density": 1.24,
"volumetric": 12,
"volumetric_s1": 14,
"machines": "beide",
"price_per_kg": 0,
"note": "Holz und Marmor laufen über dieses Profil. Glow nur mit gehärteter Düse.",
},
{
"name": "Sunlu PLA Meta",
"brand": "Sunlu",
"material_type": "PLA",
"density": 1.21,
"volumetric": 14,
"volumetric_s1": 16,
"machines": "beide",
"price_per_kg": 0,
"note": "",
},
{
"name": "Sunlu PLA Matte",
"brand": "Sunlu",
"material_type": "PLA",
"density": 1.31,
"volumetric": 10,
"volumetric_s1": 10,
"machines": "beide",
"price_per_kg": 0,
"note": "",
},
{
"name": "Sunlu PLA Silk",
"brand": "Sunlu",
"material_type": "PLA",
"density": 1.24,
"volumetric": 8,
"volumetric_s1": 8,
"machines": "beide",
"price_per_kg": 0,
"note": "Langsamer, damit die Oberfläche glänzt.",
},
{
"name": "Sunlu PLA+",
"brand": "Sunlu",
"material_type": "PLA",
"density": 1.21,
"volumetric": 12,
"volumetric_s1": 13,
"machines": "beide",
"price_per_kg": 0,
"note": "",
},
{
"name": "Sunlu PETG",
"brand": "Sunlu",
"material_type": "PETG",
"density": 1.27,
"volumetric": 10,
"volumetric_s1": 12,
"machines": "beide",
"price_per_kg": 0,
"note": "",
},
{
"name": "Sunlu PETG Matte",
"brand": "Sunlu",
"material_type": "PETG",
"density": 1.27,
"volumetric": 9,
"volumetric_s1": 10,
"machines": "beide",
"price_per_kg": 0,
"note": "",
},
{
"name": "Sunlu TPU 95A",
"brand": "Sunlu",
"material_type": "TPU",
"density": 1.21,
"volumetric": 5,
"volumetric_s1": 5,
"machines": "beide",
"price_per_kg": 0,
"note": "",
},
{
"name": "NinjaFlex",
"brand": "NinjaTek",
"material_type": "TPU",
"density": 1.19,
"volumetric": 2,
"volumetric_s1": None,
"machines": "kobra_3",
"price_per_kg": 0,
"note": "Nur Kobra 3.",
},
{
"name": "Sunlu ASA",
"brand": "Sunlu",
"material_type": "ASA",
"density": 1.04,
"volumetric": 12,
"volumetric_s1": 12,
"machines": "kobra_s1",
"price_per_kg": 0,
"note": "Nur Kobra S1, Tür zu.",
},
{
"name": "Formfutura ApolloX",
"brand": "Formfutura",
"material_type": "ASA",
"density": 1.07,
"volumetric": 12,
"volumetric_s1": 12,
"machines": "kobra_s1",
"price_per_kg": 0,
"note": "Nur Kobra S1, Tür zu.",
},
{
"name": "Sunlu ABS",
"brand": "Sunlu",
"material_type": "ABS",
"density": 1.02,
"volumetric": 11,
"volumetric_s1": 11,
"machines": "kobra_s1",
"price_per_kg": 0,
"note": "Nur Kobra S1, Tür zu.",
},
{
"name": "Sunlu PA12-CF",
"brand": "Sunlu",
"material_type": "PA",
"density": 1.08,
"volumetric": 8,
"volumetric_s1": 8,
"machines": "kobra_s1",
"price_per_kg": 50,
"note": "Nur Kobra S1, gehärtete Düse. Der ACE trocknet das nicht, dafür den externen Trockner.",
},
{
"name": "eSUN PETG-CF",
"brand": "eSUN",
"material_type": "PETG",
"density": 1.26,
"volumetric": 8,
"volumetric_s1": 8,
"machines": "kobra_s1",
"price_per_kg": 0,
"note": "Nur Kobra S1, gehärtete Düse.",
},
]
SOLIDS = [
{"id": "schwarz", "name": "Schwarz", "hex": "#1c1c1c"},
{"id": "weiss", "name": "Weiß", "hex": "#f4f4f4"},
{"id": "grau", "name": "Grau", "hex": "#8a8f8c"},
{"id": "rot", "name": "Rot", "hex": "#c23b3b"},
{"id": "orange", "name": "Orange", "hex": "#e07a2f"},
{"id": "gelb", "name": "Gelb", "hex": "#e2c04a"},
{"id": "gruen", "name": "Grün", "hex": "#3d8f5a"},
{"id": "blau", "name": "Blau", "hex": "#3a6ea5"},
{"id": "hellblau", "name": "Hellblau", "hex": "#7eb6d9"},
{"id": "lila", "name": "Lila", "hex": "#7a5ea7"},
{"id": "pink", "name": "Pink", "hex": "#e7a0b4"},
{"id": "braun", "name": "Braun", "hex": "#8a5a3c"},
{"id": "beige", "name": "Beige", "hex": "#d7c4a3"},
{"id": "natur", "name": "Natur", "hex": "#e4d7c3"},
{"id": "gold", "name": "Gold", "hex": "#d4af37"},
{"id": "silber", "name": "Silber", "hex": "#c0c6cc"},
{"id": "transparent", "name": "Transparent", "hex": "#d5e4ea"},
{"id": "kohle", "name": "Kohlefaser", "hex": "#2a2a2a"},
{"id": "glow", "name": "Glow", "hex": "#b6f25c"},
]
MULTI = [
{
"id": "regenbogen",
"name": "Regenbogen",
"colors": [
{"name": "Rot", "hex": "#e23b3b"},
{"name": "Orange", "hex": "#e07a2f"},
{"name": "Gelb", "hex": "#e2c04a"},
{"name": "Grün", "hex": "#3d8f5a"},
{"name": "Blau", "hex": "#3a6ea5"},
{"name": "Lila", "hex": "#7a5ea7"},
],
},
{
"id": "holz",
"name": "Holz",
"colors": [
{"name": "Braun", "hex": "#8a5a3c"},
{"name": "Beige", "hex": "#d7c4a3"},
{"name": "Dunkelbraun", "hex": "#5c3a24"},
],
},
{
"id": "marmor",
"name": "Marmor",
"colors": [
{"name": "Weiß", "hex": "#f4f4f4"},
{"name": "Grau", "hex": "#8a8f8c"},
{"name": "Schwarz", "hex": "#1c1c1c"},
],
},
{
"id": "schwarz-weiss",
"name": "Schwarz / Weiß",
"colors": [
{"name": "Schwarz", "hex": "#1c1c1c"},
{"name": "Weiß", "hex": "#f4f4f4"},
],
},
{
"id": "rot-schwarz",
"name": "Rot / Schwarz",
"colors": [
{"name": "Rot", "hex": "#c23b3b"},
{"name": "Schwarz", "hex": "#1c1c1c"},
],
},
{
"id": "blau-weiss",
"name": "Blau / Weiß",
"colors": [
{"name": "Blau", "hex": "#3a6ea5"},
{"name": "Weiß", "hex": "#f4f4f4"},
],
},
{
"id": "gold-silber",
"name": "Gold / Silber",
"colors": [
{"name": "Gold", "hex": "#d4af37"},
{"name": "Silber", "hex": "#c0c6cc"},
],
},
]
MACHINE_LABELS = {
"beide": "Kobra 3 und S1",
"kobra_3": "nur Kobra 3",
"kobra_s1": "nur Kobra S1",
}
def color_catalog():
solids = [{"id": row["id"], "name": row["name"], "colors": [{"name": row["name"], "hex": row["hex"]}]} for row in SOLIDS]
multi = [{"id": row["id"], "name": row["name"], "colors": row["colors"]} for row in MULTI]
return {"solids": solids, "multi": multi}
+170
View File
@@ -0,0 +1,170 @@
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
CONFIG_PATH = ROOT / "config.json"
def _energy(raw):
energy = raw.get("energy") or {}
return {
"watts": float(energy.get("watts", 120)),
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
"dryer_watts": float(energy.get("dryer_watts", 0)),
"dry_hours": float(energy.get("dry_hours", 0)),
"waste_percent": float(energy.get("waste_percent", 0)),
"utilization": float(energy.get("utilization", 50)),
}
def _telegram(raw):
tg = raw.get("telegram") or {}
return {
"enabled": bool(tg.get("enabled")),
"bot_token": str(tg.get("bot_token") or ""),
"chat_id": str(tg.get("chat_id") or ""),
}
def _alerts(raw):
alerts = raw.get("alerts") or {}
return {
"fertig": bool(alerts.get("fertig", True)),
"fehler": bool(alerts.get("fehler", True)),
"offline": bool(alerts.get("offline", True)),
"queue": bool(alerts.get("queue", True)),
"ablauf": bool(alerts.get("ablauf", False)),
"start": bool(alerts.get("start", False)),
}
def normalize_fqdn(value):
"""Leer string oder Basis-URL ohne trailing slash; ohne Schema → https://."""
text = str(value or "").strip().rstrip("/")
if not text:
return ""
if "://" not in text:
text = "https://" + text
return text.rstrip("/")
def public_base(cfg, request_root=None):
"""Öffentliche Basis für Etiketten-QR: fqdn, sonst Request-Root."""
base = normalize_fqdn(cfg.get("fqdn") or "")
if base:
return base
return str(request_root or "").rstrip("/")
def load():
path = CONFIG_PATH
if not path.exists():
path.write_text((ROOT / "config.example.json").read_text(encoding="utf-8"), encoding="utf-8")
raw = json.loads(path.read_text(encoding="utf-8"))
host = (raw.get("host") or "127.0.0.1").strip()
token = raw.get("token") or ""
if host not in LOOPBACK and not token:
raise SystemExit("Ohne Token startet der Dienst nicht, wenn er nicht nur lokal lauscht.")
data_dir = Path(raw.get("data_dir") or "data")
if not data_dir.is_absolute():
data_dir = ROOT / data_dir
watch = (raw.get("watch_dir") or "").strip()
watch_dir = Path(watch) if watch else None
if watch_dir and not watch_dir.is_absolute():
watch_dir = (ROOT / watch_dir).resolve()
return {
"host": host,
"port": int(raw.get("port") or 8770),
"data_dir": data_dir,
"watch_dir": watch_dir,
"database_url": raw.get("database_url") or "postgresql://[email protected]:5432/grotti",
"token": token,
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
"printers_file": ROOT / "drucker.json",
"energy": _energy(raw),
"telegram": _telegram(raw),
"alerts": _alerts(raw),
"auto_queue": bool(raw.get("auto_queue", True)),
"_raw": raw,
"_path": path,
}
def mask_token(token):
token = token or ""
if len(token) <= 8:
return "••••" if token else ""
return token[:4] + "…" + token[-2:]
def settings_public(cfg):
tg = cfg.get("telegram") or {}
return {
"watch_dir": str(cfg.get("watch_dir") or ""),
"auto_queue": bool(cfg.get("auto_queue", True)),
"fqdn": str(cfg.get("fqdn") or ""),
"energy": dict(cfg.get("energy") or {}),
"telegram": {
"enabled": bool(tg.get("enabled")),
"bot_token": mask_token(tg.get("bot_token") or ""),
"bot_token_set": bool(tg.get("bot_token")),
"chat_id": str(tg.get("chat_id") or ""),
},
"alerts": dict(cfg.get("alerts") or {}),
}
def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None):
"""Schreibt energy/telegram/alerts/auto_queue/fqdn zurück nach config.json und aktualisiert cfg."""
path = cfg.get("_path") or CONFIG_PATH
raw = dict(cfg.get("_raw") or {})
if path.exists():
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
if auto_queue is not None:
raw["auto_queue"] = bool(auto_queue)
cfg["auto_queue"] = bool(auto_queue)
if fqdn is not None:
normalized = normalize_fqdn(fqdn)
raw["fqdn"] = normalized
cfg["fqdn"] = normalized
if energy is not None:
raw["energy"] = {
"watts": float(energy.get("watts", 120)),
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
"dryer_watts": float(energy.get("dryer_watts", 0)),
"dry_hours": float(energy.get("dry_hours", 0)),
"waste_percent": float(energy.get("waste_percent", 0)),
"utilization": float(energy.get("utilization", 50)),
}
cfg["energy"] = _energy(raw)
if telegram is not None:
current = _telegram(raw)
token = telegram.get("bot_token")
if token is None or token == "" or "…" in str(token) or str(token).startswith("••••"):
token = current["bot_token"]
raw["telegram"] = {
"enabled": bool(telegram.get("enabled", current["enabled"])),
"bot_token": str(token or ""),
"chat_id": str(telegram.get("chat_id", current["chat_id"]) or ""),
}
cfg["telegram"] = _telegram(raw)
if alerts is not None:
raw["alerts"] = {
"fertig": bool(alerts.get("fertig", True)),
"fehler": bool(alerts.get("fehler", True)),
"offline": bool(alerts.get("offline", True)),
"queue": bool(alerts.get("queue", True)),
"ablauf": bool(alerts.get("ablauf", False)),
"start": bool(alerts.get("start", False)),
}
cfg["alerts"] = _alerts(raw)
path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
cfg["_raw"] = raw
cfg["_path"] = path
return settings_public(cfg)
+42
View File
@@ -0,0 +1,42 @@
"""Dieselbe Rechnung wie in der Druckkalkulation."""
def calculate(job, material):
part_g = float(job.get("weight_g") or 0) * max(1, float(job.get("qty") or 1))
waste = float(job.get("waste_percent") or 0)
filament_g = part_g * (1 + waste / 100)
price = float(material.get("price_per_kg") or 0)
filament_cost = (filament_g / 1000) * price
util = min(100, max(1, float(job.get("utilization") or 1))) / 100
density = float(material.get("density") or 0) or 1
volumetric = float(material.get("volumetric") or 0)
cm3_per_hour = volumetric * 3.6 * util
hours = (filament_g / density) / cm3_per_hour if cm3_per_hour else 0
watts = float(job.get("watts") or 0)
euro_per_kwh = float(job.get("euro_per_kwh") or 0)
print_kwh = (watts / 1000) * hours
dry_kwh = (float(job.get("dryer_watts") or 0) / 1000) * float(job.get("dry_hours") or 0)
energy_cost = (print_kwh + dry_kwh) * euro_per_kwh
if job.get("price_mode") == "perKg":
revenue = (part_g / 1000) * float(job.get("price") or 0)
else:
revenue = float(job.get("price") or 0) * max(1, float(job.get("qty") or 1))
margin = revenue - filament_cost - energy_cost
per_hour = margin / hours if hours else 0
return {
"part_g": part_g,
"filament_g": filament_g,
"filament_cost": filament_cost,
"hours": hours,
"energy_cost": energy_cost,
"revenue": revenue,
"margin": margin,
"per_hour": per_hour,
"print_kwh": print_kwh,
"dry_kwh": dry_kwh,
}
def energy_cost(duration_s, energy):
hours = float(duration_s or 0) / 3600
return (float(energy["watts"]) / 1000) * hours * float(energy["euro_per_kwh"])
+62
View File
@@ -0,0 +1,62 @@
"""PostgreSQL-Verbindung und Schema."""
import re
from contextlib import contextmanager
from pathlib import Path
from urllib.parse import urlparse, urlunparse
import psycopg
from psycopg import sql
from psycopg.rows import dict_row
SCHEMA = (Path(__file__).parent / "schema.sql").read_text(encoding="utf-8")
def database_name(url):
name = urlparse(url).path.lstrip("/")
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name or ""):
raise SystemExit("Der Datenbankname in database_url darf nur Buchstaben, Zahlen und Unterstrich enthalten.")
return name
def apply_schema(conn):
for statement in SCHEMA.split(";"):
cleaned = statement.strip()
if cleaned:
conn.execute(cleaned)
def prepare(url):
name = database_name(url)
admin = urlunparse(urlparse(url)._replace(path="/postgres"))
try:
with psycopg.connect(admin, autocommit=True, connect_timeout=5) as conn:
exists = conn.execute("SELECT 1 FROM pg_database WHERE datname = %s", (name,)).fetchone()
if not exists:
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
except psycopg.Error:
pass
try:
with psycopg.connect(url, connect_timeout=5) as conn:
apply_schema(conn)
conn.commit()
except psycopg.Error as exc:
raise SystemExit(
"PostgreSQL ist nicht erreichbar.\n"
"In config.json bei database_url das Passwort eintragen, zum Beispiel\n"
"postgresql://postgres:[email protected]:5432/grotti\n"
"Die Datenbank grotti legt die App an, sobald die Anmeldung klappt.\n"
+ str(exc)
) from exc
@contextmanager
def connect(url):
conn = psycopg.connect(url, row_factory=dict_row)
try:
yield conn
except Exception:
conn.rollback()
raise
finally:
conn.close()
+671
View File
@@ -0,0 +1,671 @@
"""Abräumen auf der Kobra 3: Abkühlen und mit dem Extruder nach vorn schieben."""
from __future__ import annotations
from grotti.errors import GrottiError
from grotti.printers import family_from_name
# Grenzen gegen Tippfehler (Wischpad ~Y 260, Achse max 260 — Ansetzen knapper davor)
LIMITS = {
"x_min": 0.0,
"x_max": 250.0,
"y_min": 0.0,
"y_max": 250.0,
"z_push_min": 0.6,
"z_push_max": 30.0,
"z_travel_min": 1.0,
"z_travel_max": 80.0,
"speed_min": 5.0,
"speed_max": 80.0,
"bed_max_lo": 15.0,
"bed_max_hi": 80.0,
"nozzle_max_lo": 20.0,
"nozzle_max_hi": 120.0,
"fan_lo": 0,
"fan_hi": 100,
"timeout_lo": 1,
"timeout_hi": 120,
"max_delta_lo": 0.05,
"max_delta_hi": 5.0,
"check_timeout_lo": 1,
"check_timeout_hi": 15,
}
DEFAULT_COOL = {
"type": "cool",
"bed_max": 30.0,
"nozzle_max": 45.0,
"fan_percent": 100,
"timeout_min": 25,
}
DEFAULT_SWEEP = {
"type": "sweep",
"z_push": 2.0,
"z_travel": 20.0,
"x_from": 25.0,
"x_to": 225.0,
"x_step": 20.0,
# möglichst weit hinten, bevor Z sinkt — Present war 220, Teile oft davor
"y_from": 250.0,
"y_to": 5.0,
"speed": 25.0,
}
# Ein Punkt in der Bettmitte — kein LeviQ, kein 3×3, kein Heizen.
DEFAULT_CHECK = {
"type": "check",
# Result is z= gegen Grenze. Temporär 3 mm — kalter Kunststoff an der Düse
# tastet sonst oft 1–2 mm und meldet Restteil. Später wieder straffen.
"max_delta_mm": 3.0,
"timeout_min": 2,
"x": 125.0,
"y": 125.0,
"ref_x": 25.0,
"ref_y": 125.0,
"z_clear": 20.0,
}
def _num(value, default, lo, hi):
try:
n = float(value if value is not None else default)
except (TypeError, ValueError):
n = float(default)
return max(lo, min(hi, n))
def _int(value, default, lo, hi):
return int(round(_num(value, default, lo, hi)))
def require_kobra_3(printer):
family = (printer or {}).get("family") or family_from_name((printer or {}).get("name") or "")
if family != "kobra_3":
raise GrottiError("Abräumen gibt es nur auf der Kobra 3.", "familie")
return family
def normalize_cool(step=None):
raw = step if isinstance(step, dict) else {}
return {
"type": "cool",
"bed_max": _num(raw.get("bed_max"), DEFAULT_COOL["bed_max"], LIMITS["bed_max_lo"], LIMITS["bed_max_hi"]),
"nozzle_max": _num(
raw.get("nozzle_max"), DEFAULT_COOL["nozzle_max"], LIMITS["nozzle_max_lo"], LIMITS["nozzle_max_hi"]
),
"fan_percent": _int(raw.get("fan_percent"), DEFAULT_COOL["fan_percent"], LIMITS["fan_lo"], LIMITS["fan_hi"]),
"timeout_min": _int(
raw.get("timeout_min"), DEFAULT_COOL["timeout_min"], LIMITS["timeout_lo"], LIMITS["timeout_hi"]
),
}
def normalize_sweep(step=None):
raw = step if isinstance(step, dict) else {}
z_push = _num(raw.get("z_push"), DEFAULT_SWEEP["z_push"], LIMITS["z_push_min"], LIMITS["z_push_max"])
z_travel = _num(raw.get("z_travel"), DEFAULT_SWEEP["z_travel"], LIMITS["z_travel_min"], LIMITS["z_travel_max"])
if z_travel < z_push + 1.0:
z_travel = min(LIMITS["z_travel_max"], z_push + 1.0)
x_from = _num(raw.get("x_from"), DEFAULT_SWEEP["x_from"], LIMITS["x_min"], LIMITS["x_max"])
x_to = _num(raw.get("x_to"), DEFAULT_SWEEP["x_to"], LIMITS["x_min"], LIMITS["x_max"])
x_step = abs(_num(raw.get("x_step"), DEFAULT_SWEEP["x_step"], 1.0, 100.0))
y_from = _num(raw.get("y_from"), DEFAULT_SWEEP["y_from"], LIMITS["y_min"], LIMITS["y_max"])
y_to = _num(raw.get("y_to"), DEFAULT_SWEEP["y_to"], LIMITS["y_min"], LIMITS["y_max"])
if y_from <= y_to:
raise GrottiError("Schieben: Y hinten (y_from) muss größer sein als Y vorn (y_to).", "bahn")
return {
"type": "sweep",
"z_push": z_push,
"z_travel": z_travel,
"x_from": x_from,
"x_to": x_to,
"x_step": x_step,
"y_from": y_from,
"y_to": y_to,
"speed": _num(raw.get("speed"), DEFAULT_SWEEP["speed"], LIMITS["speed_min"], LIMITS["speed_max"]),
}
def normalize_print(step=None, fallback=None):
raw = step if isinstance(step, dict) else {}
fb = fallback if isinstance(fallback, dict) else {}
try:
file_id = int(raw.get("file_id") if raw.get("file_id") is not None else fb.get("file_id"))
except (TypeError, ValueError) as exc:
raise GrottiError("Druck-Schritt braucht eine Datei.", "datei") from exc
spool_raw = raw.get("spool_id") if "spool_id" in raw else fb.get("spool_id")
try:
spool_id = int(spool_raw) if spool_raw not in (None, "") else None
except (TypeError, ValueError):
spool_id = None
ace = raw.get("ace_map") if isinstance(raw.get("ace_map"), list) else fb.get("ace_map")
if not isinstance(ace, list):
ace = []
return {
"type": "print",
"file_id": file_id,
"spool_id": spool_id,
"ace_map": ace,
"bed_leveling": bool(raw.get("bed_leveling", fb.get("bed_leveling", False))),
"override_machine": bool(raw.get("override_machine", fb.get("override_machine", False))),
}
def normalize_check(step=None):
raw = step if isinstance(step, dict) else {}
# Alte enge Grenzen (1.0 / 2.5) → aktueller Default (Düsen-Klumpen).
raw_delta = raw.get("max_delta_mm")
try:
if raw_delta is not None and raw_delta != "":
d = float(raw_delta)
if abs(d - 2.5) < 1e-9 or abs(d - 1.0) < 1e-9:
raw_delta = DEFAULT_CHECK["max_delta_mm"]
except (TypeError, ValueError):
raw_delta = raw.get("max_delta_mm")
return {
"type": "check",
"max_delta_mm": _num(
raw_delta, DEFAULT_CHECK["max_delta_mm"], LIMITS["max_delta_lo"], LIMITS["max_delta_hi"]
),
"timeout_min": _int(
raw.get("timeout_min"),
DEFAULT_CHECK["timeout_min"],
LIMITS["check_timeout_lo"],
LIMITS["check_timeout_hi"],
),
"x": _num(raw.get("x"), DEFAULT_CHECK["x"], LIMITS["x_min"], LIMITS["x_max"]),
"y": _num(raw.get("y"), DEFAULT_CHECK["y"], LIMITS["y_min"], LIMITS["y_max"]),
"ref_x": _num(raw.get("ref_x"), DEFAULT_CHECK["ref_x"], LIMITS["x_min"], LIMITS["x_max"]),
"ref_y": _num(raw.get("ref_y"), DEFAULT_CHECK["ref_y"], LIMITS["y_min"], LIMITS["y_max"]),
"z_clear": _num(
raw.get("z_clear"), DEFAULT_CHECK["z_clear"], LIMITS["z_travel_min"], LIMITS["z_travel_max"]
),
}
def normalize_steps(steps, fallback=None):
if not isinstance(steps, list) or not steps:
raise GrottiError("Ablauf braucht mindestens einen Schritt.", "ablauf")
out = []
for raw in steps:
if not isinstance(raw, dict):
raise GrottiError("Ungültiger Ablauf-Schritt.", "ablauf")
kind = (raw.get("type") or "").strip().lower()
if kind == "print":
out.append(normalize_print(raw, fallback))
elif kind == "cool":
out.append(normalize_cool(raw))
elif kind == "sweep":
out.append(normalize_sweep(raw))
elif kind == "check":
out.append(normalize_check(raw))
else:
raise GrottiError("Unbekannter Schritt: %s" % kind, "ablauf")
if not any(step["type"] == "print" for step in out):
raise GrottiError("Ablauf braucht mindestens einen Druck-Schritt.", "ablauf")
return out
def first_print_file_id(steps):
for step in steps:
if step.get("type") == "print":
return step["file_id"]
return None
def x_positions(sweep):
start = sweep["x_from"]
end = sweep["x_to"]
step = sweep["x_step"]
if abs(end - start) < 0.01:
return [round(start, 2)]
direction = 1.0 if end >= start else -1.0
positions = []
x = start
# inklusive Endpunkt, ohne Endlosschleife
for _ in range(200):
positions.append(round(x, 2))
if direction > 0 and x >= end - 0.01:
break
if direction < 0 and x <= end + 0.01:
break
x += direction * step
if direction > 0 and x > end:
positions.append(round(end, 2))
break
if direction < 0 and x < end:
positions.append(round(end, 2))
break
# Duplikate am Ende entfernen
cleaned = []
for pos in positions:
if not cleaned or abs(cleaned[-1] - pos) > 0.01:
cleaned.append(pos)
return cleaned
# Abkühlen: Düse zur Bettmitte (Park während Abkühlung).
COOL_PARK_X = 125.0
COOL_PARK_Y = 125.0
# Slice-Ende hebt Z nicht — relativ weit hoch, gegen axis_maximum Z gekappt.
COOL_PARK_LIFT = 40.0
COOL_PARK_Z_MAX_DEFAULT = 250.0
COOL_PARK_TOLERANCE_MM = 20.0
COOL_PARK_Z_TOLERANCE_MM = 2.0
def cool_script(cool):
fan = max(0, min(100, int(cool["fan_percent"])))
pwm = int(round(fan * 255 / 100)) if fan > 0 else 0
lines = [
"SET_HEATER_TEMPERATURE HEATER=extruder TARGET=0",
"SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=0",
]
if pwm > 0:
lines.append("M106 S%d" % pwm)
else:
lines.append("M107")
return "\n".join(lines)
def cool_park_z_now(live):
pos = (live or {}).get("position") or []
if len(pos) >= 3 and isinstance(pos[2], (int, float)):
return float(pos[2])
return None
def cool_park_z_max(live):
z_max = (live or {}).get("z_max")
if isinstance(z_max, (int, float)) and float(z_max) > 10:
return float(z_max)
return float(COOL_PARK_Z_MAX_DEFAULT)
def cool_park_lift_mm(live):
"""Relativer Z-Hub, begrenzt auf verbleibenden Weg bis Z-max."""
z_now = cool_park_z_now(live)
z_max = cool_park_z_max(live)
want = float(COOL_PARK_LIFT)
if z_now is None:
return want
headroom = z_max - z_now
if headroom < 0.5:
return 0.0
return min(want, headroom)
def cool_park_script(live=None):
"""Relativ Z hoch (gegen Z-max gekappt), dann Bettmitte."""
lift = cool_park_lift_mm(live)
lines = ["M83", "G90"]
if lift >= 0.5:
lines.extend([
"G91",
"G0 Z%.2f F1800" % lift,
"G90",
])
lines.extend([
"G0 X%.2f Y%.2f F6000" % (COOL_PARK_X, COOL_PARK_Y),
"M400",
"G90",
])
return "\n".join(lines)
def cool_parked(live, before_z=None, lift_mm=None, tolerance_mm=COOL_PARK_TOLERANCE_MM):
"""True wenn Mitte und geplanter Z-Hub angekommen ist (before_z/lift_mm nötig)."""
pos = (live or {}).get("position") or []
if len(pos) < 3 or not all(isinstance(v, (int, float)) for v in pos[:3]):
return False
dx = float(pos[0]) - COOL_PARK_X
dy = float(pos[1]) - COOL_PARK_Y
if (dx * dx + dy * dy) ** 0.5 > float(tolerance_mm):
return False
if before_z is None or lift_mm is None:
return False
if float(lift_mm) < 0.5:
return True
need = float(before_z) + float(lift_mm) - float(COOL_PARK_Z_TOLERANCE_MM)
return float(pos[2]) >= need
def idle_timeout_script(cool=None):
"""Leerlauf-Timeout verlängern — Stepper bleiben gehomed.
Ohne cool-Schritt: 1 h (Ablauf-Druck). Mit cool: Cool-Zeit + 10 min.
"""
if cool and isinstance(cool, dict) and cool.get("timeout_min") is not None:
seconds = max(900, int(cool["timeout_min"]) * 60 + 600)
else:
seconds = 3600
return "SET_IDLE_TIMEOUT TIMEOUT=%d" % seconds
def strip_end_motor_disable(content):
"""M84/M18 aus G-code entfernen — Slice-Ende schaltet sonst die Stepper aus.
Anycubic Present-Ende: `M84; disable motors` nach X250 Y220. Danach unhomed,
Abkühlen/Schieben scheitern. Kommentarzeilen bleiben.
"""
if content is None:
return content
if isinstance(content, bytes):
text = content.decode("utf-8", errors="replace")
as_bytes = True
else:
text = str(content)
as_bytes = False
out = []
for line in text.splitlines(keepends=True):
body = line.split(";", 1)[0].strip().upper()
if body == "M84" or body.startswith("M84 ") or body == "M18" or body.startswith("M18 "):
continue
out.append(line)
text = "".join(out)
return text.encode("utf-8") if as_bytes else text
def restore_position_script(live, sweep, allow_low_z=False):
"""Nach M84/Idle: letzte Toolhead-XYZ übernehmen (keine Bewegung).
Nur echte Moonraker-Position — keine erfundene Z. Nach Slice-M84 steht der Kopf
noch am Present; SET_KINEMATIC mit genau dieser XYZ ist korrekt. Zu niedrige Z → Abbruch.
"""
_ = allow_low_z
pos = (live or {}).get("position") or []
if len(pos) < 3 or not all(isinstance(v, (int, float)) for v in pos[:3]):
raise GrottiError(
"Achsen nicht gehomed und letzte Position unbekannt. "
"Bei leerem Bett in Mainsail G28, dann Ablauf neu.",
"homed",
)
x, y, z = (float(v) for v in pos[:3])
min_z = float((sweep or {}).get("z_push") or 2) + 1
if z < min_z:
raise GrottiError(
"Achsen nicht gehomed und Düse zu tief gemeldet (Z %.1f mm) — unsicher. "
"Bei leerem Bett in Mainsail G28, dann Ablauf neu." % z,
"homed",
)
return "SET_KINEMATIC_POSITION X=%.3f Y=%.3f Z=%.3f" % (x, y, z), z
def cool_ready(live, cool):
bed = live.get("bed")
nozzle = live.get("nozzle")
if not isinstance(bed, (int, float)) or not isinstance(nozzle, (int, float)):
return False
return float(bed) <= float(cool["bed_max"]) and float(nozzle) <= float(cool["nozzle_max"])
def explain_printer_error(message):
"""Moonraker/Klipper-Text → kurze Grotti-Meldung."""
text = (message or "").strip()
low = text.lower()
if "must home axis first" in low or "must home" in low:
return (
"Achsen sind nicht gehomed. Gleich nach dem Druck abräumen "
"(bevor die Stepper ausgehen), oder in Mainsail homen — "
"Z nur bei leerem Bett."
)
if "is not homed" in low:
return (
"Achsen sind nicht gehomed. In Mainsail homen "
"(Z nur bei leerem Bett), dann nochmal abräumen."
)
if "no trigger on probe" in low:
return (
"Taste hat nicht ausgelöst (Wägezelle, voller Hub ohne Kontakt). "
"Bei leerem Bett meist falsche Z nach Leerlauf (zu wenig Hub) — "
"nicht Restteil. Düse auf Klumpen prüfen, dann Tasten erneut; "
"Grotti setzt vor dem Tasten ein Z-Budget."
)
return text or "Der Drucker hat den Befehl abgelehnt."
# Senken auf z_push: nie F3000 — bei falscher Z-Null war das Vollgas ins Bett.
SWEEP_FEED_DOWN = 400 # mm/min ≈ 6,7 mm/s
def sweep_go_back_high_script(sweep):
"""Nur hoch + nach hinten (y_from) — noch nicht senken."""
travel = sweep["z_travel"]
mid_x = (sweep["x_from"] + sweep["x_to"]) / 2.0
feed_travel = max(3000, int(round(float(sweep["speed"]) * 60)))
return "\n".join([
"G90",
"G91",
"G0 Z%.2f F1800" % max(20.0, float(travel)),
"G90",
"G0 X%.2f Y%.2f F%d" % (mid_x, sweep["y_from"], feed_travel),
"M400",
])
def sweep_lower_to_push_script(sweep):
"""Langsam auf z_push — kein F3000-Sturz."""
push = sweep["z_push"]
return "\n".join([
"G90",
"G0 Z%.2f F%d" % (push, SWEEP_FEED_DOWN),
"M400",
])
def sweep_probe_height_script(sweep):
"""Kompatibel: hoch, hinten, langsam auf z_push."""
return sweep_go_back_high_script(sweep) + "\n" + sweep_lower_to_push_script(sweep)
def assert_clear_before_lower(live, sweep, min_z_mm=15.0):
"""Vor dem Senken: Toolhead muss noch klar über dem Bett stehen."""
pos = (live or {}).get("position") or []
if len(pos) < 3 or not isinstance(pos[2], (int, float)):
raise GrottiError("Schieben: Z-Position unbekannt — kein Senken.", "bahn")
z = float(pos[2])
need = max(float(min_z_mm), float(sweep["z_push"]) + 8.0)
if z < need:
raise GrottiError(
"Schieben gestoppt: nach hinten nur Z %.1f mm (braucht ≥ %.0f mm). "
"Senken würde ins Bett/Teil rauschen — Z-Null prüfen, leeres Bett: G28."
% (z, need),
"bahn",
)
def assert_at_push_height(live, sweep, tol_mm=1.5):
"""Wenn Z nicht bei z_push liegt, würde die Bahn das Teil nur überfliegen."""
pos = (live or {}).get("position") or []
if len(pos) < 3 or not isinstance(pos[2], (int, float)):
raise GrottiError("Schieben: Z-Position unbekannt — Abbruch.", "bahn")
z = float(pos[2])
want = float(sweep["z_push"])
if abs(z - want) > float(tol_mm):
raise GrottiError(
"Schieben gestoppt: Düse bei Z %.1f mm, erwartet %.1f mm. "
"Mit falscher Z-Höhe fährt die Bahn über das Teil (ohne Schrittverlust). "
"Z-Koordinate und reale Höhe passen oft nicht — "
"bei leerem Bett in Mainsail G28, dann neu."
% (z, want),
"bahn",
)
def sweep_script(sweep):
"""Absolute Bahn: anheben, nach hinten, langsam senken, nach vorn — nur Config-Spuren."""
travel = sweep["z_travel"]
push = sweep["z_push"]
y_from = sweep["y_from"]
y_to = sweep["y_to"]
# mm/s → mm/min für G0 F
feed = int(round(float(sweep["speed"]) * 60))
feed_travel = max(feed, 3000)
mid_x = (sweep["x_from"] + sweep["x_to"]) / 2.0
lines = [
"G90",
"G91",
"G0 Z%.2f F1800" % max(20.0, float(travel)),
"G90",
"G0 X%.2f Y%.2f F%d" % (mid_x, y_from, feed_travel),
]
for x in x_positions(sweep):
lines.append("G0 X%.2f Y%.2f F%d" % (x, y_from, feed_travel))
lines.append("G0 Z%.2f F%d" % (push, SWEEP_FEED_DOWN))
lines.append("G0 Y%.2f F%d" % (y_to, feed))
lines.append("G0 Z%.2f F%d" % (travel, feed_travel))
# hinten oben parken, weg von der Kiste
lines.append("G0 X%.2f Y%.2f Z%.2f F%d" % (mid_x, y_from, travel, feed_travel))
lines.append("M107")
lines.append("M400")
return "\n".join(lines)
def check_script(check, current_z=None):
"""Restteil-Tasten ohne G28 Z — mit aktivem Bettnetz (LeviQ).
Absolut auf Freiraum (z_clear bzw. aktuelle Z), Tastpunkt, ein PROBE.
Result is z= = Höhe in der echten Koordinate. Kein SET Z=100 / Bezug-Null —
das hat die Koordinate nur kaputt gemacht.
"""
check = normalize_check(check)
clear = max(25.0, float(check["z_clear"]))
z_travel = clear
if isinstance(current_z, (int, float)) and float(current_z) > clear:
z_travel = float(current_z)
return "\n".join([
"G90",
"G0 Z%.2f F1800" % z_travel,
"G0 X%.2f Y%.2f F6000" % (check["x"], check["y"]),
"PROBE",
"M400",
"G90",
])
def check_z_from_live(live):
"""Nur Notfall: probe.last_z_result. Toolhead-Z nicht nutzen (Retract +2 mm)."""
probe_z = (live or {}).get("probe_z")
if isinstance(probe_z, (int, float)):
return float(probe_z), "probe"
return None, ""
def check_lift_script(check):
"""Nach dem Tasten absolut hoch — Düse nicht auf dem Bett lassen."""
check = normalize_check(check)
return "\n".join([
"G90",
"G0 Z%.2f F1800" % max(25.0, float(check["z_clear"])),
"M400",
])
def _axis_coords(lo, hi, count):
if count <= 1:
return [(lo + hi) / 2.0]
return [lo + (hi - lo) * i / (count - 1) for i in range(count)]
def _matrix_ok(matrix):
if not isinstance(matrix, list) or not matrix:
return False
width = None
for row in matrix:
if not isinstance(row, list) or not row:
return False
if width is None:
width = len(row)
if len(row) != width:
return False
for value in row:
if not isinstance(value, (int, float)):
return False
return True
def interpolate_mesh_z(mesh, x, y):
"""Bilinear aus Roh-probed_matrix. Klipper: Zeile 0 = mesh_min Y."""
matrix = mesh.get("matrix") if isinstance(mesh, dict) else None
if not _matrix_ok(matrix):
return None
mesh_min = mesh.get("mesh_min") or [0, 0]
mesh_max = mesh.get("mesh_max") or [0, 0]
try:
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
except (TypeError, ValueError, IndexError):
return None
rows = len(matrix)
cols = len(matrix[0])
if cols < 1 or rows < 1:
return None
if abs(x1 - x0) < 1e-9 or abs(y1 - y0) < 1e-9:
return float(matrix[0][0])
tx = 0.0 if cols == 1 else (float(x) - x0) / (x1 - x0) * (cols - 1)
ty = 0.0 if rows == 1 else (float(y) - y0) / (y1 - y0) * (rows - 1)
tx = max(0.0, min(float(cols - 1), tx))
ty = max(0.0, min(float(rows - 1), ty))
c0 = int(tx)
r0 = int(ty)
c1 = min(cols - 1, c0 + 1)
r1 = min(rows - 1, r0 + 1)
fx = tx - c0
fy = ty - r0
z00 = float(matrix[r0][c0])
z10 = float(matrix[r0][c1])
z01 = float(matrix[r1][c0])
z11 = float(matrix[r1][c1])
z0 = z00 * (1 - fx) + z10 * fx
z1 = z01 * (1 - fx) + z11 * fx
return z0 * (1 - fy) + z1 * fy
def mesh_delta_mm(baseline, measured):
"""Max |Δ| und Mittel |Δ|: Messpunkte gegen interpoliertes Baseline-Netz."""
if not isinstance(baseline, dict) or not _matrix_ok(baseline.get("matrix")):
raise GrottiError("Kein Bettnetz zum Vergleich — vorher nivellieren.", "mesh")
if not isinstance(measured, dict) or not _matrix_ok(measured.get("matrix")):
raise GrottiError("Neues Bettnetz fehlt nach dem Antasten.", "mesh")
matrix = measured["matrix"]
mesh_min = measured.get("mesh_min") or [0, 0]
mesh_max = measured.get("mesh_max") or [0, 0]
try:
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
except (TypeError, ValueError, IndexError) as exc:
raise GrottiError("Messnetz ohne gültigen Bereich.", "mesh") from exc
rows = len(matrix)
cols = len(matrix[0])
xs = _axis_coords(x0, x1, cols)
ys = _axis_coords(y0, y1, rows)
deltas = []
for ri, y in enumerate(ys):
for ci, x in enumerate(xs):
expected = interpolate_mesh_z(baseline, x, y)
if expected is None:
raise GrottiError("Baseline-Netz lässt sich nicht interpolieren.", "mesh")
deltas.append(abs(float(matrix[ri][ci]) - expected))
if not deltas:
raise GrottiError("Keine Messpunkte zum Vergleich.", "mesh")
return {
"max_delta_mm": max(deltas),
"mean_delta_mm": sum(deltas) / len(deltas),
"points": len(deltas),
}
def step_label(step):
if not isinstance(step, dict):
return ""
kind = step.get("type")
if kind == "print":
return "Drucken"
if kind == "cool":
return "Abkühlen"
if kind == "sweep":
return "Schieben"
if kind == "check":
return "Tasten"
return kind or ""
+6
View File
@@ -0,0 +1,6 @@
class GrottiError(Exception):
def __init__(self, message, code="fehler", status=400):
super().__init__(message)
self.message = message
self.code = code
self.status = status
+503
View File
@@ -0,0 +1,503 @@
"""Senden, Steuern und das Druckprotokoll."""
import json
import urllib.error
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from grotti import acm, eject, library, moonraker, store
from grotti.cost import energy_cost
from grotti.errors import GrottiError
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
from grotti.printers import FAMILY_LABELS
TERMINAL = {"completed", "cancelled", "error", "interrupted"}
def weight_of(metadata):
raw = (metadata or {}).get("filament_weight_total")
if isinstance(raw, list):
return sum(float(item or 0) for item in raw)
if isinstance(raw, (int, float)):
return float(raw)
if isinstance(raw, str):
try:
return float(raw.replace(",", "."))
except ValueError:
return 0.0
return 0.0
def material_of(metadata):
raw = (metadata or {}).get("filament_type") or ""
if isinstance(raw, list):
raw = ";".join(str(item) for item in raw if item)
return str(raw).split(";")[0].strip()
def unix_to_dt(value):
if not value:
return None
return datetime.fromtimestamp(float(value), timezone.utc)
def choose_spool(conn, host, material, hinted_id):
if hinted_id:
row = store.get_spool_row(conn, hinted_id)
if row:
return row
rows = store.spools_for_printer(conn, host)
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
def file_material_types(file_row):
types = []
for item in (file_row or {}).get("filaments") or []:
kind = (item.get("type") or "").strip()
if kind and kind not in types:
types.append(kind)
return types
def resolve_spools_at_host(conn, host, ace_map=None):
"""Spulen am Drucker zur Startzeit: ACE-Plätze aus Map, sonst externe Rolle."""
mapping = acm.normalize_mapping(ace_map)
found = []
if mapping:
for item in mapping:
slot = int(item["ams_index"]) + 1
row = conn.execute(
"""
SELECT * FROM spools
WHERE printer_host = %s AND ace_slot = %s
ORDER BY id LIMIT 1
""",
(host, slot),
).fetchone()
if row:
found.append(row)
return found
# Externe Spule: höchstens eine ohne ACE-Platz am Host.
rows = conn.execute(
"""
SELECT * FROM spools
WHERE printer_host = %s AND ace_slot IS NULL
ORDER BY id
""",
(host,),
).fetchall()
return list(rows)
def assert_material_match(file_row, spools):
"""Datei-Material gegen eingelegte Rollen — warnt nicht bei leerer Liste."""
want = {t.lower() for t in file_material_types(file_row)}
if not want or not spools:
return
have = {(row.get("material_type") or "").strip().lower() for row in spools}
have.discard("")
if not have:
return
if want.isdisjoint(have):
raise GrottiError(
"Material passt nicht: Datei braucht %s, im Drucker steckt %s."
% (", ".join(sorted(want)), ", ".join(sorted(have)) or "unbekannt"),
"material",
409,
)
def assert_spool_at_host(conn, spool_id, host):
if not spool_id or not host:
return
spool = store.get_spool_row(conn, spool_id)
if not spool:
return
if (spool.get("printer_host") or "") != host:
raise GrottiError(
"Spule „%s“ steckt nicht in diesem Drucker (Ort: %s)."
% (spool["name"], spool.get("printer_host") or "Regal"),
"spule",
409,
)
def apply_deduction(conn, run, energy):
if run["deducted"] or run["weight_g"] <= 0 or run["state"] not in TERMINAL:
return False
weight = float(run["weight_g"] or 0)
# ACE: Abzug anteilig auf die Plätze der letzten Sendung
sent = store.latest_send(conn, run["printer_host"], run.get("filename") or "")
ace = []
if sent:
raw = sent.get("ace_map")
if isinstance(raw, list):
ace = raw
elif isinstance(raw, str):
try:
ace = json.loads(raw)
except json.JSONDecodeError:
ace = []
targets = []
if ace:
for item in acm.normalize_mapping(ace):
slot = int(item["ams_index"]) + 1
row = conn.execute(
"""
SELECT * FROM spools
WHERE printer_host = %s AND ace_slot = %s
ORDER BY id LIMIT 1
""",
(run["printer_host"], slot),
).fetchone()
if row:
targets.append(row)
if not targets and run.get("spool_id"):
spool = store.get_spool_row(conn, run["spool_id"])
if spool:
targets = [spool]
if not targets:
return False
share = weight / len(targets)
filament = sum((share / 1000) * float(spool["price_per_kg"] or 0) for spool in targets)
power = energy_cost(run["duration_s"], energy)
marked = store.mark_deducted(conn, run["id"], targets[0]["id"], filament, power)
if not marked:
return False
for spool in targets:
store.deduct_spool(conn, spool["id"], share)
return True
def sync_history(conn, cfg):
printers = store.list_printers(conn)
def fetch(printer):
return printer, moonraker.history(printer["host"])
with ThreadPoolExecutor(max_workers=max(1, len(printers))) as pool:
fetched = list(pool.map(fetch, printers))
collected = []
for printer, items in fetched:
for job in items:
collected.append((printer, job))
deducted = 0
for printer, job in collected:
job_id = str(job.get("job_id") or "")
if not job_id:
continue
filename = job.get("filename") or ""
metadata = job.get("metadata") or {}
weight = weight_of(metadata)
material = material_of(metadata)
state = job.get("status") or ""
sent = store.latest_send(conn, printer["host"], filename)
known = store.file_by_name(conn, filename)
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
project_id = (sent or {}).get("project_id") or (known or {}).get("project_id")
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
spool = choose_spool(conn, printer["host"], material, hinted)
terminal = state in TERMINAL
row = store.upsert_run(conn, {
"printer_host": printer["host"],
"printer_name": printer["name"],
"filename": filename,
"file_id": file_id,
"project_id": project_id,
"spool_id": spool["id"] if spool else None,
"state": state,
"started_at": unix_to_dt(job.get("start_time")),
"ended_at": unix_to_dt(job.get("end_time")),
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0),
"weight_g": weight,
"energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]),
"moonraker_job_id": job_id,
"needs_spool": bool(terminal and weight > 0 and not spool),
"material_type": material,
})
if apply_deduction(conn, row, cfg["energy"]):
deducted += 1
return {"gelesen": len(collected), "abgezogen": deducted}
def assert_filament_enough(conn, file_row, spool_id=None, ace_map=None, host=""):
"""Dateien-Gewicht gegen Spule oder ACE-Plätze prüfen."""
need = float((file_row or {}).get("weight_g") or 0)
if need <= 0:
return
mapping = acm.normalize_mapping(ace_map)
if mapping and host:
leftovers = []
for item in mapping:
slot = int(item["ams_index"]) + 1
row = conn.execute(
"""
SELECT remaining_g, name FROM spools
WHERE printer_host = %s AND ace_slot = %s
ORDER BY id LIMIT 1
""",
(host, slot),
).fetchone()
if row:
leftovers.append(float(row["remaining_g"] or 0))
if leftovers and sum(leftovers) + 0.5 < need:
raise GrottiError(
"ACE-Plätze haben nur %.0f g, Datei braucht %.0f g."
% (sum(leftovers), need),
"spule",
)
return
if not spool_id:
return
spool = store.get_spool_row(conn, spool_id)
if not spool:
return
left = float(spool["remaining_g"] or 0)
if left + 0.5 < need:
raise GrottiError(
"Spule „%s“ hat nur %.0f g, Datei braucht %.0f g."
% (spool["name"], left, need),
"spule",
)
def prepare_send(conn, file_id, host, confirm, override, spool_id, ace_map=None):
if not confirm:
raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung")
printer = store.printer_by_host(conn, host)
if not printer:
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
row = store.get_file_row(conn, file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
file_family = row["printer_family"]
if file_family and printer["family"] and file_family != printer["family"] and not override:
raise GrottiError(
"Die Datei ist für %s geschnitten, der Drucker ist eine %s."
% (FAMILY_LABELS.get(file_family, file_family), printer["family_label"] or "andere Maschine"),
"maschine",
409,
)
# Spule ist Ort des Druckers — Auflösung zur Startzeit, optionaler Hinweis nur wenn
# die gewählte Spule wirklich an diesem Host steckt.
if spool_id:
assert_spool_at_host(conn, spool_id, host)
at_host = resolve_spools_at_host(conn, host, ace_map)
assert_material_match(row, at_host)
if spool_id and store.get_spool_row(conn, spool_id):
chosen = spool_id
elif len(at_host) == 1:
chosen = at_host[0]["id"]
elif at_host:
chosen = at_host[0]["id"]
else:
chosen = None
assert_filament_enough(conn, row, chosen, ace_map, host)
return printer, row, chosen
def send_file(
conn,
cfg,
file_id,
host,
confirm,
override,
spool_id,
on_progress=None,
prepared=False,
ace_map=None,
bed_leveling=False,
keep_motors=False,
):
def progress(step, percent, message):
if on_progress:
on_progress(step, percent, message)
if prepared:
printer = store.printer_by_host(conn, host)
row = store.get_file_row(conn, file_id)
if not printer or not row:
raise GrottiError("Die Datei oder der Drucker fehlt.", "datei", 404)
chosen = spool_id
if chosen and not store.get_spool_row(conn, chosen):
chosen = None
else:
printer, row, chosen = prepare_send(
conn, file_id, host, confirm, override, spool_id, ace_map=ace_map
)
if prepared:
assert_filament_enough(conn, row, chosen, ace_map, host)
progress("pruefen", 15, "Drucker prüfen…")
try:
live = moonraker.query(host)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as exc:
raise GrottiError("Der Drucker ist nicht erreichbar.", "offline") from exc
if live.get("state") in ("printing", "paused"):
raise GrottiError("Der Drucker druckt schon.", "belegt", 409)
state = (live.get("state") or "").lower()
if state == "error":
# Hängender Status nach fehlgeschlagenem Befehl — Start-G-code homed selbst,
# aber print_stats=error blockiert. Kurz zurücksetzen, dann weiter.
try:
moonraker.clear_print_error(host)
live = moonraker.query(host)
state = (live.get("state") or "").lower()
except Exception:
pass
if state == "error":
raise GrottiError(
"Drucker im Fehlerzustand — auf der Wand „Fehler löschen“, dann nochmal starten.",
"fehler",
409,
)
# Kein Homing-Zwang vor Start: Idle löscht homed_axes; der Slice-Start macht G28.
slots = live.get("slots") or moonraker.slots(host)
mapping = acm.normalize_mapping(ace_map)
filaments = list(row.get("filaments") or [])
if slots and not mapping:
raise GrottiError(
"Dieser Drucker hat ACE/MMU-Plätze. Bitte Farbe der Datei einem ACE-Platz zuordnen.",
"ace",
)
if slots and mapping and not filaments:
filaments = [{"type": "PLA", "color": ""}]
progress("vorbereiten", 30, "Datei vorbereiten…")
filename, content, plate = library.printer_file(row)
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"):
content = prepare_rinkhals_gcode(content, tools=ace_tools)
if keep_motors:
# Slice-Ende `M84` → sofort unhomed; Abräumen braucht die Stepper.
content = eject.strip_end_motor_disable(content)
size_mb = len(content) / (1024 * 1024)
def upload_progress(sent, total):
if total <= 0:
return
frac = min(1.0, sent / total)
pct = 45 + int(frac * 19)
if sent >= total:
progress(
"hochladen",
pct,
"%s liest die Datei…" % printer["name"],
)
return
progress(
"hochladen",
pct,
"Hochladen zu %s… %.0f/%.0f MB" % (
printer["name"],
sent / (1024 * 1024),
total / (1024 * 1024),
),
)
progress(
"hochladen",
45,
"Hochladen zu %s… 0/%.0f MB" % (printer["name"], size_mb),
)
ace_payload = None
warnings = []
has_trailer = content_has_ace_trailer(content, kind=row.get("kind") or "", plate=plate)
if slots and mapping and not has_trailer:
# Nach prepare_rinkhals_gcode sollte der Trailer da sein; sonst Warnung.
warnings.append(acm.TRAILER_WARN)
progress("ace", 42, acm.TRAILER_WARN)
try:
stored = moonraker.upload(host, filename, content, on_progress=upload_progress)
if slots and mapping:
ace_payload = acm.build_acm(filaments, mapping, slots)
if ace_payload:
progress("ace", 65, "ACE-Zuordnung hochladen…")
body = acm.acm_bytes(ace_payload)
for acm_name in acm.acm_upload_names(stored):
moonraker.upload(host, acm_name, body)
progress("starten", 80, "Druck starten…")
moonraker.start_print(
host,
stored,
bed_leveling=bool(bed_leveling),
ams_settings=ace_payload,
)
except moonraker.PrinterError as exc:
try:
moonraker.heaters_off(host)
except Exception:
pass
raise GrottiError(exc.message, "drucker") from exc
progress("buchen", 95, "Auftrag speichern…")
store.add_send(
conn,
host,
stored,
row["id"],
row["project_id"],
chosen,
ace_map=mapping,
bed_leveling=bool(bed_leveling),
)
# needs_clear erst bei Druckende (Watcher) — sonst kurz „Platte belegt“ vorm Start
note = "Gestartet auf %s: %s" % (printer["name"], stored)
if bed_leveling:
note += " (Bettnetz vor dem Druck)"
if warnings:
note += " · " + " · ".join(warnings)
progress("fertig", 100, note)
return {
"printer": printer["name"],
"filename": stored,
"plate": plate,
"ace": bool(slots and mapping),
"ace_trailer": has_trailer,
"warnings": warnings,
"bed_leveling": bool(bed_leveling),
"bed_leveling_wired": True,
}
def control(conn, host, action):
printer = store.printer_by_host(conn, host)
if not printer:
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
try:
moonraker.command(host, action)
except moonraker.PrinterError as exc:
raise GrottiError(exc.message, "drucker") from exc
return {"printer": printer["name"], "action": action}
def steuern(conn, host, action, params=None):
printer = store.printer_by_host(conn, host)
if not printer:
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
try:
moonraker.steuern(host, action, params)
except moonraker.PrinterError as exc:
raise GrottiError(exc.message, "drucker") from exc
return {"printer": printer["name"], "action": action}
def assign_and_deduct(conn, cfg, run_id, spool_id):
spool = store.get_spool_row(conn, spool_id)
if not spool:
raise GrottiError("Die Spule fehlt.", "spule", 404)
run = store.assign_run_spool(conn, run_id, spool_id)
if not run:
raise GrottiError("Der Lauf fehlt.", "lauf", 404)
apply_deduction(conn, run, cfg["energy"])
fresh = store.get_run_row(conn, run_id)
return store.run_out(fresh)
+181
View File
@@ -0,0 +1,181 @@
"""Watch-Ordner, Upload und Vorschaubild."""
import time
import zipfile
from pathlib import Path
from grotti import store
from grotti.errors import GrottiError
from grotti.parse import embedded_png, inspect_file, safe_zip_name
_LAST_WATCH_SYNC = 0.0
_WATCH_MIN_INTERVAL_S = 20
def skip_path(path, data_dir):
if any(part.startswith(".") or part == "__pycache__" for part in path.parts):
return True
resolved = path.resolve()
data = data_dir.resolve()
return resolved == data or data in resolved.parents
def meta_for(path):
parsed = inspect_file(path)
stat = path.stat()
parsed.update({
"name": path.name,
"stored_path": str(path.resolve()),
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
})
return parsed
def sync_watch(conn, cfg, force=False):
global _LAST_WATCH_SYNC
folder = cfg["watch_dir"]
if folder is None:
return {"gelesen": 0, "hinweis": "In config.json watch_dir auf den Slice-Ordner setzen."}
if not folder.exists():
return {"gelesen": 0, "hinweis": "Der Watch-Ordner fehlt: %s" % folder}
now = time.monotonic()
if not force and _LAST_WATCH_SYNC and now - _LAST_WATCH_SYNC < _WATCH_MIN_INTERVAL_S:
return {"gelesen": 0, "hinweis": "", "skipped": True}
seen = []
for path in folder.rglob("*"):
if not path.is_file() or skip_path(path, cfg["data_dir"]):
continue
lower = path.name.lower()
if not (lower.endswith(".gcode") or lower.endswith(".3mf")):
continue
try:
store.upsert_scanned(conn, meta_for(path), force=force)
seen.append(str(path.resolve()))
except (OSError, zipfile.BadZipFile):
continue
store.forget_missing_watch(conn, seen)
_LAST_WATCH_SYNC = now
return {"gelesen": len(seen), "hinweis": ""}
def refresh_file_meta(conn, force=False):
"""Metadaten bestehender Dateien neu lesen (z. B. paint_info → filaments)."""
if not force:
return 0
# list_files/file_out ohne stored_path — Rohzeilen nutzen.
updated = 0
for row in store.list_file_rows(conn):
stored = row.get("stored_path") or ""
if not stored:
continue
path = Path(stored)
if not path.is_file():
continue
try:
meta = meta_for(path)
except (OSError, zipfile.BadZipFile):
continue
store.update_file(
conn,
row["id"],
{
"printer_family": meta.get("printer_family") or row.get("printer_family"),
"filaments": meta.get("filaments") or [],
"weight_g": meta.get("weight_g") or 0,
"estimated_s": meta.get("estimated_s") or 0,
"thumb_entry": meta.get("thumb_entry") or "",
"kind": meta.get("kind") or row.get("kind"),
"plate": meta.get("plate") or "",
"slice_meta": meta.get("slice_meta") or {},
},
)
updated += 1
return updated
def safe_filename(name):
base = Path(name).name.replace("\\", "_").replace("/", "_").replace('"', "")
return base or "datei.gcode"
def unique_path(directory, filename):
filename = safe_filename(filename)
if filename.lower().endswith(".gcode.3mf"):
stem, suffix = filename[: -len(".gcode.3mf")], ".gcode.3mf"
else:
stem, suffix = Path(filename).stem, Path(filename).suffix
candidate = directory / filename
number = 2
while candidate.exists():
candidate = directory / ("%s-%s%s" % (stem, number, suffix))
number += 1
return candidate
def save_upload(conn, filename, content, cfg, project_id=None):
directory = cfg["data_dir"] / "eingang"
directory.mkdir(parents=True, exist_ok=True)
path = unique_path(directory, filename)
path.write_bytes(content)
meta = meta_for(path)
meta["project_id"] = project_id
return store.insert_upload(conn, meta)
def ensure_library_gcode(conn, cfg, host, filename):
"""Wenn die laufende Datei fehlt: einmal vom Drucker nach Grotti ziehen."""
base = (filename or "").replace("\\", "/").split("/")[-1]
if not base or not base.lower().endswith(".gcode"):
return None
known = store.file_by_name(conn, base)
if known:
path = Path(known.get("stored_path") or "")
if path.is_file():
return store.file_out(known)
from grotti import moonraker
pulled = moonraker.download_gcode(host, base)
if not pulled:
return None
name, content = pulled
return save_upload(conn, name, content, cfg)
def printer_file(row):
path = Path(row["stored_path"])
if not path.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
if row["kind"] == "gcode":
return path.name, path.read_bytes(), ""
if row["kind"] != "gcode.3mf":
raise GrottiError("Die Datei enthält keinen G-code. Zuerst slicen.", "ungesliced")
return path.name, path.read_bytes(), row["plate"] or ""
def preview_png(row):
path = Path(row["stored_path"])
if not path.is_file():
return None
entry = row["thumb_entry"]
if row["kind"] == "gcode":
return embedded_png(path.read_bytes()[:400_000].decode("utf-8", "replace"))
if not zipfile.is_zipfile(path):
return None
with zipfile.ZipFile(path) as archive:
names = []
if entry and entry != "embedded" and safe_zip_name(entry) and entry in archive.namelist():
names.append(entry)
names.extend(
name for name in archive.namelist()
if name.lower().endswith(".png") and safe_zip_name(name) and name not in names
)
for name in names:
data = archive.read(name)
if data.startswith(b"\x89PNG"):
return data
plate = row["plate"]
if plate and safe_zip_name(plate) and plate in archive.namelist():
with archive.open(plate) as handle:
return embedded_png(handle.read(400_000).decode("utf-8", "replace"))
return None
+970
View File
@@ -0,0 +1,970 @@
"""Moonraker, nur für Drucker, die in Grotti stehen."""
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from grotti.parse import embedded_png
META_CACHE = {}
CAMERA_CACHE = {}
EMBEDDED = {}
SLOT_CACHE = {}
MESH_CACHE = {}
OBJECT_CACHE = {}
THUMB_RE_LIMIT = 180_000
def matrix_rows(points):
if isinstance(points, list) and points and isinstance(points[0], list):
return list(reversed(points))
return []
def extract_mesh(mesh):
mesh = mesh or {}
shown = matrix_rows(mesh.get("probed_matrix"))
if not shown:
shown = matrix_rows(mesh.get("mesh_matrix"))
profile = mesh.get("profile_name") or ""
return shown, profile
def _raw_matrix(points):
if isinstance(points, list) and points and isinstance(points[0], list):
rows = []
for row in points:
if not isinstance(row, list):
return []
cleaned = []
for value in row:
if not isinstance(value, (int, float)):
return []
cleaned.append(float(value))
rows.append(cleaned)
return rows
return []
def fetch_bed_mesh(host):
"""Roh-Bettnetz von Moonraker (ohne UI-Spiegelung)."""
url = "http://%s/printer/objects/query?bed_mesh" % host
payload = fetch_json(url, timeout=10)
status = (payload.get("result") or {}).get("status") or {}
mesh = status.get("bed_mesh") or {}
matrix = _raw_matrix(mesh.get("probed_matrix"))
if not matrix:
matrix = _raw_matrix(mesh.get("mesh_matrix"))
mesh_min = mesh.get("mesh_min")
mesh_max = mesh.get("mesh_max")
if not (isinstance(mesh_min, (list, tuple)) and len(mesh_min) >= 2):
mesh_min = None
if not (isinstance(mesh_max, (list, tuple)) and len(mesh_max) >= 2):
mesh_max = None
if not matrix or mesh_min is None or mesh_max is None:
return None
return {
"matrix": matrix,
"mesh_min": [float(mesh_min[0]), float(mesh_min[1])],
"mesh_max": [float(mesh_max[0]), float(mesh_max[1])],
"profile": mesh.get("profile_name") or "",
}
def store_mesh(host, shown, profile=""):
if not shown:
return
MESH_CACHE[host] = {
"mesh": shown,
"at": time.time(),
"profile": profile or "",
}
def mesh_fields(host, shown=None, profile=""):
"""Live-Netz vom Drucker, sonst zuletzt bekannte Matrix (auch offline)."""
if shown:
store_mesh(host, shown, profile)
cached = MESH_CACHE[host]
return {
"mesh": cached["mesh"],
"mesh_at": cached["at"],
"mesh_cached": False,
"mesh_profile": cached.get("profile") or "",
}
cached = MESH_CACHE.get(host)
if cached and cached.get("mesh"):
return {
"mesh": cached["mesh"],
"mesh_at": cached["at"],
"mesh_cached": True,
"mesh_profile": cached.get("profile") or "",
}
return {
"mesh": [],
"mesh_at": None,
"mesh_cached": False,
"mesh_profile": "",
}
class PrinterError(Exception):
def __init__(self, message):
super().__init__(message)
self.message = message
def is_timeout(exc):
text = (getattr(exc, "message", None) or str(exc) or "").lower()
return "zu lange nicht geantwortet" in text or "timed out" in text
def fetch_json(url, timeout=4):
request = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.load(response)
def read_error(exc):
try:
payload = json.loads(exc.read().decode("utf-8", "replace"))
message = (payload.get("error") or {}).get("message")
if message:
if "cannot parse the file" in message.lower():
return "Der Drucker lehnt die Datei ab."
return message
except (json.JSONDecodeError, AttributeError, UnicodeError):
pass
return "Der Drucker hat den Aufruf abgelehnt."
def post_json(host, path, payload, timeout=20):
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
"http://%s%s" % (host, path),
data=data,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
raw = response.read()
return json.loads(raw.decode("utf-8")) if raw else {}
except urllib.error.HTTPError as exc:
raise PrinterError(read_error(exc)) from exc
except TimeoutError as exc:
raise PrinterError("Der Drucker hat zu lange nicht geantwortet.") from exc
except urllib.error.URLError as exc:
reason = str(getattr(exc, "reason", "") or exc)
if "timed out" in reason.lower() or isinstance(getattr(exc, "reason", None), TimeoutError):
raise PrinterError("Der Drucker hat zu lange nicht geantwortet.") from exc
raise PrinterError("Der Drucker ist nicht erreichbar.") from exc
def multipart(filename, content):
boundary = "----GrottiBoundary7f3a"
ascii_name = "".join(ch if ch.isascii() and ch not in '"\\' else "_" for ch in filename) or "job.gcode"
quoted = urllib.parse.quote(filename)
fields = (
"--%s\r\nContent-Disposition: form-data; name=\"root\"\r\n\r\ngcodes\r\n" % boundary
).encode("ascii")
head = (
"--%s\r\n"
"Content-Disposition: form-data; name=\"file\"; filename=\"%s\"; filename*=UTF-8''%s\r\n"
"Content-Type: application/octet-stream\r\n\r\n"
) % (boundary, ascii_name, quoted)
tail = ("\r\n--%s--\r\n" % boundary).encode("ascii")
return boundary, fields + head.encode("ascii") + content + tail
class _UploadBody:
"""Bytes als lesbarer Stream, damit urllib Fortschritt melden kann."""
def __init__(self, data, on_progress=None):
self._data = memoryview(data)
self._pos = 0
self._on_progress = on_progress
self._last_pct = -1
def __len__(self):
return len(self._data)
def read(self, size=-1):
total = len(self._data)
if self._pos >= total:
return b""
if size is None or size < 0:
size = 64 * 1024
end = min(self._pos + size, total)
chunk = self._data[self._pos:end].tobytes()
self._pos = end
if self._on_progress and total:
pct = int(100 * self._pos / total)
if pct != self._last_pct and (pct == 100 or pct - self._last_pct >= 2):
self._last_pct = pct
self._on_progress(self._pos, total)
return chunk
def upload_timeout(size):
# Große G-codes brauchen länger als 120 s (LAN + Moonraker-Metadaten).
size = max(0, int(size or 0))
return max(180, min(600, 90 + size // (80 * 1024)))
def upload(host, filename, content, on_progress=None):
boundary, body = multipart(filename, content)
stream = _UploadBody(body, on_progress=on_progress)
request = urllib.request.Request(
"http://%s/server/files/upload" % host,
data=stream,
headers={
"Content-Type": "multipart/form-data; boundary=%s" % boundary,
"Content-Length": str(len(body)),
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=upload_timeout(len(body))) as response:
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
raise PrinterError(read_error(exc)) from exc
except (urllib.error.URLError, TimeoutError) as exc:
raise PrinterError("Hochladen ist fehlgeschlagen.") from exc
item = (payload.get("result") or {}).get("item") or {}
return item.get("path") or filename
def start_print(host, filename, bed_leveling=False, ams_settings=None):
"""Start: Moonraker nur ohne Bettnetz und ohne Grotti-ACE-Map.
Moonraker `/printer/print/start` kennt nur filename. Rinkhals baut dann
MQTT selbst (auto_leveling aus App-Property; ams_settings aus .acm /
Identity-Fallback). Bei bed_leveling oder gesetzter Grotti-ams_settings
startet Grotti per MQTTS :9883 und legt die Map explizit in die Payload.
"""
use_mqtt = bool(bed_leveling) or bool(ams_settings)
if not use_mqtt:
return post_json(host, "/printer/print/start", {"filename": filename})
from grotti import anycubic_lan
try:
return anycubic_lan.start_print(
host,
filename,
auto_leveling=bool(bed_leveling),
ams_settings=ams_settings,
)
except anycubic_lan.LanError as exc:
raise PrinterError(exc.message) from exc
def command(host, action):
path = {
"pause": "/printer/print/pause",
"fortsetzen": "/printer/print/resume",
"abbrechen": "/printer/print/cancel",
}.get(action)
if not path:
raise PrinterError("Unbekannter Befehl.")
result = post_json(host, path, {})
# Nach ACE/MQTT-Start reicht Moonraker-cancel nicht: Anycubic-Task bleibt
# aktiv → nächster MQTT-Start mit 10101. Best-effort MQTT stop.
if action == "abbrechen":
try:
from grotti import anycubic_lan
anycubic_lan.cancel_print(host)
except Exception:
pass
return result
def gcode_script(host, script, timeout=30):
script = (script or "").strip()
if not script:
raise PrinterError("Kein G-code.")
return post_json(host, "/printer/gcode/script", {"script": script}, timeout=max(10, int(timeout)))
def heaters_off(host):
"""Heizung und Lüfter aus — nach Fehler / Abbruch, nicht weiter heizen."""
return gcode_script(
host,
"\n".join([
"SET_HEATER_TEMPERATURE HEATER=extruder TARGET=0",
"SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=0",
"M107",
]),
timeout=20,
)
def clear_print_error(host):
"""Hängenden print_stats/idle error lösen (Anycubic lässt sonst 'error' stehen)."""
try:
heaters_off(host)
except PrinterError:
pass
try:
post_json(host, "/printer/print/cancel", {}, timeout=15)
except PrinterError:
pass
gcode_script(host, "SDCARD_RESET_FILE", timeout=20)
try:
from grotti import anycubic_lan
anycubic_lan.cancel_print(host)
except Exception:
pass
return {"ok": True, "message": "Fehlerstatus zurückgesetzt."}
def steuern(host, action, params=None):
"""Whitelist-Steuerung ohne freie Konsole."""
params = params if isinstance(params, dict) else {}
action = (action or "").strip()
def pct(key, default=100, lo=0, hi=200):
try:
value = int(round(float(params.get(key, default))))
except (TypeError, ValueError) as exc:
raise PrinterError("%s muss eine Zahl sein." % key) from exc
return max(lo, min(hi, value))
def temp(key):
try:
value = float(params.get(key))
except (TypeError, ValueError) as exc:
raise PrinterError("%s muss eine Temperatur sein." % key) from exc
if value < 0 or value > 350:
raise PrinterError("Temperatur außerhalb 0–350 °C.")
return value
if action == "nozzle":
return gcode_script(
host,
"SET_HEATER_TEMPERATURE HEATER=extruder TARGET=%.1f" % temp("temp"),
)
if action == "bed":
return gcode_script(
host,
"SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=%.1f" % temp("temp"),
)
if action == "speed":
return gcode_script(host, "M220 S%d" % pct("percent", 100, 10, 200))
if action == "flow":
return gcode_script(host, "M221 S%d" % pct("percent", 100, 50, 150))
if action == "fan":
value = pct("percent", 0, 0, 100)
if value <= 0:
return gcode_script(host, "M107")
return gcode_script(host, "M106 S%d" % int(round(value * 255 / 100)))
if action == "emergency_stop":
return post_json(host, "/printer/emergency_stop", {}, timeout=10)
if action == "clear_error":
return clear_print_error(host)
if action == "exclude_object":
name = str(params.get("name") or "").strip()
if not re.fullmatch(r"[A-Za-z0-9._+\-]+", name):
raise PrinterError("Ungültiger Objektname.")
return gcode_script(host, "EXCLUDE_OBJECT NAME=%s" % name, timeout=20)
if action == "firmware_restart":
# GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32).
raise PrinterError(
"Firmware-Restart geht auf GoKlipper nicht — Drucker am Display "
"neu starten oder Strom aus. Hängenden Fehler: „Fehler löschen“."
)
raise PrinterError("Unbekannte Steuerung.")
def download_gcode(host, filename, max_bytes=40 * 1024 * 1024):
"""Lädt eine G-code-Datei vom Drucker (für Grotti-Viewer-Cache)."""
name = (filename or "").replace("\\", "/").split("/")[-1]
if not name or ".." in name:
return None
url = "http://%s/server/files/gcodes/%s" % (host, urllib.parse.quote(name))
try:
with urllib.request.urlopen(url, timeout=60) as response:
data = response.read(max_bytes + 1)
except (urllib.error.URLError, TimeoutError, OSError):
return None
if not data or len(data) > max_bytes:
return None
return name, data
def history(host, limit=50):
url = "http://%s/server/history/list?limit=%s&start=0&order=desc" % (host, int(limit))
try:
payload = fetch_json(url, timeout=8)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
return []
return (payload.get("result") or {}).get("jobs") or []
def metadata(host, path):
key = (host, path)
cached = META_CACHE.get(key)
now = time.time()
if cached and now - cached[0] < 60:
return cached[1]
url = "http://%s/server/files/metadata?filename=%s" % (host, urllib.parse.quote(path))
try:
data = fetch_json(url).get("result") or {}
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
data = {}
META_CACHE[key] = (now, data)
return data
def preview_path(gcode_path, meta):
thumbs = meta.get("thumbnails") or []
if not thumbs or not gcode_path:
return ""
best = max(thumbs, key=lambda item: item.get("width") or 0)
relative = (best.get("relative_path") or "").replace("\\", "/")
if not relative.endswith(".png") or ".thumbs/" not in relative or ".." in relative:
return ""
folder = gcode_path.replace("\\", "/").rsplit("/", 1)[0] if "/" in gcode_path else ""
return "%s/%s" % (folder, relative) if folder else relative
def embedded_preview(host, path):
key = (host, path)
if key in EMBEDDED:
return EMBEDDED[key]
url = "http://%s/server/files/gcodes/%s" % (host, urllib.parse.quote(path))
request = urllib.request.Request(url, headers={"Range": "bytes=0-%s" % THUMB_RE_LIMIT})
try:
with urllib.request.urlopen(request, timeout=6) as response:
text = response.read().decode("utf-8", "replace")
except (urllib.error.URLError, TimeoutError):
EMBEDDED[key] = None
return None
png = embedded_png(text)
EMBEDDED[key] = png
return png
def css_hex(value):
if isinstance(value, (list, tuple)) and len(value) >= 3:
channels = []
for channel in value[:3]:
if isinstance(channel, bool) or not isinstance(channel, (int, float)):
return ""
channels.append(max(0, min(255, int(round(float(channel))))))
return "#%02x%02x%02x" % tuple(channels)
if value in (None, ""):
return ""
text = str(value).strip()
if text.startswith("#"):
text = text[1:]
digits = "0123456789abcdefABCDEF"
if not text or any(ch not in digits for ch in text):
return ""
if len(text) == 8:
if text[6:8].lower() != "ff":
return "#" + text.lower()
text = text[:6]
if len(text) == 6:
return "#" + text.lower()
return ""
def slot_kind(value):
text = str(value or "").strip()
if not text or text.lower() == "unknown":
return ""
return text
def slots_from_mmu(mmu):
if not isinstance(mmu, dict):
return []
colors = mmu.get("gate_color")
materials = mmu.get("gate_material")
names = mmu.get("gate_filament_name")
states = mmu.get("gate_status")
colors = colors if isinstance(colors, list) else []
materials = materials if isinstance(materials, list) else []
names = names if isinstance(names, list) else []
states = states if isinstance(states, list) else []
count = mmu.get("num_gates")
if not isinstance(count, int) or isinstance(count, bool) or count < 1:
count = max(len(colors), len(materials), len(names), len(states))
found = []
for index in range(min(count, 8)):
state = states[index] if index < len(states) else None
if state == 0:
continue
kind = slot_kind(materials[index] if index < len(materials) else "")
if not kind and index < len(names):
kind = slot_kind(names[index])
color = css_hex(colors[index] if index < len(colors) else "")
if state not in (1, 2) and not kind and color in ("", "#000000"):
continue
if not color and not kind:
continue
found.append({"index": index, "color": color, "type": kind})
return found
def slots_from_hubs(status):
hubs = status.get("filament_hubs")
if isinstance(hubs, list):
groups = hubs
elif isinstance(status.get("filament_hub"), dict):
groups = [status["filament_hub"]]
else:
return []
found = []
for hub_index, hub in enumerate(groups):
if not isinstance(hub, dict):
continue
items = hub.get("slots")
if not isinstance(items, list):
continue
for offset, item in enumerate(items):
if not isinstance(item, dict):
continue
index = item.get("index")
if isinstance(index, bool) or not isinstance(index, int):
index = hub_index * 4 + offset
raw_color = item.get("color")
if raw_color in (None, ""):
raw_color = item.get("colour")
color = css_hex(raw_color)
kind = slot_kind(item.get("type") or item.get("material") or item.get("filament_type"))
if not color and not kind:
continue
found.append({"index": index, "color": color, "type": kind})
return found
def extract_slots(status):
status = status or {}
found = slots_from_mmu(status.get("mmu"))
if not found:
found = slots_from_hubs(status)
found.sort(key=lambda item: item.get("index") or 0)
return found[:8]
def slots(host, status=None):
if status is not None:
found = extract_slots(status)
SLOT_CACHE[host] = (time.time(), found)
return found
cached = SLOT_CACHE.get(host)
now = time.time()
if cached and now - cached[0] < 60:
return cached[1]
found = []
try:
payload = fetch_json(
"http://%s/printer/objects/query?mmu" % host,
timeout=2,
)
found = extract_slots((payload.get("result") or {}).get("status") or {})
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
found = []
SLOT_CACHE[host] = (now, found)
return found
def fan_speed_of(entry):
if not isinstance(entry, dict):
return None
speed = entry.get("speed")
if isinstance(speed, (int, float)):
return max(0.0, min(1.0, float(speed)))
return None
def fan_values(status):
fan = status.get("fan")
if not isinstance(fan, dict):
return None, None
speed = fan_speed_of(fan)
rpm = fan.get("rpm")
if isinstance(rpm, (int, float)):
rpm = int(rpm)
else:
rpm = None
return speed, rpm
def _object_names(host):
now = time.time()
cached = OBJECT_CACHE.get(host)
if cached and now - cached[0] < 120:
return cached[1]
names = []
try:
payload = fetch_json("http://%s/printer/objects/list" % host, timeout=3)
names = list((payload.get("result") or {}).get("objects") or [])
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError, TypeError):
names = []
OBJECT_CACHE[host] = (now, names)
return names
def _pick_object(names, prefixes, needles):
lowered = [(name, name.lower()) for name in names]
for needle in needles:
for name, low in lowered:
if any(low.startswith(prefix) for prefix in prefixes) and needle in low:
return name
for name, low in lowered:
if any(low.startswith(prefix) for prefix in prefixes):
return name
return ""
def chamber_and_fans(host, status):
names = _object_names(host)
chamber_name = _pick_object(
names,
("temperature_sensor ", "heater_generic "),
("chamber", "bauraum", "盒", "box"),
)
part_name = "fan" if "fan" in names else ""
chamber_fan = _pick_object(names, ("temperature_fan ", "heater_fan ", "controller_fan "), ("chamber", "bauraum"))
aux_fan = _pick_object(names, ("temperature_fan ", "heater_fan ", "controller_fan ", "fan_generic "), ("aux", "auxiliary", "side"))
wanted = [name for name in (chamber_name, part_name, chamber_fan, aux_fan) if name and name not in status]
extra = {}
if wanted:
try:
url = "http://%s/printer/objects/query?%s" % (
host,
"&".join(urllib.parse.quote(name, safe="") for name in wanted),
)
payload = fetch_json(url, timeout=3)
extra = (payload.get("result") or {}).get("status") or {}
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError):
extra = {}
merged = dict(status)
merged.update(extra)
chamber = None
chamber_target = None
if chamber_name and isinstance(merged.get(chamber_name), dict):
entry = merged[chamber_name]
temp = entry.get("temperature")
if isinstance(temp, (int, float)):
chamber = float(temp)
target = entry.get("target")
if isinstance(target, (int, float)):
chamber_target = float(target)
fans = {
"part": fan_speed_of(merged.get("fan")),
"chamber": fan_speed_of(merged.get(chamber_fan)) if chamber_fan else None,
"aux": fan_speed_of(merged.get(aux_fan)) if aux_fan else None,
}
return chamber, chamber_target, fans
def last_gcode_error(host, limit=40):
"""Letzte Fehlerzeile aus dem G-code-Log (Anycubic lässt print_stats.message oft leer)."""
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 (item.get("type") or "") != "response":
continue
text = (item.get("message") or "").strip()
low = text.lower()
if "error" not in low and "!!" not in text and "must home" not in low:
continue
# "error: typ = WebRequestError, code = 10011600, message = Must home axis first"
marker = "message ="
if marker in text:
text = text.split(marker, 1)[-1].strip()
if text.lower().startswith("error:"):
text = text[6:].strip()
return text
return ""
def enrich_error_message(host, stats, status):
raw = (stats.get("message") or "").strip()
if raw and raw.lower() not in ("error", "fehler", "failed", "fail"):
return raw
bits = []
detail = last_gcode_error(host)
if detail:
# Nur echte G-code-Antworten — leeres homed_axes im Idle ist normal
# (Stepper aus) und darf keinen Druckstart blockieren oder erklären.
if "must home" in detail.lower():
bits.append("Bewegung ohne Homing (Must home axis first)")
else:
bits.append(detail)
duration = stats.get("print_duration") or 0
if stats.get("filename") and isinstance(duration, (int, float)) and float(duration) <= 0:
bits.append("Druck sofort abgebrochen")
idle = ((status.get("idle_timeout") or {}).get("state") or "").lower()
if idle == "error" and not bits:
bits.append("Drucker im Fehlerzustand")
return " · ".join(bits) if bits else (raw or "Unbekannter Fehler — in Mainsail prüfen")
def exclude_status(host):
"""Klipper [exclude_object] — leer, wenn Modul fehlt oder Query scheitert."""
empty = {"objects": [], "excluded": [], "current": None}
url = "http://%s/printer/objects/query?exclude_object" % host
try:
payload = fetch_json(url, timeout=4)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, TypeError):
return empty
eo = ((payload.get("result") or {}).get("status") or {}).get("exclude_object")
if not isinstance(eo, dict):
return empty
objects = []
for item in eo.get("objects") or []:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if not name:
continue
center = item.get("center")
polygon = item.get("polygon")
objects.append({
"name": name,
"center": center if isinstance(center, list) else [],
"polygon": polygon if isinstance(polygon, list) else [],
})
excluded = [str(x) for x in (eo.get("excluded_objects") or []) if x]
current = eo.get("current_object")
return {
"objects": objects,
"excluded": excluded,
"current": str(current) if current else None,
}
def _probe_z_of(status):
probe = status.get("probe")
if not isinstance(probe, dict):
return None
raw = probe.get("last_z_result")
if isinstance(raw, (int, float)):
return float(raw)
return None
_PROBE_RESULT_RE = re.compile(r"Result is z=\s*([+-]?\d+(?:\.\d+)?)", re.I)
def _message_has_probe_cmd(message):
return bool(re.search(r"(?im)^\s*PROBE\s*$", message or ""))
def latest_probe_result_z(host, after_time=None):
"""Tast-Z aus Moonraker-G-code-Store (`Result is z=`).
Auf Kobra 3/GoKlipper ist `printer.probe` oft leer — `last_z_result` fehlt.
Tasten-Script: ein Moonraker-Befehl mit zwei PROBE (Rand-Null + Mitte) —
es zählt das **letzte** `Result is z=` nach diesem Befehl.
"""
url = "http://%s/server/gcode_store?count=60" % host
payload = fetch_json(url, timeout=10)
entries = (payload.get("result") or {}).get("gcode_store") or []
min_t = None if after_time is None else float(after_time) - 2.0
last_probe_cmd = -1
for idx, entry in enumerate(entries):
if not isinstance(entry, dict) or (entry.get("type") or "") != "command":
continue
if not _message_has_probe_cmd(entry.get("message") or ""):
continue
if min_t is not None:
try:
if float(entry.get("time") or 0) < min_t:
continue
except (TypeError, ValueError):
continue
last_probe_cmd = idx
if last_probe_cmd >= 0:
found = None
for entry in entries[last_probe_cmd + 1 :]:
if not isinstance(entry, dict):
continue
if (entry.get("type") or "") == "command":
break
match = _PROBE_RESULT_RE.search(entry.get("message") or "")
if match:
found = float(match.group(1))
return found
for entry in reversed(entries):
if not isinstance(entry, dict):
continue
if min_t is not None:
try:
if float(entry.get("time") or 0) < min_t:
continue
except (TypeError, ValueError):
continue
match = _PROBE_RESULT_RE.search(entry.get("message") or "")
if match:
return float(match.group(1))
return None
def query(host):
base = (
"print_stats&heater_bed&extruder&virtual_sdcard&bed_mesh&fan&gcode_move&mmu&toolhead&idle_timeout"
)
names = _object_names(host)
extras = []
if "probe" in names:
extras.append("probe")
url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras))
payload = fetch_json(url)
status = payload["result"]["status"]
stats = status.get("print_stats") or {}
card = status.get("virtual_sdcard") or {}
extruder = status.get("extruder") or {}
bed = status.get("heater_bed") or {}
raw_name = stats.get("filename") or ""
filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else ""
meta = metadata(host, raw_name) if raw_name else {}
picture = preview_path(raw_name, meta)
if not picture and raw_name:
embedded_preview(host, raw_name)
shown, mesh_profile = extract_mesh(status.get("bed_mesh"))
info = stats.get("info") or {}
progress = card.get("progress")
if not isinstance(progress, (int, float)):
size = card.get("file_size") or 0
pos = card.get("file_position") or 0
progress = (pos / size) if size else 0
quoted_host = urllib.parse.quote(host)
fan_speed, fan_rpm = fan_values(status)
chamber, chamber_target, fans = chamber_and_fans(host, status)
if fans.get("part") is None:
fans["part"] = fan_speed
move = status.get("gcode_move") or {}
speed_factor = move.get("speed_factor")
if isinstance(speed_factor, (int, float)):
speed_factor = float(speed_factor)
else:
speed_factor = None
extrude_factor = move.get("extrude_factor")
if isinstance(extrude_factor, (int, float)):
extrude_factor = float(extrude_factor)
else:
extrude_factor = None
state = stats.get("state") or "standby"
message = stats.get("message") or ""
if (state or "").lower() == "error":
message = enrich_error_message(host, stats, status)
toolhead = status.get("toolhead") or {}
axis_max = toolhead.get("axis_maximum") or []
z_max = None
if isinstance(axis_max, (list, tuple)) and len(axis_max) >= 3 and isinstance(axis_max[2], (int, float)):
z_max = float(axis_max[2])
return {
"ok": True,
"state": state,
"message": message,
"homed_axes": toolhead.get("homed_axes") or "",
"position": toolhead.get("position") or [],
"z_max": z_max,
"probe_z": _probe_z_of(status),
"filename": filename,
"progress": max(0, min(1, float(progress))),
"layer": info.get("current_layer") or card.get("current_layer") or 0,
"layers": info.get("total_layer") or card.get("total_layer") or 0,
"remain": card.get("remain_time") or 0,
"elapsed": stats.get("print_duration") or 0,
"nozzle": extruder.get("temperature"),
"nozzle_target": extruder.get("target"),
"bed": bed.get("temperature"),
"bed_target": bed.get("target"),
"chamber": chamber,
"chamber_target": chamber_target,
"material": card.get("filament_type") or meta.get("filament_type") or "",
"weight": meta.get("filament_weight_total") or 0,
"height": meta.get("object_height") or 0,
"layer_height": meta.get("layer_height") or card.get("layer_height") or 0,
"estimated": meta.get("estimated_time") or 0,
"preview": (
"/api/v1/vorschau?host=%s&file=%s" % (quoted_host, urllib.parse.quote(picture))
if picture else
"/api/v1/vorschau?host=%s&embedded=%s" % (quoted_host, urllib.parse.quote(raw_name))
if raw_name and EMBEDDED.get((host, raw_name)) else ""
),
"camera": "/api/v1/kamera?host=%s" % quoted_host,
**mesh_fields(host, shown, mesh_profile),
"fan": fan_speed,
"fan_rpm": fan_rpm,
"fans": fans,
"speed_factor": speed_factor,
"extrude_factor": extrude_factor,
"slots": slots(host, status),
"exclude": exclude_status(host),
}
def snapshot(printers):
def one(printer):
card = {
"name": printer["name"],
"host": printer["host"],
"family": printer["family"],
"family_label": printer["family_label"],
"mainsail": "http://%s/" % printer["host"],
}
try:
card.update(query(printer["host"]))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as error:
card.update({
"ok": False,
"state": "offline",
"message": str(error),
"fan": None,
"fan_rpm": None,
"fans": {"part": None, "chamber": None, "aux": None},
"chamber": None,
"chamber_target": None,
"speed_factor": None,
"extrude_factor": None,
"slots": [],
})
card.update(mesh_fields(printer["host"]))
return card
with ThreadPoolExecutor(max_workers=max(1, len(printers))) as pool:
return list(pool.map(one, printers))
def proxy_image(host, url):
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=5) as response:
body = response.read()
kind = response.headers.get("Content-Type") or "image/jpeg"
return kind, body
def camera_image(host):
cached = CAMERA_CACHE.get(host)
now = time.time()
if cached and now - cached[0] < cached[1]:
return cached[2]
try:
kind, body = proxy_image(host, "http://%s/webcam/?action=snapshot" % host)
if not body.startswith(b"\xff\xd8") and not body.startswith(b"\x89PNG"):
raise urllib.error.URLError("kein Bild")
CAMERA_CACHE[host] = (now, 2, (kind, body))
except (urllib.error.URLError, TimeoutError):
CAMERA_CACHE[host] = (now, 30, None)
return CAMERA_CACHE[host][2]
def embedded_bytes(host, name):
return EMBEDDED.get((host, name))
+53
View File
@@ -0,0 +1,53 @@
"""Telegram-Benachrichtigungen (Bot API)."""
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
def telegram_configured(cfg):
tg = (cfg or {}).get("telegram") or {}
return bool(tg.get("enabled") and tg.get("bot_token") and tg.get("chat_id"))
def send_telegram(cfg, text):
"""Sendet Text an Telegram. Fehler werden geschluckt (False)."""
if not telegram_configured(cfg):
return False
tg = cfg["telegram"]
url = "https://api.telegram.org/bot%s/sendMessage" % tg["bot_token"]
payload = urllib.parse.urlencode({
"chat_id": str(tg["chat_id"]),
"text": text[:4000],
"disable_web_page_preview": "1",
}).encode("utf-8")
request = urllib.request.Request(url, data=payload, method="POST")
try:
with urllib.request.urlopen(request, timeout=12) as response:
body = json.loads(response.read().decode("utf-8", "replace"))
return bool(body.get("ok"))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
return False
ALERT_GROUP = {"abbruch": "fehler"}
DEFAULT_OFF = {"ablauf", "start"}
def alert_enabled(cfg, kind):
alerts = (cfg or {}).get("alerts") or {}
key = ALERT_GROUP.get(kind, kind)
return bool(alerts.get(key, key not in DEFAULT_OFF))
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60):
"""Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster."""
from grotti import store
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s)
store.add_event(conn, host, printer, kind, message, filename or "")
if not recent and alert_enabled(cfg, kind):
send_telegram(cfg, message)
+455
View File
@@ -0,0 +1,455 @@
"""Metadaten aus G-code und 3MF, ohne den Drucker anzufassen."""
import base64
import io
import json
import re
import zipfile
from pathlib import Path
from grotti.printers import family_from_text
THUMB_RE = re.compile(r"; thumbnail begin (\d+)x(\d+) \d+\s*(.*?); thumbnail end", re.S)
COMMENT_RE = re.compile(r"^;\s*([^:=]+?)\s*[:=]\s*(.*)$")
DURATION_RE = re.compile(r"(\d+)\s*([dhms])", re.I)
TRAILER_RE = re.compile(rb";\s*referenced_tools\s*=", re.I)
# Rinkhals mmu_ace_metadata.py: erste Zeile überspringt den vollen Scan auf dem Drucker.
RINKHALS_FINGERPRINT = b"; processed by MmuAcePatcher"
_SLICER_RE = re.compile(rb"^;.*generated by ([A-Za-z]*) .*$|^; (BambuStudio) .*$", re.I)
_TOOL_RE = re.compile(rb"^(?:MMU_CHANGE_TOOL(?:_STANDALONE)? .*?TOOL=|T)(\d{1,2})", re.I)
_AUTHORIZED_SLICERS = {
"prusaslicer",
"superslicer",
"orcaslicer",
"bambustudio",
"anycubicslicernext",
}
def parse_duration(value):
total = 0
for number, unit in DURATION_RE.findall(value or ""):
total += int(number) * {"d": 86400, "h": 3600, "m": 60, "s": 1}[unit.lower()]
return total
def sum_numbers(value):
total = 0.0
for number in re.findall(r"\d+(?:[.,]\d+)?", value or ""):
total += float(number.replace(",", "."))
return total
def comments_of(text):
found = {}
for line in text.splitlines():
stripped = line.strip()
if stripped and not stripped.startswith(";"):
break
match = COMMENT_RE.match(stripped)
if match:
found[match.group(1).strip().lower()] = match.group(2).strip()
return found
def comments_scan(text):
"""Alle `; key = value`-Zeilen, auch im Footer nach G-code."""
found = {}
for line in (text or "").splitlines():
match = COMMENT_RE.match(line.strip())
if match:
found[match.group(1).strip().lower()] = match.group(2).strip()
return found
def _num_comment(comments, *keys):
for key in keys:
raw = comments.get(key) or ""
if not raw:
continue
match = re.search(r"[-+]?\d+(?:[.,]\d+)?", raw.replace(",", "."))
if match:
try:
return float(match.group(0))
except ValueError:
continue
return 0.0
def _int_comment(comments, *keys):
value = _num_comment(comments, *keys)
return int(value) if value else 0
def color_hex(channels):
if not isinstance(channels, (list, tuple)) or len(channels) < 3:
return ""
try:
rgb = [max(0, min(255, int(round(float(channel))))) for channel in channels[:3]]
except (TypeError, ValueError):
return ""
return "#%02x%02x%02x" % tuple(rgb)
def filaments_from_paint_info(raw):
try:
items = json.loads(raw or "")
except (TypeError, json.JSONDecodeError):
return []
if not isinstance(items, list):
return []
found = []
for item in items:
if not isinstance(item, dict):
continue
try:
index = int(item.get("paint_index"))
except (TypeError, ValueError):
index = len(found)
while len(found) <= index:
found.append({"type": "", "color": ""})
found[index] = {
"type": str(item.get("material_type") or "").strip(),
"color": color_hex(item.get("paint_color")),
}
return [row for row in found if row["type"] or row["color"]]
def filaments_of(comments):
painted = filaments_from_paint_info(comments.get("paint_info"))
if painted:
return painted
types = [part.strip() for part in (comments.get("filament_type") or "").split(";") if part.strip()]
colors = [part.strip() for part in (comments.get("filament_colour") or comments.get("filament_color") or "").split(";")]
return [
{"type": kind, "color": colors[index] if index < len(colors) else ""}
for index, kind in enumerate(types)
]
def _gcode_lines(content):
start = 0
size = len(content)
while start < size:
end = content.find(b"\n", start)
if end < 0:
line = content[start:]
if line.endswith(b"\r"):
line = line[:-1]
if line:
yield line
break
line = content[start:end]
if line.endswith(b"\r"):
line = line[:-1]
yield line
start = end + 1
def _paint_tool_indexes(content):
"""paint_index aus ; paint_info = […] im G-code."""
found = set()
for line in _gcode_lines(content):
if b"paint_info" not in line.lower():
continue
text = line.decode("utf-8", "replace")
match = COMMENT_RE.match(text.strip())
raw = match.group(2).strip() if match else ""
if not raw and "=" in text:
raw = text.split("=", 1)[-1].strip()
try:
items = json.loads(raw or "")
except (TypeError, json.JSONDecodeError):
continue
if not isinstance(items, list):
continue
for item in items:
if not isinstance(item, dict):
continue
try:
found.add(int(item.get("paint_index")))
except (TypeError, ValueError):
continue
return found
def prepare_rinkhals_gcode(content, tools=None):
"""Kopfzeile für Rinkhals plus `; referenced_tools`, wenn sie fehlt.
`tools`: explizite Indizes (z. B. aus der ACE-Zuordnung). Sonst aus T-Befehlen
oder paint_info. Ohne Fingerprint scannt mmu_ace_metadata die ganze Datei auf
dem Drucker, bevor der Upload zurückkommt.
"""
if not content or content[:2] == b"PK":
return content
first_end = content.find(b"\n")
first = content if first_end < 0 else content[:first_end]
if first.endswith(b"\r"):
first = first[:-1]
has_fingerprint = first.lower().startswith(RINKHALS_FINGERPRINT.lower())
forced = set()
for value in tools or []:
try:
forced.add(int(value))
except (TypeError, ValueError):
continue
slicer = ""
found_tools = set()
for line in _gcode_lines(content):
if not slicer and line.startswith(b";"):
match = _SLICER_RE.match(line)
if match:
raw = match.group(1) or match.group(2) or b""
slicer = raw.decode("ascii", "replace").lower()
match = _TOOL_RE.match(line)
if match:
found_tools.add(int(match.group(1)))
paint_tools = _paint_tool_indexes(content)
chosen = found_tools or forced or paint_tools
# ACE-Send mit Map, aber nichts erkannt: Einzelfarbe Platz 0.
if forced and not chosen:
chosen = {0}
stamped = content if has_fingerprint else (RINKHALS_FINGERPRINT + b"\n" + content)
need_trailer = chosen and not has_ace_trailer(content)
allow_trailer = bool(forced) or slicer in _AUTHORIZED_SLICERS
if need_trailer and allow_trailer:
if not stamped.endswith(b"\n"):
stamped += b"\n"
listed = ",".join(str(number) for number in sorted(chosen))
stamped += ("; referenced_tools = %s\n" % listed).encode("ascii")
return stamped
def has_ace_trailer(data):
"""True wenn Rinkhals-/Slicer-Trailer `; referenced_tools = …` vorhanden ist."""
if not data:
return False
if isinstance(data, str):
data = data.encode("utf-8", "replace")
if TRAILER_RE.search(data[-131072:]):
return True
return bool(TRAILER_RE.search(data[:65536]))
def content_has_ace_trailer(content, kind="", plate=""):
if has_ace_trailer(content):
return True
kind = (kind or "").lower()
if kind not in ("gcode.3mf", "3mf") and not (content and content[:2] == b"PK"):
return False
try:
with zipfile.ZipFile(io.BytesIO(content)) as archive:
names = [name for name in archive.namelist() if safe_zip_name(name)]
candidates = []
if plate and plate in names:
candidates.append(plate)
candidates.extend(
name for name in names
if name.lower().endswith(".gcode") and name not in candidates
)
for name in candidates[:3]:
with archive.open(name) as handle:
raw = handle.read()
if has_ace_trailer(raw):
return True
except (zipfile.BadZipFile, OSError, RuntimeError):
return False
return False
def _stream_has_ace_trailer(handle, head_limit=400_000):
"""Liest Stream: Kopf für Parse, Ende für Trailer — ohne ganze Datei im RAM."""
head = handle.read(head_limit)
found = bool(TRAILER_RE.search(head[:65536])) or bool(TRAILER_RE.search(head[-131072:]))
tail = head[-131072:] if len(head) > 131072 else head
while True:
chunk = handle.read(256 * 1024)
if not chunk:
break
if len(chunk) >= 131072:
tail = chunk[-131072:]
else:
tail = (tail + chunk)[-131072:]
if not found and TRAILER_RE.search(tail):
found = True
return found, head
def path_has_ace_trailer(path, kind="", plate=""):
path = Path(path)
if not path.is_file():
return False
try:
kind = (kind or "").lower()
if kind in ("gcode.3mf", "3mf") or path.name.lower().endswith(".3mf"):
if not zipfile.is_zipfile(path):
return False
with zipfile.ZipFile(path) as archive:
names = [name for name in archive.namelist() if safe_zip_name(name)]
candidates = []
if plate and plate in names:
candidates.append(plate)
candidates.extend(
name for name in names
if name.lower().endswith(".gcode") and name not in candidates
)
for name in candidates[:3]:
with archive.open(name) as handle:
found, _ = _stream_has_ace_trailer(handle)
if found:
return True
return False
size = path.stat().st_size
with path.open("rb") as handle:
if size <= 200_000:
return has_ace_trailer(handle.read())
head = handle.read(65536)
handle.seek(max(0, size - 131072))
tail = handle.read(131072)
return has_ace_trailer(head + tail)
except (OSError, zipfile.BadZipFile, RuntimeError):
return False
def parse_gcode(text, trailer=""):
head = comments_of(text)
tail = comments_scan(trailer) if trailer else comments_scan(text[-131072:] if len(text) > 200_000 else "")
comments = dict(head)
comments.update(tail)
weight_key = comments.get("total filament used [g]") or comments.get("filament used [g]") or ""
model = comments.get("printer_model") or comments.get("printer model") or ""
family = family_from_text(model) if model else family_from_text(text[:8000])
time_key = (
comments.get("estimated printing time (normal mode)")
or comments.get("estimated printing time")
or comments.get("model printing time")
or ""
)
slicer = ""
for line in (text or "").splitlines()[:40]:
match = re.search(r"generated by\s+(\S+)|;\s*(BambuStudio)\b", line, re.I)
if match:
slicer = (match.group(1) or match.group(2) or "").strip()
break
slice_meta = {
"layer_height": _num_comment(comments, "layer_height", "layer height"),
"layers": _int_comment(comments, "total layer number", "total layers count"),
"max_z_mm": _num_comment(comments, "max_z_height", "max z height", "object_height"),
"nozzle_mm": _num_comment(comments, "nozzle_diameter", "nozzle diameter"),
"temp_nozzle": _num_comment(comments, "first_layer_temperature", "nozzle_temperature"),
"temp_bed": _num_comment(comments, "bed_temperature", "first_layer_bed_temperature"),
"slicer": slicer,
}
return {
"printer_family": family,
"filaments": filaments_of(comments) or filaments_of(head),
"weight_g": sum_numbers(weight_key),
"estimated_s": parse_duration(time_key),
"thumb_entry": "embedded" if "; thumbnail begin" in text else "",
"ace_trailer": has_ace_trailer(text.encode("utf-8", "replace") if isinstance(text, str) else text),
"slice_meta": {key: value for key, value in slice_meta.items() if value},
}
def embedded_png(text):
best = None
for match in THUMB_RE.finditer(text):
width = int(match.group(1))
raw = "".join(
line[1:].strip() if line.startswith(";") else line.strip()
for line in match.group(3).splitlines()
)
try:
png = base64.b64decode(raw)
except ValueError:
continue
if png.startswith(b"\x89PNG") and (best is None or width > best[0]):
best = (width, png)
return best[1] if best else None
def safe_zip_name(name):
parts = Path(name).parts
return not name.startswith(("/", "\\")) and ".." not in parts
def pick_name(names, suffix, preferred_end):
chosen = [name for name in names if name.lower().endswith(suffix) and safe_zip_name(name)]
for name in chosen:
if name.lower().endswith(preferred_end):
return name
return sorted(chosen)[0] if chosen else ""
def _read_gcode_head_tail(path, head_limit=400_000, tail_limit=131072):
path = Path(path)
size = path.stat().st_size
with path.open("rb") as handle:
head = handle.read(head_limit).decode("utf-8", "replace")
if size <= head_limit:
return head, ""
handle.seek(max(0, size - tail_limit))
tail = handle.read(tail_limit).decode("utf-8", "replace")
return head, tail
def inspect_file(path):
path = Path(path)
empty = {
"kind": "gcode",
"printer_family": "",
"filaments": [],
"weight_g": 0,
"estimated_s": 0,
"thumb_entry": "",
"plate": "",
"ace_trailer": False,
"slice_meta": {},
}
lower = path.name.lower()
if lower.endswith(".gcode") and not lower.endswith(".3mf"):
text, trailer = _read_gcode_head_tail(path)
meta = parse_gcode(text, trailer)
meta["kind"] = "gcode"
meta["plate"] = ""
meta["ace_trailer"] = path_has_ace_trailer(path, kind="gcode")
return meta
if not zipfile.is_zipfile(path):
empty["kind"] = "3mf" if lower.endswith(".3mf") else "gcode"
return empty
with zipfile.ZipFile(path) as archive:
names = [name for name in archive.namelist() if safe_zip_name(name) and not name.startswith("__MACOSX")]
plate = pick_name(names, ".gcode", "plate_1.gcode")
thumb = pick_name(names, ".png", "plate_1.png")
text = ""
trailer = ""
ace_trailer = False
if plate:
with archive.open(plate) as handle:
ace_trailer, head = _stream_has_ace_trailer(handle)
text = head.decode("utf-8", "replace")
# Trailer: Ende der Plate-Datei nachlesen
try:
info = archive.getinfo(plate)
if info.file_size > len(head):
with archive.open(plate) as again:
again.read(max(0, info.file_size - 131072))
trailer = again.read(131072).decode("utf-8", "replace")
except (OSError, RuntimeError, KeyError):
trailer = ""
meta = parse_gcode(text, trailer) if text else dict(empty)
meta["kind"] = "gcode.3mf" if plate else "3mf"
meta["plate"] = plate
meta["thumb_entry"] = thumb or meta.get("thumb_entry") or ""
meta["ace_trailer"] = ace_trailer
if not meta["printer_family"]:
for entry in names:
if entry.lower().endswith("project_settings.config"):
raw = archive.read(entry)[:200_000]
try:
model = str(json.loads(raw).get("printer_model") or "")
except (json.JSONDecodeError, UnicodeDecodeError, AttributeError):
model = raw.decode("utf-8", "replace")
meta["printer_family"] = family_from_text(model)
break
return meta
File diff suppressed because it is too large. Load diff
+44
View File
@@ -0,0 +1,44 @@
"""Die Drucker: Familie aus dem Namen, die Liste selbst liegt in PostgreSQL."""
import json
FAMILY_LABELS = {"kobra_s1": "Kobra S1", "kobra_3": "Kobra 3"}
def family_from_name(name):
low = name.lower()
if "s1" in low:
return "kobra_s1"
if "kobra 3" in low or "kobra3" in low:
return "kobra_3"
return ""
def family_from_text(text):
low = (text or "").lower()
if "kobra s1" in low:
return "kobra_s1"
if "kobra 3" in low or "kobra3" in low:
return "kobra_3"
return ""
def load_printers(path):
rows = json.loads(path.read_text(encoding="utf-8"))
printers = []
for row in rows:
family = row.get("family") or family_from_name(row["name"])
printers.append({
"name": row["name"],
"host": row["host"],
"family": family,
"family_label": FAMILY_LABELS.get(family, family),
})
return printers
def printer_by_host(path, host):
for printer in load_printers(path):
if printer["host"] == host:
return printer
return None
+182
View File
@@ -0,0 +1,182 @@
CREATE TABLE IF NOT EXISTS projects (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
notes text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'idee',
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS spools (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
brand text NOT NULL DEFAULT '',
material_type text NOT NULL DEFAULT '',
color text NOT NULL DEFAULT '',
price_per_kg double precision NOT NULL DEFAULT 0,
density double precision NOT NULL DEFAULT 1.24,
volumetric double precision NOT NULL DEFAULT 12,
remaining_g double precision NOT NULL DEFAULT 1000,
printer_host text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS files (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
name text NOT NULL,
stored_path text NOT NULL,
source text NOT NULL,
kind text NOT NULL,
printer_family text NOT NULL DEFAULT '',
filaments jsonb NOT NULL DEFAULT '[]'::jsonb,
weight_g double precision NOT NULL DEFAULT 0,
estimated_s double precision NOT NULL DEFAULT 0,
thumb_entry text NOT NULL DEFAULT '',
plate text NOT NULL DEFAULT '',
size_bytes bigint NOT NULL DEFAULT 0,
mtime double precision NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (source, stored_path)
);
ALTER TABLE files ADD COLUMN IF NOT EXISTS slice_meta jsonb NOT NULL DEFAULT '{}'::jsonb;
CREATE TABLE IF NOT EXISTS sends (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
printer_host text NOT NULL,
filename text NOT NULL,
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE sends ADD COLUMN IF NOT EXISTS ace_map jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE sends ADD COLUMN IF NOT EXISTS bed_leveling boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS runs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
printer_host text NOT NULL,
printer_name text NOT NULL DEFAULT '',
filename text NOT NULL DEFAULT '',
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
state text NOT NULL DEFAULT '',
started_at timestamptz,
ended_at timestamptz,
duration_s double precision NOT NULL DEFAULT 0,
weight_g double precision NOT NULL DEFAULT 0,
filament_cost double precision NOT NULL DEFAULT 0,
energy_cost double precision NOT NULL DEFAULT 0,
moonraker_job_id text NOT NULL,
deducted boolean NOT NULL DEFAULT false,
needs_spool boolean NOT NULL DEFAULT false,
verdict text NOT NULL DEFAULT '',
material_type text NOT NULL DEFAULT '',
UNIQUE (printer_host, moonraker_job_id)
);
CREATE INDEX IF NOT EXISTS files_project ON files (project_id);
CREATE INDEX IF NOT EXISTS runs_started ON runs (started_at DESC);
CREATE TABLE IF NOT EXISTS printers (
id uuid PRIMARY KEY,
name text NOT NULL,
host text NOT NULL UNIQUE,
family text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE printers ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS ideas (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
body text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'offen',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS materials (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
brand text NOT NULL DEFAULT '',
material_type text NOT NULL DEFAULT '',
density double precision NOT NULL,
volumetric double precision NOT NULL,
volumetric_s1 double precision,
machines text NOT NULL DEFAULT 'beide',
price_per_kg double precision NOT NULL DEFAULT 0,
note text NOT NULL DEFAULT ''
);
ALTER TABLE spools ADD COLUMN IF NOT EXISTS colors jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS ace_slot smallint;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS material_id bigint REFERENCES materials (id) ON DELETE SET NULL;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS chargen_nr text;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS hersteller_charge text NOT NULL DEFAULT '';
ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text;
CREATE UNIQUE INDEX IF NOT EXISTS spools_chargen_nr ON spools (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
CREATE UNIQUE INDEX IF NOT EXISTS spools_code ON spools (code) WHERE code IS NOT NULL AND code <> '';
-- Ein ACE-Platz gehört höchstens einer Spule.
CREATE UNIQUE INDEX IF NOT EXISTS spools_host_slot
ON spools (printer_host, ace_slot)
WHERE printer_host <> '' AND ace_slot IS NOT NULL;
CREATE TABLE IF NOT EXISTS events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
host text NOT NULL DEFAULT '',
printer text NOT NULL DEFAULT '',
kind text NOT NULL,
message text NOT NULL DEFAULT '',
filename text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
read_at timestamptz
);
CREATE INDEX IF NOT EXISTS events_unread ON events (read_at, created_at DESC);
CREATE TABLE IF NOT EXISTS print_queue (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
file_id bigint NOT NULL REFERENCES files (id) ON DELETE CASCADE,
host text NOT NULL DEFAULT '',
family text NOT NULL DEFAULT '',
priority integer NOT NULL DEFAULT 100,
status text NOT NULL DEFAULT 'waiting',
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
ace_map jsonb NOT NULL DEFAULT '[]'::jsonb,
bed_leveling boolean NOT NULL DEFAULT false,
override_machine boolean NOT NULL DEFAULT false,
message text NOT NULL DEFAULT '',
steps jsonb NOT NULL DEFAULT '[]'::jsonb,
step_index integer NOT NULL DEFAULT 0,
repeat_total integer NOT NULL DEFAULT 1,
repeat_done integer NOT NULL DEFAULT 0,
phase text NOT NULL DEFAULT '',
phase_started_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS steps jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS step_index integer NOT NULL DEFAULT 0;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_total integer NOT NULL DEFAULT 1;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_done integer NOT NULL DEFAULT 0;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase text NOT NULL DEFAULT '';
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase_started_at timestamptz;
CREATE INDEX IF NOT EXISTS print_queue_waiting ON print_queue (status, priority, id);
CREATE INDEX IF NOT EXISTS print_queue_running_host ON print_queue (host, status) WHERE status = 'running';
-- Nach dem Druck: Platte manuell quittieren, bevor „wenn frei“ den nächsten Job startet.
CREATE TABLE IF NOT EXISTS bed_status (
host text PRIMARY KEY,
needs_clear boolean NOT NULL DEFAULT false,
cleared_at timestamptz,
message text NOT NULL DEFAULT ''
);
+218
View File
@@ -0,0 +1,218 @@
"""Hintergrund-Queue für Senden und Starten an Moonraker."""
from __future__ import annotations
import threading
import time
import uuid
from collections import deque
from queue import Empty, Queue
from grotti import jobs
from grotti.db import connect
from grotti.errors import GrottiError
class SendQueue:
def __init__(self, database_url, cfg, workers=4):
self.database_url = database_url
self.cfg = cfg
self._lock = threading.Lock()
self._pending = Queue()
self._jobs = {}
self._order = deque(maxlen=60)
self._threads = []
for index in range(max(1, int(workers))):
thread = threading.Thread(
target=self._worker,
name="grotti-send-queue-%s" % (index + 1),
daemon=True,
)
thread.start()
self._threads.append(thread)
def enqueue(
self,
conn,
file_id,
host,
confirm,
override,
spool_id,
ace_map=None,
bed_leveling=False,
):
jobs_out = self.enqueue_many(
conn,
file_id,
[host],
confirm,
override,
spool_id,
{host: ace_map or []} if ace_map else {},
bed_leveling=bed_leveling,
)
return jobs_out[0]
def enqueue_many(
self,
conn,
file_id,
hosts,
confirm,
override,
spool_id,
ace_maps=None,
bed_leveling=False,
):
if not confirm:
raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung")
hosts = [str(host or "").strip() for host in (hosts or []) if str(host or "").strip()]
if not hosts:
raise GrottiError("Mindestens einen Drucker wählen.", "drucker")
seen = set()
unique = []
for host in hosts:
if host in seen:
continue
seen.add(host)
unique.append(host)
ace_maps = ace_maps if isinstance(ace_maps, dict) else {}
# Eine Batch-Option für alle Drucker im Multi-Send (nicht pro Host).
want_level = bool(bed_leveling)
created = []
for host in unique:
printer, row, chosen = jobs.prepare_send(conn, file_id, host, True, override, spool_id)
job_id = uuid.uuid4().hex[:12]
mapping = ace_maps.get(host) or ace_maps.get(printer["name"]) or []
job = {
"id": job_id,
"status": "pending",
"progress": 0,
"step": "warteschlange",
"message": "In der Warteschlange…",
"file_id": row["id"],
"filename": row["name"],
"host": host,
"printer": printer["name"],
"spool_id": chosen,
"override_machine": bool(override),
"ace_map": mapping,
"bed_leveling": want_level,
"result": None,
"error": None,
"created_at": time.time(),
"updated_at": time.time(),
}
with self._lock:
self._jobs[job_id] = job
self._order.appendleft(job_id)
self._pending.put(
{
"id": job_id,
"file_id": row["id"],
"host": host,
"override": bool(override),
"spool_id": chosen,
"ace_map": mapping,
"bed_leveling": want_level,
}
)
created.append(self.snapshot(job_id))
return created
def get(self, job_id):
return self.snapshot(job_id)
def list_jobs(self, limit=20):
with self._lock:
ids = list(self._order)[: max(1, int(limit))]
return [dict(self._jobs[job_id]) for job_id in ids if job_id in self._jobs]
def snapshot(self, job_id):
with self._lock:
job = self._jobs.get(job_id)
return dict(job) if job else None
def _update(self, job_id, **fields):
with self._lock:
job = self._jobs.get(job_id)
if not job:
return
job.update(fields)
job["updated_at"] = time.time()
def _worker(self):
while True:
try:
item = self._pending.get(timeout=1)
except Empty:
continue
self._run(item)
def _run(self, item):
job_id = item["id"]
def on_progress(step, progress, message):
self._update(
job_id,
status="running",
step=step,
progress=int(progress),
message=message,
)
self._update(
job_id,
status="running",
step="vorbereiten",
progress=5,
message="Vorbereitung…",
)
try:
with connect(self.database_url) as conn:
result = jobs.send_file(
conn,
self.cfg,
item["file_id"],
item["host"],
True,
item["override"],
item["spool_id"],
on_progress=on_progress,
prepared=True,
ace_map=item.get("ace_map"),
bed_leveling=bool(item.get("bed_leveling")),
)
conn.commit()
done_msg = "Gestartet auf %s: %s" % (result["printer"], result["filename"])
warnings = result.get("warnings") or []
if warnings:
done_msg += " · " + " · ".join(warnings)
self._update(
job_id,
status="done",
step="fertig",
progress=100,
message=done_msg,
result=result,
error=None,
)
except GrottiError as exc:
self._update(
job_id,
status="error",
step="fehler",
progress=100,
message=exc.message,
error={"message": exc.message, "code": exc.code},
)
except Exception as exc:
self._update(
job_id,
status="error",
step="fehler",
progress=100,
message="Senden ist fehlgeschlagen.",
error={"message": str(exc) or "Senden ist fehlgeschlagen.", "code": "intern"},
)
+1282
View File
File diff suppressed because it is too large. Load diff
+142
View File
@@ -0,0 +1,142 @@
"""G-code zu einem dünnen 3D-Pfad für den Viewer."""
import re
import zipfile
from pathlib import Path
MOVE = re.compile(r"^(?:G0|G00|G1|G01)\b(.*)$", re.IGNORECASE)
# Auch E.39 / Z.6 ohne führende Null (Anycubic/Prusa-Stil)
AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
# Cache-/Viewer-Version: Segment-IDs, kein Sehnen-Thinning
TOOLPATH_VERSION = 5
def _gcode_text(path: Path) -> str:
name = path.name.lower()
if name.endswith(".3mf") or name.endswith(".gcode.3mf"):
with zipfile.ZipFile(path) as archive:
names = [
item
for item in archive.namelist()
if item.lower().endswith(".gcode") and not item.endswith("/")
]
if not names:
return ""
plate = next((item for item in names if "plate" in item.lower()), names[0])
return archive.read(plate).decode("utf-8", "replace")
return path.read_text(encoding="utf-8", errors="replace")
def _thin_points(points, layer_breaks, max_points):
"""Extrusions-Segmente stichprobenartig behalten — nie Endpunkte einer Bahn
weglassen und die Nachbarn verbinden (sonst Sehnen statt Infill)."""
if len(points) <= max_points:
return points, layer_breaks
extrude_ends = [i for i in range(1, len(points)) if points[i][3]]
keep = {0, len(points) - 1}
for break_i in layer_breaks:
keep.add(max(0, min(len(points) - 1, break_i)))
if break_i > 0:
keep.add(break_i - 1)
# Budget für Segment-Endpunkte (jedes Segment braucht 2 Indizes, viele teilen sich)
target_segs = max(2000, max_points // 2)
step = max(1, (len(extrude_ends) + target_segs - 1) // target_segs)
for j, end_i in enumerate(extrude_ends):
if j % step == 0:
keep.add(end_i - 1)
keep.add(end_i)
ordered = sorted(keep)
# Falls immer noch zu groß: Segmente grober
if len(ordered) > max_points:
step2 = max(1, (len(ordered) + max_points - 1) // max_points)
ordered = ordered[::step2]
if ordered[-1] != len(points) - 1:
ordered.append(len(points) - 1)
index_map = {old: new for new, old in enumerate(ordered)}
kept = [points[i] for i in ordered]
new_breaks = sorted({
index_map[b]
for b in layer_breaks
if b in index_map
})
if not new_breaks:
new_breaks = [0]
return kept, new_breaks
def build_toolpath(path: Path, max_points=120000):
text = _gcode_text(path)
if not text:
return {
"points": [],
"layer_breaks": [],
"count": 0,
"version": TOOLPATH_VERSION,
}
x = y = z = 0.0
e = 0.0
absolute_e = True
points = []
layer_breaks = [0]
last_z = None
seq = 0
for raw in text.splitlines():
line = raw.split(";", 1)[0].strip()
if not line:
continue
upper = line.upper()
if upper.startswith("M82"):
absolute_e = True
continue
if upper.startswith("M83"):
absolute_e = False
continue
match = MOVE.match(line)
if not match:
continue
nx, ny, nz, ne = x, y, z, e
saw_e = False
for axis, value in AXIS.findall(match.group(1)):
number = float(value)
axis = axis.upper()
if axis == "X":
nx = number
elif axis == "Y":
ny = number
elif axis == "Z":
nz = number
elif axis == "E":
saw_e = True
ne = number if absolute_e else e + number
if nx == x and ny == y and nz == z:
if saw_e:
e = ne
continue
extrude = 1 if saw_e and ne > e + 1e-6 else 0
x, y, z = nx, ny, nz
if saw_e:
e = ne
if last_z is None:
last_z = z
elif abs(z - last_z) > 0.01:
layer_breaks.append(len(points))
last_z = z
# seq = Originalindex — Viewer verbindet nur seq und seq+1
points.append([round(x, 3), round(y, 3), round(z, 3), extrude, seq])
seq += 1
points, layer_breaks = _thin_points(points, layer_breaks, max_points)
return {
"points": points,
"layer_breaks": layer_breaks,
"count": len(points),
"version": TOOLPATH_VERSION,
}
+3
View File
@@ -0,0 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "2.0.0"
+197
View File
@@ -0,0 +1,197 @@
"""Drucker-Zustände beobachten → events + optional Telegram."""
from __future__ import annotations
import threading
import time
from grotti import moonraker, notify, store
from grotti.db import connect
class StateWatcher:
def __init__(self, database_url, cfg, interval=10):
self.database_url = database_url
self.cfg = cfg
self.interval = max(5, int(interval))
self._prev = {}
self._offline_hits = {}
self.latest = []
self._lock = threading.Lock()
self._updated_at = 0.0
self._stop = threading.Event()
self._thread = None
def start(self):
if self._thread and self._thread.is_alive():
return
self._thread = threading.Thread(
target=self._loop,
name="grotti-watcher",
daemon=True,
)
self._thread.start()
def stop(self):
self._stop.set()
def snapshot(self):
"""Kopie des letzten Live-Stands (für API und Dispatcher)."""
with self._lock:
return [dict(row) for row in self.latest]
def by_host(self):
return {row.get("host"): row for row in self.snapshot() if row.get("host")}
def age_s(self):
if not self._updated_at:
return None
return time.monotonic() - self._updated_at
def _loop(self):
while not self._stop.is_set():
try:
self.tick()
except Exception as exc:
print("Watcher: %s" % exc, flush=True)
self._stop.wait(self.interval)
def tick(self):
with connect(self.database_url) as conn:
printers = store.list_printers(conn)
if not printers:
conn.commit()
return
lives = moonraker.snapshot(printers)
with self._lock:
self.latest = lives
self._updated_at = time.monotonic()
for live in lives:
self._one(conn, live)
conn.commit()
def _one(self, conn, live):
host = live.get("host") or ""
name = live.get("name") or host
state = (live.get("state") or "").lower()
filename = live.get("filename") or ""
offline = live.get("ok") is False or state in ("", "offline")
prev = self._prev.get(host) or {}
prev_state = (prev.get("state") or "").lower()
prev_offline = bool(prev.get("offline"))
if offline:
hits = self._offline_hits.get(host, 0) + 1
self._offline_hits[host] = hits
if hits >= 2 and not prev_offline:
self._emit(conn, host, name, "offline", "%s ist offline." % name, filename)
self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
return
self._offline_hits[host] = 0
if prev_offline:
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename)
elif prev_state and prev_state not in ("printing", "paused") and state == "printing":
self._emit(conn, host, name, "start", "%s druckt %s" % (name, filename or "einen Auftrag"), filename)
elif prev_state == "printing" and state == "paused":
self._emit(conn, host, name, "start", "%s pausiert." % name, filename)
elif prev_state == "paused" and state == "printing":
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename)
elif prev_state == "printing" and state == "complete":
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
self._emit(
conn,
host,
name,
"fertig",
"%s fertig: %s" % (name, filename or "Druck"),
filename,
)
# Protokoll und Spulenabzug nicht erst nach manuellem Knopf
try:
from grotti import jobs
jobs.sync_history(conn, self.cfg)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"):
store.set_bed_needs_clear(conn, host, True, filename or state)
self._emit(
conn,
host,
name,
"abbruch",
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"),
filename,
)
elif state == "error" and prev_state != "error":
if prev_state == "printing" or prev_state == "paused":
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
msg = (live.get("message") or "").strip()
if not msg or msg.lower() in ("error", "fehler"):
msg = "Ursache unbekannt — in Mainsail prüfen"
self._cool_heaters(host)
self._emit(
conn,
host,
name,
"fehler",
"%s: %s · Heizung aus" % (name, msg),
filename,
)
elif state == "error":
# Schon im Fehler, Heizer laufen noch → aus
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
self._cool_heaters(host)
elif state == "complete":
known = conn.execute(
"SELECT 1 FROM bed_status WHERE host = %s", (host,)
).fetchone()
if not known:
# Erster Stand nach Deploy: fertiger Druck ohne Quittierung
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
spool_warn = bool(prev.get("spool_warn"))
if state == "printing" and not spool_warn:
spool_warn = self._check_spool(conn, host, name, filename, live)
self._prev[host] = {
"state": state,
"offline": False,
"filename": filename,
"spool_warn": spool_warn if state == "printing" else False,
}
def _cool_heaters(self, host):
try:
moonraker.heaters_off(host)
except Exception as exc:
print("Watcher Heizung aus %s: %s" % (host, exc), flush=True)
def _check_spool(self, conn, host, name, filename, live):
need = float(live.get("weight") or 0)
if need <= 0 or not filename:
return False
sent = store.latest_send(conn, host, filename)
spool_id = (sent or {}).get("spool_id")
if not spool_id:
return False
spool = store.get_spool_row(conn, spool_id)
if not spool:
return False
left = float(spool["remaining_g"] or 0)
if left + 0.5 >= need:
return False
self._emit(
conn,
host,
name,
"spule",
"%s: Spule „%s“ hat nur %.0f g, Druck braucht %.0f g."
% (name, spool["name"], left, need),
filename,
)
return True
def _emit(self, conn, host, printer, kind, message, filename):
notify.record(conn, self.cfg, host, printer, kind, message, filename, dedupe_s=60)
+56
View File
@@ -0,0 +1,56 @@
# Homepage
Intern. Liegt nicht unter `docs/` und erscheint nicht unter `/doku/`.
Die öffentliche Vorschau ist [`site/index.html`](../site/index.html). Sie ersetzt die Druckerwand nicht. Die Route `/` bleibt die Wand der laufenden Farm.
Rechtssätze nicht abwandeln. Wortlaut in [Recht.md](Recht.md), öffentlich in `docs/Haftung.md` und `docs/Voraussetzungen.md`.
**Entwurf** steht an jedem rechtlichen Block (Fuß, Garantie-Frage). Die übrige Seite ist die Produktvorschau, ohne Preis, ohne Shop, ohne AGB und ohne Impressum.
## Aufbau
1. Überschrift: „Eine lokale Seite für die Drucker in der Werkstatt.“ Darunter: „Warteschlange, Abräumen, Material und Kosten. Im eigenen Netz.“
2. Drei Karten: Wand, Warteschlange „wenn frei“, Ablauf auf der Kobra 3. Daneben Material, Projekte, Kosten, Telegram.
3. Kasten Voraussetzung: Moonraker im eigenen Netz.
4. Kasten vor dem ersten Start: Hitze und Brandgefahr, sichtbar, nicht nur im Fuß.
5. Was sie nicht ist.
6. Vergleich in Sätzen, Stand 14.10.2026, keine Preistabelle.
7. FAQ: welche Drucker, wer einrichtet, Garantie.
8. Fuß: Entwurf Haftung, Markenhinweis.
Hinweis im Kopf: „Vorschau. Die Druckerwand der laufenden Farm bleibt unter /.“
## Festgelegte Sätze
Wand: „Eine Karte je Drucker mit Live-Zustand: Fortschritt, Temperatur, Kamera. Pause, Abbrechen und Fehler löschen bleiben auf der Karte.“
Warteschlange: „‚Wenn frei‘ hält den Auftrag, bis der Drucker frei ist. Die Priorität lässt sich ändern.“
Kobra 3: „Auf der Kobra 3 kann ein Ablauf abkühlen, schieben und tasten.“
Daneben: „Material: Spulen, Ort und Rest. Projekte: Zuordnung und Kalkulation. Kosten: Strom aus Watt und Preis, im Protokoll. Telegram: Nachricht, wenn ein eingestelltes Ereignis kommt.“
Voraussetzung: „Moonraker im eigenen Netz. Bewährt auf der Kobra 3 und der Kobra S1, nachdem Rinkhals läuft. Rinkhals installiert der Nutzer selbst. Andere Drucker nur, wenn Moonraker dort schon erreichbar ist. Bambu Lab, Prusa und Creality Cloud bleiben bei deren eigenen Programmen.“
Grenze: „Kein Slicer. Keine Cloud. Keine Fehlererkennung aus dem Kamerabild. Kein Ersatz für Bambu Farm Manager oder Prusa Connect.“
Vergleich: „Bambu Farm Manager ist das lokale Programm von Bambu Lab, kostenlos und nur für Bambu-Drucker. Prusa Connect ist kostenlos und nur für Prusa. Creality Cloud deckt die Creality-Flotte ab. SimplyPrint und Printago sind Cloud-Dienste für viele Marken, mit Abo. Die Farm ist die lokale deutsche Seite für Drucker, die schon Moonraker sprechen. Bewährt ist das auf der Kobra 3 und der Kobra S1, nachdem Rinkhals läuft.“
FAQ Drucker: „Gemeint sind die Kobra 3 und die Kobra S1, sobald Moonraker über Rinkhals erreichbar ist. Der Ablauf Abkühlen, Schieben, Tasten ist für die Kobra 3. An anderen Modellen ist die Farm nicht bewiesen. Ein Drucker, auf dem Moonraker schon erreichbar ist, kann dazukommen, ohne eigene Anleitung.“
FAQ Einrichten: „Der Nutzer. Die Farm flasht nicht, schickt keine Firmware und beschreibt keine Signatur-Umgehung, keinen Root-Exploit und keine Umgehung einer Downgrade-Sperre. Es gibt kein Auftrags-Flashen und keine vorgeflashten Drucker. Verlinkt wird nur die Installationsseite von Rinkhals.“
FAQ Garantie, Entwurf, wortgleich zum Grundsatz: Anycubic-Satz, Creality-Satz, Bambu-Satz, Mängelhaftung beim Verkäufer, Disclaimer wischt Verletzung, Vorsatz und grobe Fahrlässigkeit nicht weg.
Marken: „Anycubic, Bambu Lab, Creality, Prusa und Elegoo sind Marken ihrer Inhaber. Die Farm gehört nicht zu diesen Herstellern.“
## Was nicht auf die Seite darf
- Schritte zum Flashen, Rooten, zur Signaturprüfung, zur Downgrade-Sperre oder zur Bambu-Anmeldung.
- Weitere Druckermodelle als bewiesen. Kobra X nicht nennen.
- Markenliste Flashforge, Qidi, Sovol.
- Preis, Shop, AGB, Impressum, Datenschutz als wäre das schon veröffentlicht.
- Preistabelle der anderen Programme. „Kostenlos“ und „Abo“ nur in den festgelegten Sätzen, Stand 14.10.2026.
- Behauptung, die Kamera erkenne Fehler. Die Kamera zeigt ein Bild.
- Abräumen auf der Kobra S1. Das ist offen.
+75
View File
@@ -0,0 +1,75 @@
# Markt
Intern. Liegt nicht unter `docs/` und erscheint nicht unter `/doku/`.
Stand der Recherche: **14.10.2026**.
Garantie, Disclaimer und das Verbot von Umgehungsanleitungen stehen wortgleich in [Recht.md](Recht.md). Diesen Wortlaut hier nicht abwandeln.
## Entscheidung
An den Markt gehen lohnt sich nur schmal: eine lokale, deutschsprachige Farm für Drucker, die schon Moonraker sprechen. Heute ist das die Kobra 3 und die Kobra S1 mit Rinkhals, den der Nutzer selbst installiert. Die Farm verkauft kein Flashen, schickt keine Firmware und bietet keinen Flash-Dienst.
Die Lücke ist die Werkstattseite: Wand, Warteschlange „wenn frei“, auf der Kobra 3 der Ablauf Abkühlen / Schieben / Tasten, dazu Material, Projekte, Kosten und Telegram. Kein Slicer, keine Cloud, keine Fehlererkennung aus dem Kamerabild.
## Was wir bewusst nicht bauen
- Kein Kampf gegen die fertigen Welten: Bambu Farm Manager (lokal, kostenlos, nur Bambu), Prusa Connect (kostenlos, nur Prusa), Creality Cloud (Creality-Flotte), SimplyPrint und Printago (viele Marken, Cloud, Abo).
- Keine eigene Creality-Farm und keine Root-Anleitung.
- Kein Bambu-Adapter in diesem Schritt. Später höchstens neben Kobra-Druckern, und nur über den offiziellen Entwicklermodus.
- Keine Beschreibung der Signaturprüfung an der Kobra X, keiner Downgrade-Sperre bei Elegoo, keiner Umgehung der Bambu-Anmeldung. Keine Schritte, keine Links auf solche Anleitungen.
- Kein Auftrags-Flashen und keine vorgeflashten Drucker.
- Preis, Shop, AGB und Impressum gibt es hier nicht. Vor einem Verkauf kommen sie von außen.
## Hersteller
### Anycubic Kobra 3 und Kobra S1
Das ist der bewiesene Stand. Offiziell gibt es App und Cloud, keinen Farm-Manager. Die Farm spricht Moonraker. Rinkhals legt Moonraker als Aufsatz darüber; die ursprüngliche Software bleibt darunter ([rinkhals-community/Rinkhals](https://github.com/rinkhals-community/Rinkhals)). Printago und SimplyPrint hängen sich per LAN-Client oder Rinkhals daran. Den Aufsatz installiert der Nutzer selbst. Wir verlinken nur die Projektseite.
Kobra X: Rinkhals scheitert an der Signaturprüfung. Diesen Weg beschreiben wir nicht.
Der Ablauf Abkühlen, Schieben, Tasten gilt für die Kobra 3. Die Kobra S1 ist geschlossen, Abräumen dort ist offen.
### Bambu Lab
[Bambu Farm Manager](https://wiki.bambulab.com/en/software/bambu-farm-manager) ist lokal, für Windows, kostenlos, mit Queue, Sammelbefehlen und mehreren Nutzern. Drittsoftware nur im offiziellen Entwicklermodus plus LAN, ohne Cloud. Eigene Firmware (X1 Plus) streicht den Support. Für eine Farm nur aus Bambu-Druckern gibt es keinen Grund, die Farm zu nehmen.
### Creality
Größte Marke, aber zersplittert. K1 und K2 laufen intern mit Klipper, nach außen mit Creality-API und Creality Cloud. Moonraker setzt voraus, dass der Nutzer den Drucker schon geöffnet hat. Diese Schritte schreiben wir nicht. Creality selbst: beim offiziellen Root bleibt der Hardware-Service, Software-Unterstützung dafür gibt es nicht; fremde Firmware ist von der Garantie ausgenommen ([Creality-Garantie](https://wiki.creality.com/en/warranty-policy)). Eine eigene Creality-Farm lohnt nicht. Ein K1, auf dem Moonraker schon erreichbar ist, kann später derselbe Adapter sein wie die Kobra.
### Prusa
Prusa Connect und PrusaLink, offene API, kein Flashen. Gutes Gratis-Werkzeug, nur Prusa. Nicht unser Markt.
### Elegoo Centauri
Stock-LAN ohne Root. OpenCentauri ist eine fremde Firmware; neuere Carbon-2-Stände sperren SSH und Downgrade. Diesen Umbau beschreiben wir nicht. Technisch später interessant, weil kein Flashen nötig wäre, aber ein anderes Protokoll als Moonraker.
### Voron und andere offene Klipper-Drucker
Moonraker ist schon da. Technisch der leichteste Anschluss, wirtschaftlich klein; die Nutzer haben Mainsail oder Fluidd. In der öffentlichen Doku als „Drucker, auf dem Moonraker schon erreichbar ist“, ohne Extra-Anleitung. Bewiesen haben wir die Farm an Kobra 3 und Kobra S1.
### Flashforge, Qidi, Sovol
Eigene Apps, mal Klipper, mal nicht. Keine Markenliste auf der Homepage.
```mermaid
flowchart LR
nutzer[Nutzer]
farm[Farm lokal]
moon[Moonraker]
kobra[Kobra mit Rinkhals]
andere[Klipper schon offen]
bambu[Bambu Farm Manager]
prusa[Prusa Connect]
cloud[SimplyPrint Printago Creality Cloud]
nutzer --> farm
farm --> moon
moon --> kobra
moon --> andere
nutzer --> bambu
nutzer --> prusa
nutzer --> cloud
```
+51
View File
@@ -0,0 +1,51 @@
# Recht
Intern. Liegt nicht unter `docs/` und erscheint nicht unter `/doku/`.
**Entwurf.** Kein anwaltlich geprüfter Text. Vor einem Verkauf mit Preis, Abo oder Shop kommen Impressum, Datenschutz und AGB von außen dazu. Diese Seiten nennen keinen Preis.
Der folgende Grundsatz steht wortgleich in `docs/Haftung.md`, in `docs/Voraussetzungen.md` und im Fuß der Homepage. Nicht umformulieren.
## Grundsatz
Die Farm flasht nicht, schickt keine Firmware und beschreibt keine Signatur-Umgehung, keinen Root-Exploit und keine Umgehung einer Downgrade-Sperre.
Der Nutzer richtet seinen eigenen Drucker ein, bevor er die Farm nutzt. Verlinkt wird nur die Installationsseite von Rinkhals: <https://github.com/rinkhals-community/Rinkhals>. Die Warnung, die der Hersteller selbst einblendet, bleibt seine.
Anycubic schließt ungenehmigte Änderungen von der Garantie aus. Rinkhals ist ein Community-Aufsatz; die ursprüngliche Software bleibt darunter, der Aufsatz lässt sich wieder entfernen. Ob Anycubic einen Fall annimmt, entscheidet Anycubic.
Creality lässt beim offiziellen Root den Hardware-Service stehen und gibt dafür keine Software-Unterstützung. Fremde Firmware ist von der Garantie ausgenommen (<https://wiki.creality.com/en/warranty-policy>).
Bambu Lab deckt Schäden durch inoffizielle Firmware nicht ab. Der Entwicklermodus ist eine eigene Funktion von Bambu Lab, mit der Warnung, die Bambu Lab selbst einblendet.
Die gesetzliche Mängelhaftung für den Drucker bleibt beim Verkäufer des Druckers. Ein Disclaimer der Farm wischt Verletzung, Vorsatz und grobe Fahrlässigkeit nicht weg.
Unbeaufsichtigtes Drucken, Hitze und Brandgefahr stehen vor dem ersten Start, nicht im Kleingedruckten.
Es gibt kein Auftrags-Flashen und keine vorgeflashten Drucker.
## Was der Disclaimer nicht darf
Der Hinweis begrenzt nicht die Haftung für Verletzung von Leben, Körper oder Gesundheit, nicht Vorsatz und nicht grobe Fahrlässigkeit. Die gesetzliche Mängelhaftung für den Drucker bleibt beim Verkäufer des Druckers. Die Farm ist nicht dieser Verkäufer, solange sie keine Drucker verkauft.
Den Hinweis nicht ins Kleingedruckte schieben. Hitze, Brandgefahr und unbeaufsichtigtes Drucken stehen vor dem ersten Start, auf der Homepage im sichtbaren Kasten und in der Doku unter Voraussetzungen und Haftung.
AGB, Impressum und Datenschutz nicht so tun, als seien sie schon veröffentlicht.
## Umgehungsanleitungen
Keine Anleitung, die eine technische Schutzmaßnahme umgeht. Das gilt intern und außen. Nicht beschreiben, auch nicht als Schritte in dieser Notiz:
- die Signaturprüfung der Kobra X
- eine Downgrade-Sperre bei Elegoo
- eine Umgehung der Anmeldung bei Bambu Lab
Erlaubt ist der Verweis auf die Installationsseite von Rinkhals und auf die Warnung, die der Hersteller selbst zeigt. Kein Link auf Root-Skripte, fremde Firmware oder Sperr-Umgehungen.
## Nähe zum Gerät
Kein Auftrags-Flashen und keine vorgeflashten Drucker. Sonst rückt die Farm näher an das Gerät als eine Steuerungssoftware.
## Was die Software schon einhält
Druckstarts brauchen `confirm: true`. Es gibt keine freie G-code-Konsole und kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Siehe [README](../README.md).
+5
View File
@@ -0,0 +1,5 @@
flask>=3.1
psycopg[binary]>=3.2
paho-mqtt>=2.1
cryptography>=42
markdown>=3.6
+40
View File
@@ -0,0 +1,40 @@
"""Grotti starten. Kern und Seiten liegen daneben."""
import threading
from grotti import jobs, library, store
from grotti.config import load
from grotti.db import connect, prepare
from web.app import create_app
def background(cfg):
def work():
try:
with connect(cfg["database_url"]) as conn:
library.sync_watch(conn, cfg, force=False)
jobs.sync_history(conn, cfg)
conn.commit()
except Exception as exc:
print("Abgleich: %s" % exc, flush=True)
threading.Thread(target=work, name="grotti-abgleich", daemon=True).start()
def main():
cfg = load()
cfg["data_dir"].mkdir(parents=True, exist_ok=True)
(cfg["data_dir"] / "eingang").mkdir(exist_ok=True)
prepare(cfg["database_url"])
with connect(cfg["database_url"]) as conn:
store.seed_printers(conn, cfg["printers_file"])
store.seed_materials(conn)
conn.commit()
app = create_app(cfg)
background(cfg)
print("Grotti: http://%s:%s/" % (cfg["host"], cfg["port"]), flush=True)
app.run(host=cfg["host"], port=cfg["port"], threaded=True, use_reloader=False)
if __name__ == "__main__":
main()
+294
View File
@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Farm — Drucker in der Werkstatt</title>
<style>
:root {
color-scheme: dark;
--bg: #0a0a0c;
--ink: #f0f0f2;
--muted: #9a9aa3;
--faint: #6b6b75;
--line: rgba(255, 255, 255, 0.08);
--line-strong: rgba(255, 255, 255, 0.14);
--card: #141418;
--accent: #f97316;
--accent-ink: #1a0a00;
--accent-soft: rgba(249, 115, 22, 0.18);
--warn: #f87171;
--warn-soft: rgba(248, 113, 113, 0.12);
--radius: 14px;
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
}
* { box-sizing: border-box; }
html { scrollbar-color: rgba(255, 255, 255, 0.14) transparent; }
body {
margin: 0;
font: 14.5px/1.45 "Segoe UI", system-ui, -apple-system, sans-serif;
color: var(--ink);
background:
radial-gradient(800px 400px at 0% 0%, rgba(249, 115, 22, 0.07), transparent 55%),
var(--bg);
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); transition: color 0.15s var(--ease); }
a:hover { color: #fdba74; }
:focus-visible { outline: 2px solid rgba(249, 115, 22, 0.75); outline-offset: 2px; }
.wrap { max-width: 960px; margin: 0 auto; padding: 1.25rem 1.35rem 3rem; }
header.top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 2.2rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--ink);
font-weight: 720;
letter-spacing: -0.03em;
text-decoration: none;
font-size: 1.05rem;
}
.brand::before {
content: "";
width: 0.65rem;
height: 0.65rem;
border-radius: 99px;
background: var(--accent);
box-shadow: 0 0 0 4px var(--accent-soft);
}
.preview {
color: var(--muted);
font-size: 0.8rem;
text-align: right;
}
.nowrap { white-space: nowrap; }
.hero { margin: 0 0 1.75rem; max-width: 38rem; }
.eyebrow {
margin: 0 0 0.55rem;
color: var(--accent);
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
font-weight: 650;
}
h1 {
margin: 0;
font-size: 2.15rem;
line-height: 1.15;
letter-spacing: -0.035em;
font-weight: 680;
}
.lead { margin: 0.75rem 0 0; color: var(--muted); font-size: 1.05rem; max-width: 36rem; }
h2 {
margin: 0 0 0.7rem;
font-size: 0.95rem;
letter-spacing: -0.02em;
font-weight: 650;
}
.cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin: 0 0 0.75rem;
}
.card, .box, .faq details, .aside-row article {
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.card { padding: 1rem 1rem 1.05rem; }
.card h2 { margin-bottom: 0.4rem; }
.card p, .aside-row p, .faq p { margin: 0; color: var(--muted); }
.kicker {
display: block;
color: var(--accent);
font-size: 0.72rem;
letter-spacing: 0.03em;
text-transform: uppercase;
margin-bottom: 0.35rem;
}
.chips {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
margin: 0 0 1.5rem;
}
.chips article { padding: 0.85rem 0.9rem; }
.chips h2 { font-size: 0.88rem; margin-bottom: 0.3rem; }
.box { padding: 1rem 1.1rem; margin: 0 0 0.75rem; }
.box.prereq { box-shadow: inset 3px 0 0 var(--accent); }
.box.heat {
background: var(--warn-soft);
border-color: rgba(248, 113, 113, 0.35);
box-shadow: inset 3px 0 0 var(--warn);
}
.box p { margin: 0.35rem 0 0; color: var(--muted); }
.box p strong { color: var(--ink); font-weight: 650; }
.not { margin: 1.35rem 0 0.4rem; }
.bounds {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin: 0 0 1.5rem;
padding: 0;
list-style: none;
}
.bounds li {
background: var(--card);
border: 1px solid var(--line);
border-radius: 99px;
padding: 0.35rem 0.75rem;
color: var(--muted);
font-size: 0.85rem;
}
.compare { max-width: 40rem; margin-bottom: 1.75rem; }
.compare p { color: var(--muted); margin: 0.45rem 0 0; }
.compare .stand { color: var(--faint); font-size: 0.78rem; margin-top: 0.6rem; }
.faq { display: grid; gap: 0.5rem; margin-bottom: 2rem; }
.faq details { padding: 0.15rem 1rem; }
.faq summary {
cursor: pointer;
padding: 0.75rem 0;
font-weight: 650;
letter-spacing: -0.02em;
}
.faq details p { padding-bottom: 0.85rem; }
.draft {
display: inline-block;
margin-right: 0.35rem;
padding: 0.05rem 0.4rem;
border-radius: 99px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.72rem;
font-weight: 650;
letter-spacing: 0.02em;
vertical-align: 0.05em;
}
footer {
border-top: 1px solid var(--line);
padding-top: 1.1rem;
color: var(--muted);
font-size: 0.8rem;
}
footer p { margin: 0.45rem 0 0; max-width: 46rem; }
footer .copy { margin-top: 1rem; }
@media (max-width: 760px) {
h1 { font-size: 1.65rem; }
.cards, .chips { grid-template-columns: 1fr; }
header.top { align-items: flex-start; flex-direction: column; }
.preview { text-align: left; }
}
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div class="brand">Farm</div>
<p class="preview">Vorschau. Die Druckerwand der laufenden Farm bleibt <span class="nowrap">unter /</span>.</p>
</header>
<section class="hero">
<p class="eyebrow">Lokal, deutsch, Moonraker</p>
<h1>Eine lokale Seite für die Drucker in der Werkstatt.</h1>
<p class="lead">Warteschlange, Abräumen, Material und Kosten. Im eigenen Netz.</p>
</section>
<section class="cards" aria-label="Was die Farm zusammen zeigt">
<article class="card">
<span class="kicker">Wand</span>
<h2>Live-Zustand</h2>
<p>Eine Karte je Drucker mit Live-Zustand: Fortschritt, Temperatur, Kamera. Pause, Abbrechen und Fehler löschen bleiben auf der Karte.</p>
</article>
<article class="card">
<span class="kicker">Warteschlange</span>
<h2>Wenn frei</h2>
<p>„Wenn frei“ hält den Auftrag, bis der Drucker frei ist. Die Priorität lässt sich ändern.</p>
</article>
<article class="card">
<span class="kicker">Kobra 3</span>
<h2>Abkühlen, schieben, tasten</h2>
<p>Auf der Kobra 3 kann ein Ablauf abkühlen, schieben und tasten.</p>
</article>
</section>
<section class="chips" aria-label="Daneben">
<article class="card">
<h2>Material</h2>
<p>Spulen, Ort und Rest.</p>
</article>
<article class="card">
<h2>Projekte</h2>
<p>Zuordnung und Kalkulation.</p>
</article>
<article class="card">
<h2>Kosten</h2>
<p>Strom aus Watt und Preis, im Protokoll.</p>
</article>
<article class="card">
<h2>Telegram</h2>
<p>Nachricht, wenn ein eingestelltes Ereignis kommt.</p>
</article>
</section>
<section class="box prereq" aria-labelledby="vor">
<h2 id="vor">Voraussetzung: Moonraker im eigenen Netz</h2>
<p>Bewährt auf der Kobra 3 und der Kobra S1, nachdem Rinkhals läuft. Rinkhals installiert der Nutzer selbst. Andere Drucker nur, wenn Moonraker dort schon erreichbar ist. Bambu Lab, Prusa und Creality Cloud bleiben bei deren eigenen Programmen.</p>
<p>Verlinkt wird nur die <a href="https://github.com/rinkhals-community/Rinkhals">Installationsseite von Rinkhals</a>. Die Warnung, die der Hersteller selbst einblendet, bleibt seine.</p>
</section>
<section class="box heat" aria-labelledby="start">
<h2 id="start">Vor dem ersten Start</h2>
<p>Unbeaufsichtigtes Drucken, Hitze und Brandgefahr stehen vor dem ersten Start, nicht im Kleingedruckten. Düse und Bett werden heiß. Den ersten Lauf behält der Nutzer im Blick.</p>
</section>
<h2 class="not">Was sie nicht ist</h2>
<ul class="bounds">
<li>Kein Slicer.</li>
<li>Keine Cloud.</li>
<li>Keine Fehlererkennung aus dem Kamerabild.</li>
<li>Kein Ersatz für Bambu Farm Manager oder Prusa Connect.</li>
</ul>
<section class="compare" aria-labelledby="vgl">
<h2 id="vgl">Vergleich</h2>
<p>Bambu Farm Manager ist das lokale Programm von Bambu Lab, kostenlos und nur für Bambu-Drucker. Prusa Connect ist kostenlos und nur für Prusa. Creality Cloud deckt die Creality-Flotte ab. SimplyPrint und Printago sind Cloud-Dienste für viele Marken, mit Abo. Die Farm ist die lokale deutsche Seite für Drucker, die schon Moonraker sprechen. Bewährt ist das auf der Kobra 3 und der Kobra S1, nachdem Rinkhals läuft.</p>
<p class="stand">Stand 14.10.2026. Keine Preisliste.</p>
</section>
<section class="faq" aria-label="Fragen">
<details>
<summary>Welche Drucker sind heute gemeint?</summary>
<p>Gemeint sind die Kobra 3 und die Kobra S1, sobald Moonraker über Rinkhals erreichbar ist. Der Ablauf Abkühlen, Schieben, Tasten ist für die Kobra 3. An anderen Modellen ist die Farm nicht bewiesen. Ein Drucker, auf dem Moonraker schon erreichbar ist, kann dazukommen, ohne eigene Anleitung.</p>
</details>
<details>
<summary>Wer richtet den Drucker ein?</summary>
<p>Der Nutzer. Die Farm flasht nicht, schickt keine Firmware und beschreibt keine Signatur-Umgehung, keinen Root-Exploit und keine Umgehung einer Downgrade-Sperre. Es gibt kein Auftrags-Flashen und keine vorgeflashten Drucker. Verlinkt wird nur die <a href="https://github.com/rinkhals-community/Rinkhals">Installationsseite von Rinkhals</a>.</p>
</details>
<details>
<summary>Was wird aus der Garantie?</summary>
<p><span class="draft">Entwurf</span> Anycubic schließt ungenehmigte Änderungen von der Garantie aus. Rinkhals ist ein Community-Aufsatz; die ursprüngliche Software bleibt darunter, der Aufsatz lässt sich wieder entfernen. Ob Anycubic einen Fall annimmt, entscheidet Anycubic. Creality lässt beim offiziellen Root den Hardware-Service stehen und gibt dafür keine Software-Unterstützung. Fremde Firmware ist von der Garantie ausgenommen (<a href="https://wiki.creality.com/en/warranty-policy">Creality-Garantie</a>). Bambu Lab deckt Schäden durch inoffizielle Firmware nicht ab. Der Entwicklermodus ist eine eigene Funktion von Bambu Lab, mit der Warnung, die Bambu Lab selbst einblendet. Die gesetzliche Mängelhaftung für den Drucker bleibt beim Verkäufer des Druckers. Ein Disclaimer der Farm wischt Verletzung, Vorsatz und grobe Fahrlässigkeit nicht weg.</p>
</details>
</section>
<footer>
<p><span class="draft">Entwurf</span> Kein anwaltlich geprüfter Text. Vor einem Verkauf mit Preis, Abo oder Shop kommen Impressum, Datenschutz und AGB dazu. Diese Seiten nennen keinen Preis.</p>
<p>Die Farm flasht nicht, schickt keine Firmware und beschreibt keine Signatur-Umgehung, keinen Root-Exploit und keine Umgehung einer Downgrade-Sperre. Es gibt kein Auftrags-Flashen und keine vorgeflashten Drucker.</p>
<p>Der Nutzer richtet seinen eigenen Drucker ein, bevor er die Farm nutzt. Verlinkt wird nur die <a href="https://github.com/rinkhals-community/Rinkhals">Installationsseite von Rinkhals</a>. Die Warnung, die der Hersteller selbst einblendet, bleibt seine.</p>
<p>Anycubic schließt ungenehmigte Änderungen von der Garantie aus. Rinkhals ist ein Community-Aufsatz; die ursprüngliche Software bleibt darunter, der Aufsatz lässt sich wieder entfernen. Ob Anycubic einen Fall annimmt, entscheidet Anycubic.</p>
<p>Creality lässt beim offiziellen Root den Hardware-Service stehen und gibt dafür keine Software-Unterstützung. Fremde Firmware ist von der Garantie ausgenommen (<a href="https://wiki.creality.com/en/warranty-policy">Creality-Garantie</a>).</p>
<p>Bambu Lab deckt Schäden durch inoffizielle Firmware nicht ab. Der Entwicklermodus ist eine eigene Funktion von Bambu Lab, mit der Warnung, die Bambu Lab selbst einblendet.</p>
<p>Die gesetzliche Mängelhaftung für den Drucker bleibt beim Verkäufer des Druckers. Ein Disclaimer der Farm wischt Verletzung, Vorsatz und grobe Fahrlässigkeit nicht weg.</p>
<p>Unbeaufsichtigtes Drucken, Hitze und Brandgefahr stehen vor dem ersten Start, nicht im Kleingedruckten.</p>
<p>Anycubic, Bambu Lab, Creality, Prusa und Elegoo sind Marken ihrer Inhaber. Die Farm gehört nicht zu diesen Herstellern.</p>
<p class="copy">© by <a href="mailto:[email protected]">[email protected]</a></p>
</footer>
</div>
</body>
</html>
+198
View File
@@ -0,0 +1,198 @@
"""Tests für Abräum-Bahn und Normalisierung."""
import unittest
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti import eject
from grotti.errors import GrottiError
class EjectTests(unittest.TestCase):
def test_default_steps(self):
steps = eject.normalize_steps([
{"type": "print", "file_id": 1, "bed_leveling": True},
{"type": "cool"},
{"type": "sweep"},
{"type": "check"},
])
self.assertEqual([s["type"] for s in steps], ["print", "cool", "sweep", "check"])
self.assertEqual(steps[0]["file_id"], 1)
self.assertTrue(steps[0]["bed_leveling"])
def test_needs_print(self):
with self.assertRaises(GrottiError):
eject.normalize_steps([{"type": "cool"}])
def test_x_positions_inclusive(self):
sweep = eject.normalize_sweep({"x_from": 25, "x_to": 45, "x_step": 10})
self.assertEqual(eject.x_positions(sweep), [25.0, 35.0, 45.0])
def test_sweep_y_order(self):
with self.assertRaises(GrottiError):
eject.normalize_sweep({"y_from": 5, "y_to": 220})
def test_cool_ready(self):
cool = eject.normalize_cool({"bed_max": 30, "nozzle_max": 45})
self.assertFalse(eject.cool_ready({"bed": 40, "nozzle": 40}, cool))
self.assertTrue(eject.cool_ready({"bed": 28, "nozzle": 40}, cool))
def test_cool_park_center(self):
# Relativ +40, gegen z_max gekappt
low = eject.cool_park_script({"position": [250, 220, 8.0], "z_max": 262}).replace(" ", "")
self.assertIn("G91", low)
self.assertIn("Z40.00", low)
self.assertIn("X125.00", low)
# Nah an Z-max → nur Restweg
near = eject.cool_park_lift_mm({"position": [125, 125, 245.0], "z_max": 250})
self.assertAlmostEqual(near, 5.0)
capped = eject.cool_park_script({"position": [125, 125, 245.0], "z_max": 250}).replace(" ", "")
self.assertIn("Z5.00", capped)
self.assertTrue(
eject.cool_parked({"position": [125, 125, 48]}, before_z=8.0, lift_mm=40.0)
)
self.assertFalse(
eject.cool_parked({"position": [125, 125, 20]}, before_z=8.0, lift_mm=40.0)
)
self.assertFalse(eject.cool_parked({"position": [125, 125, 50]})) # ohne Hub-Nachweis
self.assertFalse(eject.cool_parked({"position": [250, 220, 50]}, before_z=10, lift_mm=40))
def test_sweep_does_not_lower_before_back(self):
script = eject.sweep_probe_height_script(eject.normalize_sweep({}))
# Y hinten (Default 250) vor dem Senken auf z_push
y_idx = script.find("Y250")
z_push_idx = script.find("Z2.00")
self.assertGreater(y_idx, 0)
self.assertGreater(z_push_idx, y_idx)
# Senken langsam — nicht F3000 ins Bett
self.assertIn("F%d" % eject.SWEEP_FEED_DOWN, script)
down = script[z_push_idx : z_push_idx + 40]
self.assertNotIn("F3000", down)
def test_assert_clear_before_lower(self):
sweep = eject.normalize_sweep({})
eject.assert_clear_before_lower({"position": [125, 250, 40.0]}, sweep)
with self.assertRaises(GrottiError):
eject.assert_clear_before_lower({"position": [125, 250, 8.0]}, sweep)
def test_check_delta_migrates_retract_workaround(self):
check = eject.normalize_check({"max_delta_mm": 2.5})
self.assertAlmostEqual(check["max_delta_mm"], 3.0)
self.assertAlmostEqual(eject.normalize_check({"max_delta_mm": 1.0}).get("max_delta_mm"), 3.0)
self.assertAlmostEqual(eject.normalize_check({}).get("max_delta_mm"), 3.0)
err = eject.explain_printer_error("No trigger on probe after full movement")
self.assertIn("Wägezelle", err)
self.assertNotIn("Induktiv", err)
def test_check_z_ignores_toolhead(self):
z, src = eject.check_z_from_live({"probe_z": None, "position": [125, 125, 1.7]})
self.assertIsNone(z)
z, src = eject.check_z_from_live({"probe_z": 0.37})
self.assertAlmostEqual(z, 0.37)
self.assertEqual(src, "probe")
def test_sweep_respects_single_x(self):
sweep = eject.normalize_sweep({"x_from": 125, "x_to": 125, "x_step": 0})
script = eject.sweep_script(sweep)
# eine Schiebe-Spur: genau ein Absenken auf z_push, ein Mal nach vorn
self.assertEqual(script.count("Z2.00"), 1)
self.assertEqual(script.count("Y5.00"), 1)
self.assertLess(script.find("Y250"), script.find("Z2.00"))
def test_assert_at_push_height(self):
sweep = eject.normalize_sweep({})
eject.assert_at_push_height({"position": [125, 220, 2.0]}, sweep)
with self.assertRaises(GrottiError):
eject.assert_at_push_height({"position": [125, 220, 18.0]}, sweep)
def test_step_labels(self):
self.assertEqual(eject.step_label({"type": "check"}), "Tasten")
self.assertEqual(eject.step_label({"type": "sweep"}), "Schieben")
def test_check_script_probe(self):
script = eject.check_script({"x": 125, "y": 125}, current_z=50.0)
self.assertEqual(script.count("PROBE"), 1)
self.assertNotIn("SET_KINEMATIC_POSITION", script)
self.assertNotIn("BED_MESH_CLEAR", script)
self.assertIn("G0 Z50.00", script)
self.assertIn("X125", script.replace(" ", ""))
# Auch bei schlechter Z: kein Budget-Fake, nur z_clear
low = eject.check_script({"x": 125, "y": 125}, current_z=-4.0)
self.assertEqual(low.count("PROBE"), 1)
self.assertNotIn("SET_KINEMATIC_POSITION", low)
self.assertIn("G0 Z25.00", low)
def test_restore_uses_live_xyz(self):
script, z = eject.restore_position_script(
{"position": [250.0, 220.0, 48.0]}, {"z_push": 2}
)
self.assertAlmostEqual(z, 48.0)
self.assertIn("SET_KINEMATIC_POSITION", script)
self.assertIn("X=250.000", script)
self.assertIn("Y=220.000", script)
self.assertIn("Z=48.000", script)
with self.assertRaises(GrottiError):
eject.restore_position_script({"position": [125, 125, 1.0]}, {"z_push": 2})
def test_strip_end_motor_disable(self):
raw = "G1 X250 Y220 F12000\nM84; disable motors\nM140 S0\n"
out = eject.strip_end_motor_disable(raw)
self.assertNotIn("M84", out)
self.assertIn("G1 X250 Y220", out)
self.assertIn("M140 S0", out)
# Kommentarzeile mit M84 bleibt
commented = "; M84; disable motors\nG1 X1\n"
self.assertIn("; M84", eject.strip_end_motor_disable(commented))
def test_probe_result_regex(self):
from grotti import moonraker
match = moonraker._PROBE_RESULT_RE.search("Result is z=0.374500\n")
self.assertIsNotNone(match)
self.assertAlmostEqual(float(match.group(1)), 0.3745)
class StoreQueueOutTests(unittest.TestCase):
def test_legacy_mesh_hidden(self):
from grotti import store
row = {
"id": 1,
"file_id": 1,
"host": "10.0.0.1",
"family": "kobra_3",
"priority": 100,
"status": "running",
"spool_id": None,
"ace_map": [],
"bed_leveling": True,
"override_machine": False,
"message": "ok",
"steps": [
{"type": "print", "file_id": 1},
{"type": "cool"},
{"type": "sweep"},
{"type": "check", "probe_count": 3, "mesh_min_x": 0},
{"type": "check", "max_delta_mm": 1},
],
"step_index": 4,
"repeat_total": 1,
"repeat_done": 0,
"phase": "",
"phase_started_at": None,
"created_at": None,
"updated_at": None,
"printer_name": "A",
"filename": "x.gcode",
}
out = store.print_queue_out(row, "x.gcode")
self.assertEqual(out["step_labels"], ["Drucken", "Abkühlen", "Schieben", "Tasten"])
self.assertEqual(out["step_label_current"], "Tasten")
self.assertTrue(out["is_ablauf"])
if __name__ == "__main__":
unittest.main()
+50
View File
@@ -0,0 +1,50 @@
"""Zustandsmaschine der Druckwarteschlange mit Fake-Moonraker."""
import unittest
from pathlib import Path
import sys
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti.print_queue import PRINT_START_TIMEOUT_S, PrintQueueDispatcher, _cool_wait_text
class CoolTextTests(unittest.TestCase):
def test_wait_text(self):
cool = {"bed_max": 30, "nozzle_max": 45}
text = _cool_wait_text({"bed": 40, "nozzle": 50}, cool)
self.assertIn("Düse", text)
self.assertIn("Bett", text)
self.assertNotIn(" · ", text)
class DispatcherHelpers(unittest.TestCase):
def test_timeout_constant(self):
self.assertGreaterEqual(PRINT_START_TIMEOUT_S, 300)
def test_worker_busy_empty(self):
d = PrintQueueDispatcher("postgresql://x", {}, watcher=None)
self.assertFalse(d._worker_busy(1))
def test_auto_enabled_default(self):
d = PrintQueueDispatcher("postgresql://x", {}, watcher=None)
self.assertTrue(d._auto_enabled())
d.cfg = {"auto_queue": False}
self.assertFalse(d._auto_enabled())
class ResolveSpoolTests(unittest.TestCase):
def test_material_match(self):
from grotti.jobs import assert_material_match
from grotti.errors import GrottiError
file_row = {"filaments": [{"type": "PLA"}]}
assert_material_match(file_row, [{"material_type": "PLA"}])
with self.assertRaises(GrottiError):
assert_material_match(file_row, [{"material_type": "PETG"}])
if __name__ == "__main__":
unittest.main()
+91
View File
@@ -0,0 +1,91 @@
"""store.print_queue_out und Hilfen ohne Datenbank."""
import unittest
from datetime import datetime, timezone
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti.store import print_queue_out
class PrintQueueOutTests(unittest.TestCase):
def test_basic_fields(self):
now = datetime(2026, 10, 14, 12, 0, tzinfo=timezone.utc)
out = print_queue_out(
{
"id": 7,
"file_id": 3,
"filename": "teil.gcode",
"host": "10.10.1.10",
"printer_name": "A2",
"family": "kobra_3",
"priority": 50,
"status": "waiting",
"spool_id": None,
"ace_map": [{"paint_index": 0, "ams_index": 1}],
"bed_leveling": True,
"override_machine": False,
"message": "wartet",
"steps": [],
"step_index": 0,
"repeat_total": 1,
"repeat_done": 0,
"phase": "",
"phase_started_at": None,
"created_at": now,
"updated_at": now,
}
)
self.assertEqual(out["id"], 7)
self.assertEqual(out["host"], "10.10.1.10")
self.assertEqual(out["family_label"], "Kobra 3")
self.assertTrue(out["bed_leveling"])
self.assertEqual(out["ace_map"][0]["ams_index"], 1)
self.assertFalse(out["is_ablauf"])
self.assertEqual(out["filename"], "teil.gcode")
def test_ablauf_labels_skip_legacy_mesh(self):
steps = [
{"type": "print"},
{"type": "cool"},
{"type": "sweep"},
{"type": "check", "probe_count": 3, "mesh_min_x": 0},
{"type": "check", "max_delta_mm": 1},
]
out = print_queue_out(
{
"id": 1,
"file_id": 1,
"filename": "x.gcode",
"host": "h",
"printer_name": "",
"family": "kobra_3",
"priority": 100,
"status": "running",
"spool_id": None,
"ace_map": [],
"bed_leveling": False,
"override_machine": False,
"message": "",
"steps": steps,
"step_index": 2,
"repeat_total": 2,
"repeat_done": 0,
"phase": "cooling",
"phase_started_at": None,
"created_at": None,
"updated_at": None,
}
)
self.assertTrue(out["is_ablauf"])
self.assertNotIn("mesh", " ".join(out["step_labels"]).lower())
self.assertEqual(out["step_labels"][0], "Drucken")
self.assertIn(out["step_label_current"], out["step_labels"])
self.assertEqual(out["repeat_total"], 2)
if __name__ == "__main__":
unittest.main()
View File
Whitespace-only changes.
+1328
View File
File diff suppressed because it is too large. Load diff
+172
View File
@@ -0,0 +1,172 @@
const TOKEN = document.querySelector('meta[name="token"]')?.content || "";
const STATE_LABELS = {
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline",
waiting: "wartet", running: "läuft",
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (TOKEN) headers.set("Authorization", "Bearer " + TOKEN);
let body = options.body;
if (options.json !== undefined) {
headers.set("Content-Type", "application/json");
body = JSON.stringify(options.json);
}
const response = await fetch("/api/v1" + path, {
method: options.method || "GET",
headers,
body,
cache: "no-store",
});
if (!response.ok) {
let message = response.statusText;
try {
const payload = await response.json();
message = payload.error || message;
} catch (error) {
message = response.statusText;
}
throw new Error(message);
}
if (response.status === 204) return null;
const type = response.headers.get("content-type") || "";
if (type.includes("json")) return response.json();
return response;
}
function withToken(url) {
if (!url || !TOKEN) return url;
return url + (url.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN);
}
function euro(value) {
return Number(value || 0).toLocaleString("de-DE", { style: "currency", currency: "EUR" });
}
function grams(value) {
return Number(value || 0).toLocaleString("de-DE", { maximumFractionDigits: 0 }) + " g";
}
function hours(seconds) {
seconds = Math.round(Number(seconds) || 0);
if (seconds <= 0) return "";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return h > 0 ? h + " h " + String(m).padStart(2, "0") + " min" : m + " min";
}
function finishClock(remainSeconds) {
const sec = Number(remainSeconds) || 0;
if (sec <= 0) return "";
const at = new Date(Date.now() + sec * 1000);
return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
function clockOf(iso) {
if (!iso) return "";
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return "";
return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
function showNote(id, error, ok) {
const node = document.getElementById(id);
if (node) {
if (error) {
node.textContent = error.message || String(error);
node.className = "msg";
} else {
node.textContent = ok || "";
node.className = ok ? "ok" : "ok";
}
}
if (error) toast(error.message || String(error), "bad");
else if (ok) toast(ok, "good");
}
function icon(name, cls) {
return `<svg class="ico${cls ? " " + cls : ""}" aria-hidden="true"><use href="#i-${name}"/></svg>`;
}
function chipHtml(state, label, small) {
const text = label || STATE_LABELS[state] || state || "";
return `<span class="chip ${escapeHtml(state || "")}${small ? " sm" : ""}">${escapeHtml(text)}</span>`;
}
function stepsHtml(labels, index, compact) {
if (!labels || !labels.length) return "";
const parts = labels.map((label, i) => {
const cls = i < index ? "done" : i === index ? "now" : "";
return `<span class="step ${cls}">${escapeHtml(label)}</span>`;
});
return `<span class="steps${compact ? " compact" : ""}">${parts.join('<span class="step-sep"></span>')}</span>`;
}
function repeatText(job) {
const total = Math.max(1, Number(job.repeat_total) || 1);
if (total <= 1) return "";
return Math.min(total, (Number(job.repeat_done) || 0) + 1) + "/" + total;
}
function phaseText(job) {
const message = (job && job.message) || "";
if (!job || !job.is_ablauf || job.status !== "running") return message;
const parts = message.split(" · ");
return parts.length > 1 ? parts.slice(-1)[0] : message;
}
function jobLabel(filename) {
return (filename || "").replace(/\.gcode(\.3mf)?$/i, "").replace(/\.3mf$/i, "").replace(/^(\d+-)+/, "");
}
function toast(message, kind) {
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 " + (kind || "good");
node.innerHTML = icon(kind === "bad" ? "alert" : "check", "lg") + "<div></div>";
node.querySelector("div").textContent = message;
host.appendChild(node);
const ttl = kind === "bad" ? 7000 : 4000;
setTimeout(() => {
node.classList.add("out");
setTimeout(() => node.remove(), 260);
}, ttl);
}
function armButton(button, armedText, onConfirm, timeoutMs) {
if (button.dataset.armed === "1") {
clearTimeout(Number(button.dataset.armTimer || 0));
button.dataset.armed = "";
button.classList.remove("confirm-armed");
if (button.dataset.idleHtml) button.innerHTML = button.dataset.idleHtml;
onConfirm();
return;
}
button.dataset.idleHtml = button.innerHTML;
button.dataset.armed = "1";
button.classList.add("confirm-armed");
button.textContent = armedText;
const timer = setTimeout(() => {
button.dataset.armed = "";
button.classList.remove("confirm-armed");
if (button.dataset.idleHtml) button.innerHTML = button.dataset.idleHtml;
}, timeoutMs || 5000);
button.dataset.armTimer = String(timer);
}
+723
View File
@@ -0,0 +1,723 @@
/* global THREE */
window.GrottiGcodeViewer = (function () {
const PHI_MIN = 0.08;
const PHI_MAX = Math.PI - 0.08;
const COLOR_DONE = 0x5dffb0;
const COLOR_TODO = 0x6b7280;
const COLOR_PREVIEW = 0xf97316;
const COLOR_OBJ = 0x60a5fa;
const COLOR_OBJ_EX = 0xef4444;
const COLOR_OBJ_CUR = 0xfbbf24;
const GRID_STEP = 10;
// Nur leichte Doppelung — starke Offsets + Punkte wirkten wie Vollkörper mit Löchern
const FAT_OFFSETS = [0, -0.22, 0.22];
function create(canvas) {
const wrap = document.createElement("div");
wrap.className = "gcode-viewer-wrap";
if (canvas.parentNode) {
canvas.parentNode.insertBefore(wrap, canvas);
wrap.appendChild(canvas);
}
const legend = document.createElement("div");
legend.className = "gcode-legend";
legend.innerHTML =
'<span class="gcode-leg-item live"><span class="gcode-leg-swatch done thick"></span><span>gedruckt</span></span>' +
'<span class="gcode-leg-item live"><span class="gcode-leg-swatch todo"></span><span>offen</span></span>' +
'<span class="gcode-leg-item preview-only"><span class="gcode-leg-swatch done"></span><span>Pfad</span></span>' +
'<span class="gcode-leg-scale">Gitter ' + GRID_STEP + " mm</span>";
wrap.appendChild(legend);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x121212);
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 5000);
camera.up.set(0, 0, 1);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const light = new THREE.DirectionalLight(0xffffff, 0.9);
light.position.set(1, -2, 3);
scene.add(light);
scene.add(new THREE.AmbientLight(0xffffff, 0.45));
const bedGroup = new THREE.Group();
scene.add(bedGroup);
const pathGroup = new THREE.Group();
scene.add(pathGroup);
const excludeGroup = new THREE.Group();
scene.add(excludeGroup);
let points = [];
let layerBreaks = [0];
let progress = null;
let layer = null;
let layers = 0;
let center = { x: 0, y: 0, z: 0 };
let span = 80;
let excludeData = { objects: [], excluded: [], current: null };
let selectedName = null;
let onExcludeSelect = null;
let excludeMeshes = [];
let theta = 0.55;
let phi = 1.05;
let distance = 320;
let target = new THREE.Vector3(0, 0, 20);
let defaultView = { theta: 0.55, phi: 1.05, distance: 320, tx: 0, ty: 0, tz: 20 };
let mode = null;
let lastX = 0;
let lastY = 0;
let downX = 0;
let downY = 0;
let pointers = new Map();
let pinchDist = 0;
let alive = true;
let raf = 0;
let dirty = true;
const raycaster = new THREE.Raycaster();
const pointerNdc = new THREE.Vector2();
canvas.style.cursor = "grab";
canvas.style.touchAction = "none";
function resize() {
const width = canvas.clientWidth || canvas.parentElement.clientWidth || 480;
const height = canvas.clientHeight || 320;
if (canvas.width !== width || canvas.height !== height) {
renderer.setSize(width, height, false);
camera.aspect = width / Math.max(1, height);
camera.updateProjectionMatrix();
dirty = true;
}
}
function orbit() {
const sinPhi = Math.sin(phi);
camera.position.set(
target.x + distance * sinPhi * Math.sin(theta),
target.y - distance * sinPhi * Math.cos(theta),
target.z + distance * Math.cos(phi)
);
camera.lookAt(target);
}
function clearGroup(group) {
while (group.children.length) {
const child = group.children[0];
group.remove(child);
if (child.geometry) child.geometry.dispose();
if (child.material) {
const mats = Array.isArray(child.material) ? child.material : [child.material];
mats.forEach((m) => {
if (m.map) m.map.dispose();
m.dispose();
});
}
}
}
function clearLines() {
clearGroup(pathGroup);
}
function clearBed() {
clearGroup(bedGroup);
}
function clearExclude() {
clearGroup(excludeGroup);
excludeMeshes = [];
}
function boundsOf(pts) {
let minX = Infinity;
let minY = Infinity;
let minZ = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
let maxZ = -Infinity;
for (const point of pts) {
minX = Math.min(minX, point[0]);
minY = Math.min(minY, point[1]);
minZ = Math.min(minZ, point[2]);
maxX = Math.max(maxX, point[0]);
maxY = Math.max(maxY, point[1]);
maxZ = Math.max(maxZ, point[2]);
}
return { minX, minY, minZ, maxX, maxY, maxZ };
}
function niceFloor(value, step) {
return Math.floor(value / step) * step;
}
function niceCeil(value, step) {
return Math.ceil(value / step) * step;
}
function makeLabelSprite(text) {
const c = document.createElement("canvas");
c.width = 256;
c.height = 64;
const ctx = c.getContext("2d");
ctx.clearRect(0, 0, c.width, c.height);
ctx.fillStyle = "rgba(18,18,18,0.72)";
roundRect(ctx, 8, 8, c.width - 16, c.height - 16, 10);
ctx.fill();
ctx.fillStyle = "#e8e8e8";
ctx.font = "700 36px ui-sans-serif, system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(text, c.width / 2, c.height / 2 + 1);
const tex = new THREE.CanvasTexture(c);
tex.needsUpdate = true;
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
const sprite = new THREE.Sprite(mat);
sprite.scale.set(18, 4.5, 1);
sprite.renderOrder = 10;
return sprite;
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function buildBed(b) {
clearBed();
const pad = GRID_STEP * 3;
const minX = niceFloor(b.minX - pad, GRID_STEP) - center.x;
const maxX = niceCeil(b.maxX + pad, GRID_STEP) - center.x;
const minY = niceFloor(b.minY - pad, GRID_STEP) - center.y;
const maxY = niceCeil(b.maxY + pad, GRID_STEP) - center.y;
const w = Math.max(GRID_STEP, maxX - minX);
const h = Math.max(GRID_STEP, maxY - minY);
const plate = new THREE.Mesh(
new THREE.PlaneGeometry(w, h),
new THREE.MeshBasicMaterial({
color: 0x1a1a1a,
transparent: true,
opacity: 0.88,
side: THREE.DoubleSide,
})
);
plate.position.set((minX + maxX) / 2, (minY + maxY) / 2, -0.08);
bedGroup.add(plate);
const gridLines = [];
for (let x = minX; x <= maxX + 0.01; x += GRID_STEP) {
const major = Math.round(x) % 50 === 0;
gridLines.push(x, minY, 0, x, maxY, 0);
if (major) gridLines.push(x + 0.2, minY, 0, x + 0.2, maxY, 0);
}
for (let y = minY; y <= maxY + 0.01; y += GRID_STEP) {
const major = Math.round(y) % 50 === 0;
gridLines.push(minX, y, 0, maxX, y, 0);
if (major) gridLines.push(minX, y + 0.2, 0, maxX, y + 0.2, 0);
}
const gridGeo = new THREE.BufferGeometry();
gridGeo.setAttribute("position", new THREE.Float32BufferAttribute(gridLines, 3));
bedGroup.add(
new THREE.LineSegments(
gridGeo,
new THREE.LineBasicMaterial({ color: 0x3f3f3f })
)
);
const tick = [];
for (let x = minX; x <= maxX + 0.01; x += GRID_STEP) {
const len = Math.round(x) % 50 === 0 ? 7 : 3.5;
tick.push(x, minY, 0.02, x, minY - len, 0.02);
}
for (let y = minY; y <= maxY + 0.01; y += GRID_STEP) {
const len = Math.round(y) % 50 === 0 ? 7 : 3.5;
tick.push(minX, y, 0.02, minX - len, y, 0.02);
}
tick.push(minX, minY, 0.02, maxX, minY, 0.02);
tick.push(minX, minY, 0.02, minX, maxY, 0.02);
tick.push(maxX, minY, 0.02, maxX, maxY, 0.02);
tick.push(minX, maxY, 0.02, maxX, maxY, 0.02);
// Achsen-Hinweis
tick.push(minX, minY, 0.05, minX + 25, minY, 0.05);
tick.push(minX, minY, 0.05, minX, minY + 25, 0.05);
const tickGeo = new THREE.BufferGeometry();
tickGeo.setAttribute("position", new THREE.Float32BufferAttribute(tick, 3));
bedGroup.add(
new THREE.LineSegments(
tickGeo,
new THREE.LineBasicMaterial({ color: 0xb0b0b0 })
)
);
for (let x = minX; x <= maxX + 0.01; x += 50) {
const mm = Math.round(x + center.x);
const sprite = makeLabelSprite(String(mm) + " mm");
sprite.position.set(x, minY - 14, 1.5);
bedGroup.add(sprite);
}
for (let y = minY + 50; y <= maxY + 0.01; y += 50) {
const mm = Math.round(y + center.y);
const sprite = makeLabelSprite(String(mm));
sprite.position.set(minX - 16, y, 1.5);
bedGroup.add(sprite);
}
const zero = makeLabelSprite("0");
zero.position.set(minX - 14, minY - 14, 1.5);
bedGroup.add(zero);
const xAxis = makeLabelSprite("X →");
xAxis.position.set(minX + 32, minY - 14, 1.5);
bedGroup.add(xAxis);
const yAxis = makeLabelSprite("Y →");
yAxis.position.set(minX - 16, minY + 32, 1.5);
bedGroup.add(yAxis);
}
function resetView() {
theta = defaultView.theta;
phi = defaultView.phi;
distance = defaultView.distance;
target.set(defaultView.tx, defaultView.ty, defaultView.tz);
dirty = true;
}
function isExtrude(point) {
return !point || point.length < 4 || point[3];
}
function consecutiveMove(prev, next) {
// Original-seq (Index 4): nach Thinning keine Sehnen über Lücken
if (!prev || !next || prev.length < 5 || next.length < 5) return true;
return Number(next[4]) === Number(prev[4]) + 1;
}
function fillSegments(from, to, out, fat) {
const end = Math.min(to, points.length - 1);
const start = Math.max(0, from);
const offsets = fat ? FAT_OFFSETS : [0];
for (let i = start + 1; i <= end; i++) {
if (!isExtrude(points[i])) continue;
if (!consecutiveMove(points[i - 1], points[i])) continue;
const ax = points[i - 1][0] - center.x;
const ay = points[i - 1][1] - center.y;
const az = points[i - 1][2] - center.z;
const bx = points[i][0] - center.x;
const by = points[i][1] - center.y;
const bz = points[i][2] - center.z;
const dx = bx - ax;
const dy = by - ay;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len;
const ny = dx / len;
for (const off of offsets) {
out.push(
ax + nx * off, ay + ny * off, az,
bx + nx * off, by + ny * off, bz
);
}
}
}
function makeSegLine(from, to, color, opacity, fat) {
const segs = [];
fillSegments(from, to, segs, fat);
if (segs.length < 6) return null;
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(segs, 3));
const mat = new THREE.LineBasicMaterial({
color,
transparent: opacity < 0.99,
opacity,
depthWrite: opacity > 0.9,
});
return new THREE.LineSegments(geo, mat);
}
function cutIndex() {
if (!points.length) return 0;
if (progress == null) return points.length - 1;
if (progress >= 0.995 || (layers > 0 && layer != null && Number(layer) >= layers)) {
return points.length - 1;
}
// Primär Datei-Fortschritt. layer_breaks zählt oft Z-Hops mit —
// Index === current_layer schnitt viel zu früh ab.
let cut = Math.floor(points.length * progress);
if (layerBreaks.length > 1 && layers > 0 && layer != null && Number(layer) > 0) {
const ratio = Math.min(1, Number(layer) / Number(layers));
const idx = Math.floor(ratio * (layerBreaks.length - 1));
const byLayer = layerBreaks[Math.max(0, Math.min(layerBreaks.length - 1, idx))];
cut = Math.max(cut, byLayer);
}
return Math.max(0, Math.min(points.length - 1, cut));
}
function rebuildProgress() {
clearLines();
if (!points.length) {
legend.classList.add("preview");
dirty = true;
return;
}
if (progress == null) {
legend.classList.add("preview");
const line = makeSegLine(0, points.length - 1, COLOR_PREVIEW, 0.95, true);
if (line) pathGroup.add(line);
dirty = true;
return;
}
legend.classList.remove("preview");
const cut = cutIndex();
const todo = makeSegLine(Math.max(0, cut - 1), points.length - 1, COLOR_TODO, 0.42, false);
const done = makeSegLine(0, cut, COLOR_DONE, 1, true);
if (todo) pathGroup.add(todo);
if (done) pathGroup.add(done);
dirty = true;
}
function polygonHitMesh(polyLocal, name) {
// unsichtbare Fläche für Raycast (Dreiecke fan)
if (!polyLocal || polyLocal.length < 3) return null;
const positions = [];
const c = { x: 0, y: 0 };
for (const p of polyLocal) {
c.x += p[0];
c.y += p[1];
}
c.x /= polyLocal.length;
c.y /= polyLocal.length;
for (let i = 0; i < polyLocal.length; i++) {
const a = polyLocal[i];
const b = polyLocal[(i + 1) % polyLocal.length];
positions.push(c.x, c.y, 0.15, a[0], a[1], 0.15, b[0], b[1], 0.15);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
const mesh = new THREE.Mesh(
geo,
new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.01,
side: THREE.DoubleSide,
depthWrite: false,
})
);
mesh.userData.excludeName = name;
return mesh;
}
function rebuildExclude() {
clearExclude();
const objects = excludeData.objects || [];
const excluded = new Set(excludeData.excluded || []);
const current = excludeData.current || null;
for (const obj of objects) {
const name = obj.name || "";
const poly = obj.polygon || [];
if (!name || poly.length < 2) continue;
const local = poly.map((p) => [p[0] - center.x, p[1] - center.y]);
const isEx = excluded.has(name);
const isCur = current === name;
const isSel = selectedName === name;
let color = COLOR_OBJ;
if (isEx) color = COLOR_OBJ_EX;
else if (isCur) color = COLOR_OBJ_CUR;
if (isSel && !isEx) color = 0xffffff;
const loop = [];
for (let i = 0; i < local.length; i++) {
const a = local[i];
const b = local[(i + 1) % local.length];
loop.push(a[0], a[1], 0.4, b[0], b[1], 0.4);
if (isEx || isSel) {
loop.push(a[0], a[1], 0.55, b[0], b[1], 0.55);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(loop, 3));
const line = new THREE.LineSegments(
geo,
new THREE.LineBasicMaterial({
color,
transparent: true,
opacity: isEx ? 0.45 : 0.95,
})
);
excludeGroup.add(line);
const hit = polygonHitMesh(local, name);
if (hit) {
excludeGroup.add(hit);
excludeMeshes.push(hit);
}
if (obj.center && obj.center.length >= 2) {
const mark = new THREE.Mesh(
new THREE.CircleGeometry(1.8, 12),
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: isEx ? 0.35 : 0.9,
side: THREE.DoubleSide,
depthWrite: false,
})
);
mark.position.set(obj.center[0] - center.x, obj.center[1] - center.y, 0.5);
mark.userData.excludeName = name;
excludeGroup.add(mark);
excludeMeshes.push(mark);
}
}
dirty = true;
}
function setPath(data, opts) {
const keepView = !!(opts && opts.keepView);
clearLines();
points = (data && data.points) || [];
layerBreaks = (data && data.layer_breaks && data.layer_breaks.length)
? data.layer_breaks
: [0];
if (!points.length) {
clearBed();
clearExclude();
rebuildProgress();
return;
}
const b = boundsOf(points);
center = {
x: (b.minX + b.maxX) / 2,
y: (b.minY + b.maxY) / 2,
z: b.minZ,
};
span = Math.max(b.maxX - b.minX, b.maxY - b.minY, b.maxZ - b.minZ, 40);
const midZ = (b.maxZ - b.minZ) * 0.35;
const fitDist = Math.max(60, span * 1.85);
defaultView = {
theta: 0.55,
phi: 1.05,
distance: fitDist,
tx: 0,
ty: 0,
tz: midZ,
};
if (!keepView) {
distance = fitDist;
target.set(0, 0, midZ);
theta = defaultView.theta;
phi = defaultView.phi;
}
buildBed(b);
rebuildProgress();
rebuildExclude();
dirty = true;
}
function setProgress(value, meta) {
if (value == null || value === "") {
progress = null;
} else {
progress = Math.max(0, Math.min(1, Number(value) || 0));
}
if (meta && typeof meta === "object") {
layer = meta.layer != null ? Number(meta.layer) : layer;
layers = meta.layers != null ? Number(meta.layers) : layers;
}
rebuildProgress();
}
function setExcludeObjects(data, opts) {
excludeData = data && typeof data === "object"
? {
objects: data.objects || [],
excluded: data.excluded || data.excluded_objects || [],
current: data.current || data.current_object || null,
}
: { objects: [], excluded: [], current: null };
if (opts && opts.selected != null) selectedName = opts.selected;
if (opts && typeof opts.onSelect === "function") onExcludeSelect = opts.onSelect;
rebuildExclude();
}
function setSelectedExclude(name) {
selectedName = name || null;
rebuildExclude();
}
function pickExclude(clientX, clientY) {
if (!excludeMeshes.length) return null;
const rect = canvas.getBoundingClientRect();
pointerNdc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
pointerNdc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointerNdc, camera);
const hits = raycaster.intersectObjects(excludeMeshes, false);
if (!hits.length) return null;
return hits[0].object.userData.excludeName || null;
}
function panBy(dx, dy) {
const right = new THREE.Vector3();
const up = new THREE.Vector3();
right.setFromMatrixColumn(camera.matrixWorld, 0).normalize();
up.setFromMatrixColumn(camera.matrixWorld, 1).normalize();
const scale = distance * 0.0018;
target.addScaledVector(right, -dx * scale);
target.addScaledVector(up, dy * scale);
dirty = true;
}
function frame() {
if (!alive) return;
resize();
if (dirty || mode) {
orbit();
renderer.render(scene, camera);
dirty = false;
}
raf = requestAnimationFrame(frame);
}
function destroy() {
alive = false;
if (raf) cancelAnimationFrame(raf);
clearLines();
clearBed();
clearExclude();
renderer.dispose();
pointers.clear();
if (legend.parentNode) legend.parentNode.removeChild(legend);
if (wrap.parentNode && canvas.parentNode === wrap) {
wrap.parentNode.insertBefore(canvas, wrap);
wrap.parentNode.removeChild(wrap);
}
}
function pointerCount() {
return pointers.size;
}
function pinchDistance() {
const pts = [...pointers.values()];
if (pts.length < 2) return 0;
const dx = pts[0].x - pts[1].x;
const dy = pts[0].y - pts[1].y;
return Math.hypot(dx, dy);
}
canvas.addEventListener("pointerdown", (event) => {
canvas.setPointerCapture(event.pointerId);
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
downX = event.clientX;
downY = event.clientY;
if (pointerCount() === 2) {
mode = "pinch";
pinchDist = pinchDistance();
canvas.style.cursor = "grabbing";
return;
}
if (event.button === 1 || event.button === 2 || event.shiftKey || event.altKey) {
mode = "pan";
} else if (event.button === 0) {
mode = "orbit";
} else {
mode = null;
return;
}
lastX = event.clientX;
lastY = event.clientY;
canvas.style.cursor = "grabbing";
});
function endPointer(event) {
const moved = Math.hypot(event.clientX - downX, event.clientY - downY);
if (mode === "orbit" && moved < 6 && event.button === 0) {
const name = pickExclude(event.clientX, event.clientY);
if (name) {
selectedName = name;
rebuildExclude();
if (onExcludeSelect) onExcludeSelect(name);
}
}
pointers.delete(event.pointerId);
if (pointerCount() === 0) {
mode = null;
canvas.style.cursor = "grab";
} else if (pointerCount() === 1) {
const remaining = [...pointers.values()][0];
mode = "orbit";
lastX = remaining.x;
lastY = remaining.y;
}
}
canvas.addEventListener("pointerup", endPointer);
canvas.addEventListener("pointercancel", endPointer);
canvas.addEventListener("pointermove", (event) => {
if (!pointers.has(event.pointerId)) return;
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (mode === "pinch" && pointerCount() >= 2) {
const next = pinchDistance();
if (pinchDist > 0 && next > 0) {
distance = Math.max(25, Math.min(2500, distance * (pinchDist / next)));
dirty = true;
}
pinchDist = next;
return;
}
if (!mode) return;
const dx = event.clientX - lastX;
const dy = event.clientY - lastY;
lastX = event.clientX;
lastY = event.clientY;
if (mode === "pan") {
panBy(dx, dy);
return;
}
theta -= dx * 0.008;
phi = Math.max(PHI_MIN, Math.min(PHI_MAX, phi + dy * 0.008));
dirty = true;
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
// Chrome (Pixel-Deltas / Trackpad) feuert aggressiver als z. B. Embedded-Browser.
let dy = event.deltaY;
if (event.deltaMode === 1) dy *= 16; // Zeilen
else if (event.deltaMode === 2) dy *= canvas.clientHeight || 320; // Seiten
// sanfte Exponential-Kurve, gekappt gegen Ausreißer
const factor = Math.exp(Math.max(-80, Math.min(80, dy)) * 0.0012);
distance = Math.max(25, Math.min(2500, distance * factor));
dirty = true;
}, { passive: false });
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
canvas.addEventListener("dblclick", (event) => {
event.preventDefault();
resetView();
});
frame();
return {
setPath,
setProgress,
setExcludeObjects,
setSelectedExclude,
resize,
destroy,
resetView,
};
}
return { create };
})();
File diff suppressed because it is too large. Load diff
+121
View File
@@ -0,0 +1,121 @@
/* Draufsicht: Schiebe-Bahn auf dem Kobra-3-Bett. */
(function (global) {
const BED_X = 250;
const BED_Y = 235;
function SweepViewer(canvas) {
this.canvas = canvas;
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.scene = new THREE.Scene();
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
this.camera.position.set(0, 0, 10);
this.camera.lookAt(0, 0, 0);
this.root = new THREE.Group();
this.scene.add(this.root);
this._bed = null;
this._paths = null;
this._front = null;
this._resize();
}
SweepViewer.prototype._resize = function () {
const width = this.canvas.clientWidth || 280;
const height = this.canvas.clientHeight || 220;
this.renderer.setSize(width, height, false);
const aspect = width / height;
const worldW = BED_X * 1.12;
const worldH = BED_Y * 1.12;
let halfW = worldW / 2;
let halfH = worldH / 2;
if (halfW / halfH < aspect) halfW = halfH * aspect;
else halfH = halfW / aspect;
this.camera.left = -halfW;
this.camera.right = halfW;
this.camera.top = halfH;
this.camera.bottom = -halfH;
this.camera.updateProjectionMatrix();
this.renderer.render(this.scene, this.camera);
};
SweepViewer.prototype._clearGroup = function (name) {
const old = this[name];
if (!old) return;
this.root.remove(old);
old.traverse(function (obj) {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
if (Array.isArray(obj.material)) obj.material.forEach((m) => m.dispose());
else obj.material.dispose();
}
});
this[name] = null;
};
SweepViewer.prototype.setSweep = function (step) {
const params = step || {};
const xFrom = Number(params.x_from);
const xTo = Number(params.x_to);
const xStep = Math.abs(Number(params.x_step) || 20);
const yFrom = Number(params.y_from);
const yTo = Number(params.y_to);
this._clearGroup("_bed");
this._clearGroup("_paths");
this._clearGroup("_front");
const bed = new THREE.Group();
const plate = new THREE.Mesh(
new THREE.PlaneGeometry(BED_X, BED_Y),
new THREE.MeshBasicMaterial({ color: 0x1a1a22, transparent: true, opacity: 0.95 })
);
bed.add(plate);
const edge = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.PlaneGeometry(BED_X, BED_Y)),
new THREE.LineBasicMaterial({ color: 0x555566 })
);
bed.add(edge);
bed.position.set(BED_X / 2, BED_Y / 2, 0);
this.root.add(bed);
this._bed = bed;
const front = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0.02),
new THREE.Vector3(BED_X, 0, 0.02),
]),
new THREE.LineBasicMaterial({ color: 0xf97316 })
);
this.root.add(front);
this._front = front;
const paths = new THREE.Group();
if (Number.isFinite(xFrom) && Number.isFinite(xTo) && Number.isFinite(yFrom) && Number.isFinite(yTo) && xStep > 0) {
const dir = xTo >= xFrom ? 1 : -1;
const points = [];
for (let x = xFrom, i = 0; dir > 0 ? x <= xTo + 0.01 : x >= xTo - 0.01; x += dir * xStep, i += 1) {
const yA = i % 2 === 0 ? yFrom : yTo;
const yB = i % 2 === 0 ? yTo : yFrom;
points.push(new THREE.Vector3(x, yA, 0.05), new THREE.Vector3(x, yB, 0.05));
if (dir > 0 ? x + dir * xStep <= xTo + 0.01 : x + dir * xStep >= xTo - 0.01) {
points.push(new THREE.Vector3(x, yB, 0.05), new THREE.Vector3(x + dir * xStep, yB, 0.05));
}
}
if (points.length >= 2) {
const geo = new THREE.BufferGeometry().setFromPoints(points);
paths.add(new THREE.LineSegments(geo, new THREE.LineBasicMaterial({ color: 0x3dce7c })));
}
}
this.root.add(paths);
this._paths = paths;
this._resize();
};
SweepViewer.prototype.dispose = function () {
this._clearGroup("_bed");
this._clearGroup("_paths");
this._clearGroup("_front");
this.renderer.dispose();
};
global.SweepViewer = SweepViewer;
})(window);
+7
View File
@@ -0,0 +1,7 @@
console.warn('Scripts "build/three.js" and "build/three.min.js" are deprecated with r150+, and will be removed with r160. Please use ES Modules or alternatives: https://threejs.org/docs/index.html#manual/en/introduction/Installation'),
/**
* @license
* Copyright 2010-2023 Three.js Authors
* SPDX-License-Identifier: MIT
*/
function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).THREE={})}(this,(function(t){"use strict";const e="160",n=1,i=2,r=3,s=0,a=1,o=100,l=204,c=205,h=0,u=1,d=2,p=0,m=1,f=2,g=3,_=4,v=5,x=6,y="attached",M="detached",S=300,b=301,T=302,E=303,w=304,A=306,R=1e3,C=1001,L=1002,P=1003,I=1004,U=1005,D=1006,N=1007,O=1008,F=1009,B=1012,z=1013,G=1014,H=1015,V=1016,k=1017,W=1018,X=1020,j=1023,q=1026,Y=1027,Z=1029,J=1031,K=1033,$=33776,Q=33777,tt=33778,et=33779,nt=35840,it=35841,rt=35842,st=35843,at=36196,ot=37492,lt=37496,ct=37808,ht=37809,ut=37810,dt=37811,pt=37812,mt=37813,ft=37814,gt=37815,_t=37816,vt=37817,xt=37818,yt=37819,Mt=37820,St=37821,bt=36492,Tt=36494,Et=36495,wt=36284,At=36285,Rt=36286,Ct=2300,Lt=2301,Pt=2302,It=2400,Ut=2401,Dt=2402,Nt=2500,Ot=2501,Ft=3e3,Bt=3001,zt="",Gt="srgb",Ht="srgb-linear",Vt="display-p3",kt="display-p3-linear",Wt="linear",Xt="srgb",jt="rec709",qt="p3",Yt=7680,Zt=35044,Jt="300 es",Kt=1035,$t=2e3,Qt=2001;class te{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e<i;e++)n[e].call(this,t);t.target=null}}}const ee=["00","01","02","03","04","05","06","07","08","09","0a","0b","0c","0d","0e","0f","10","11","12","13","14","15","16","17","18","19","1a","1b","1c","1d","1e","1f","20","21","22","23","24","25","26","27","28","29","2a","2b","2c","2d","2e","2f","30","31","32","33","34","35","36","37","38","39","3a","3b","3c","3d","3e","3f","40","41","42","43","44","45","46","47","48","49","4a","4b","4c","4d","4e","4f","50","51","52","53","54","55","56","57","58","59","5a","5b","5c","5d","5e","5f","60","61","62","63","64","65","66","67","68","69","6a","6b","6c","6d","6e","6f","70","71","72","73","74","75","76","77","78","79","7a","7b","7c","7d","7e","7f","80","81","82","83","84","85","86","87","88","89","8a","8b","8c","8d","8e","8f","90","91","92","93","94","95","96","97","98","99","9a","9b","9c","9d","9e","9f","a0","a1","a2","a3","a4","a5","a6","a7","a8","a9","aa","ab","ac","ad","ae","af","b0","b1","b2","b3","b4","b5","b6","b7","b8","b9","ba","bb","bc","bd","be","bf","c0","c1","c2","c3","c4","c5","c6","c7","c8","c9","ca","cb","cc","cd","ce","cf","d0","d1","d2","d3","d4","d5","d6","d7","d8","d9","da","db","dc","dd","de","df","e0","e1","e2","e3","e4","e5","e6","e7","e8","e9","ea","eb","ec","ed","ee","ef","f0","f1","f2","f3","f4","f5","f6","f7","f8","f9","fa","fb","fc","fd","fe","ff"];let ne=1234567;const ie=Math.PI/180,re=180/Math.PI;function se(){const t=4294967295*Math.random()|0,e=4294967295*Math.random()|0,n=4294967295*Math.random()|0,i=4294967295*Math.random()|0;return(ee[255&t]+ee[t>>8&255]+ee[t>>16&255]+ee[t>>24&255]+"-"+ee[255&e]+ee[e>>8&255]+"-"+ee[e>>16&15|64]+ee[e>>24&255]+"-"+ee[63&n|128]+ee[n>>8&255]+"-"+ee[n>>16&255]+ee[n>>24&255]+ee[255&i]+ee[i>>8&255]+ee[i>>16&255]+ee[i>>24&255]).toLowerCase()}function ae(t,e,n){return Math.max(e,Math.min(n,t))}function oe(t,e){return(t%e+e)%e}function le(t,e,n){return(1-n)*t+n*e}function ce(t){return 0==(t&t-1)&&0!==t}function he(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}function ue(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return t/4294967295;case Uint16Array:return t/65535;case Uint8Array:return t/255;case Int32Array:return Math.max(t/2147483647,-1);case Int16Array:return Math.max(t/32767,-1);case Int8Array:return Math.max(t/127,-1);default:throw new Error("Invalid component type.")}}function de(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return Math.round(4294967295*t);case Uint16Array:return Math.round(65535*t);case Uint8Array:return Math.round(255*t);case Int32Array:return Math.round(2147483647*t);case Int16Array:return Math.round(32767*t);case Int8Array:return Math.round(127*t);default:throw new Error("Invalid component type.")}}const pe={DEG2RAD:ie,RAD2DEG:re,generateUUID:se,clamp:ae,euclideanModulo:oe,mapLinear:function(t,e,n,i,r){return i+(t-e)*(r-i)/(n-e)},inverseLerp:function(t,e,n){return t!==e?(n-t)/(e-t):0},lerp:le,damp:function(t,e,n,i){return le(t,e,1-Math.exp(-n*i))},pingpong:function(t,e=1){return e-Math.abs(oe(t,2*e)-e)},smoothstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*(3-2*t)},smootherstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*t*(t*(6*t-15)+10)},randInt:function(t,e){return t+Math.floor(Math.random()*(e-t+1))},randFloat:function(t,e){return t+Math.random()*(e-t)},randFloatLine truncated
+17
View File
@@ -0,0 +1,17 @@
{% macro doc_toc(current) %}
<nav class="doc-toc" aria-label="Doku">
<p>Handbuch</p>
<a href="/doku#grotti" data-sec="grotti" class="{{ 'on' if current == 'start' else '' }}">Was Grotti ist</a>
<a href="/doku#druck" data-sec="druck">Ein Druck von A bis Z</a>
<a href="/doku#ablauf" data-sec="ablauf">Ablauf (Kobra 3)</a>
<a href="/doku#seiten" data-sec="seiten">Seiten</a>
<a href="/doku#haengt" data-sec="haengt">Wenn etwas hängt</a>
<a href="/doku#begriffe" data-sec="begriffe">Begriffe</a>
<p>Versionen</p>
<a href="/doku/changelog" class="{{ 'on' if current == 'changelog' else '' }}">Changelog · v{{ version }}</a>
<p>Technik</p>
{% for name in docs %}
<a href="/doku/{{ name }}" class="{{ 'on' if current == name else '' }}">{{ name | replace('-', ' ') }}</a>
{% endfor %}
</nav>
{% endmacro %}
+183
View File
@@ -0,0 +1,183 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Aktivität</h1>
<p class="tiny">Ereignisse von Grotti und den Druckern. Neueste oben.</p>
</div>
<div class="page-head-side">
<button type="button" class="ghost" id="tl-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
<button type="button" id="tl-read"><svg class="ico"><use href="#i-check"/></svg>Alle gelesen</button>
</div>
</div>
<div class="filter-bar" id="f-range" role="group" aria-label="Zeitraum">
<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="all">Alle</button>
</div>
<div class="filter-bar" id="f-kind" role="group" aria-label="Art"></div>
<div class="filter-bar" id="f-host" role="group" aria-label="Drucker"></div>
<p id="tl-note" class="tiny"></p>
<div class="timeline" id="timeline">
<div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div>
</div>
{% endblock %}
{% block script %}
<script>
const KINDS = [
["queue", "Warteschlange"],
["ablauf", "Ablauf"],
["start", "Start / Pause"],
["fertig", "Fertig"],
["fehler", "Fehler"],
["abbruch", "Abbruch"],
["offline", "Offline"],
["spule", "Spule"],
];
const KIND_LABEL = Object.fromEntries(KINDS);
const params = new URLSearchParams(location.search);
const filter = {
range: params.get("range") || (params.get("host") ? "week" : "day"),
kinds: new Set((params.get("kind") || "").split(",").filter(Boolean)),
host: params.get("host") || "",
};
let printers = [];
function syncUrl() {
const q = new URLSearchParams();
if (filter.range !== "day") q.set("range", filter.range);
if (filter.kinds.size) q.set("kind", [...filter.kinds].join(","));
if (filter.host) q.set("host", filter.host);
const s = q.toString();
history.replaceState(null, "", s ? "?" + s : location.pathname);
}
function renderFilters() {
document.querySelectorAll("#f-range .fchip").forEach((b) => b.classList.toggle("on", b.dataset.range === filter.range));
document.getElementById("f-kind").innerHTML =
`<button type="button" class="fchip ${filter.kinds.size ? "" : "on"}" data-kind="">Alle Arten</button>` +
KINDS.map(([k, label]) =>
`<button type="button" class="fchip ${filter.kinds.has(k) ? "on" : ""}" data-kind="${k}"><span class="tl-dot ${k}"></span>${label}</button>`
).join("");
document.getElementById("f-host").innerHTML =
`<button type="button" class="fchip ${filter.host ? "" : "on"}" data-host="">Alle Drucker</button>` +
printers.map((p) =>
`<button type="button" class="fchip ${filter.host === p.host ? "on" : ""}" data-host="${escapeHtml(p.host)}">${escapeHtml(p.name)}</button>`
).join("");
}
function sinceIso() {
if (filter.range === "all") return "";
const d = new Date();
if (filter.range === "day") d.setHours(0, 0, 0, 0);
else d.setDate(d.getDate() - 7);
return d.toISOString();
}
function dayLabel(date) {
const today = new Date(); today.setHours(0, 0, 0, 0);
const that = new Date(date); that.setHours(0, 0, 0, 0);
const diff = Math.round((today - that) / 86400000);
if (diff === 0) return "Heute";
if (diff === 1) return "Gestern";
return that.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" });
}
function itemHtml(ev) {
const t = new Date(ev.created_at);
const time = t.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
let msg = ev.message || "";
if (ev.printer && msg.startsWith(ev.printer)) msg = msg.slice(ev.printer.length).replace(/^[\s:·—-]+/, "");
const who = ev.printer
? `<a href="/#${encodeURIComponent(ev.host)}"><b>${escapeHtml(ev.printer)}</b></a>`
: `<b>Grotti</b>`;
const file = ev.filename && !msg.includes(ev.filename)
? `<div class="tl-file">${icon("file", "ico-sm")} ${escapeHtml(ev.filename)}</div>` : "";
return `<li class="tl-item ${ev.unread ? "unread" : ""}">
<span class="tl-time" title="${escapeHtml(t.toLocaleString("de-DE"))}">${time}</span>
<span class="tl-dot ${escapeHtml(ev.kind)}"></span>
<div class="tl-body">
<div class="tl-head">${who}<span class="tl-kind">${escapeHtml(KIND_LABEL[ev.kind] || ev.kind)}</span></div>
<div class="tl-msg">${escapeHtml(msg)}</div>${file}
</div>
</li>`;
}
function render(items) {
const box = document.getElementById("timeline");
const note = document.getElementById("tl-note");
if (!items.length) {
box.innerHTML = `<div class="card empty-state">${icon("activity")}<p>Keine Einträge${filter.range === "day" ? " heute" : ""}.</p>${
filter.range !== "all" ? `<button type="button" class="ghost" data-range-jump="all">Alle anzeigen</button>` : ""}</div>`;
note.textContent = "";
return;
}
const groups = [];
for (const ev of items) {
const label = dayLabel(ev.created_at);
if (!groups.length || groups[groups.length - 1].label !== label) groups.push({ label, items: [] });
groups[groups.length - 1].items.push(ev);
}
box.innerHTML = groups.map((g) =>
`<section class="tl-day"><h3>${escapeHtml(g.label)} · ${g.items.length}</h3><ol class="tl-list">${g.items.map(itemHtml).join("")}</ol></section>`
).join("");
note.textContent = items.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : "";
}
async function load() {
const q = new URLSearchParams({ limit: "300" });
const since = sinceIso();
if (since) q.set("since", since);
if (filter.kinds.size) q.set("kind", [...filter.kinds].join(","));
if (filter.host) q.set("host", filter.host);
try {
const data = await api("/ereignisse?" + q.toString());
render(data.items || []);
} catch (err) {
document.getElementById("timeline").innerHTML = `<div class="strip bad">${icon("alert")}<span>${escapeHtml(err.message)}</span></div>`;
}
}
document.addEventListener("click", (e) => {
const range = e.target.closest("[data-range], [data-range-jump]");
if (range) {
filter.range = range.dataset.range || range.dataset.rangeJump;
} else if (e.target.closest("#f-kind [data-kind]")) {
const k = e.target.closest("[data-kind]").dataset.kind;
if (!k) filter.kinds.clear();
else if (filter.kinds.has(k)) filter.kinds.delete(k);
else filter.kinds.add(k);
} else if (e.target.closest("#f-host [data-host]")) {
filter.host = e.target.closest("[data-host]").dataset.host;
} else {
return;
}
syncUrl();
renderFilters();
load();
});
document.getElementById("tl-refresh").addEventListener("click", load);
document.getElementById("tl-read").addEventListener("click", async () => {
await api("/ereignisse/gelesen", { method: "POST", json: {} });
toast("Alles als gelesen markiert.", "good");
if (typeof refreshEvents === "function") refreshEvents();
load();
});
window.onEventsRead = load;
(async () => {
try {
printers = await api("/drucker?live=0");
} catch (_) {
printers = [];
}
renderFilters();
load();
setInterval(load, 15000);
})();
</script>
{% endblock %}
File diff suppressed because it is too large. Load diff
+100
View File
@@ -0,0 +1,100 @@
{% extends "layout.html" %}
{% from "_doc_toc.html" import doc_toc with context %}
{% block body %}
<div class="doc-layout">
{{ doc_toc('start') }}
<article class="prose">
<h1 id="grotti">Grotti <span class="badge">v{{ version }}</span></h1>
<p class="lede">Grotti steuert die Drucker im Haus von einer Seite aus: sehen, was läuft, Dateien schicken, Aufträge einreihen und nachlesen, was passiert ist.</p>
<p>Sie läuft auf diesem Rechner und spricht jeden Drucker über Moonraker an (Rinkhals auf Kobra 3 und Kobra S1). Daten liegen in PostgreSQL. Alles, was die Seiten zeigen, gibt es auch unter <code>/api/v1</code>.</p>
<blockquote>Sicherheit: Ein Druck startet nur nach ausdrücklicher Bestätigung. Es gibt keine freie G-code-Konsole und kein <code>FIRMWARE_RESTART</code>.</blockquote>
<h2 id="druck">Ein Druck von A bis Z</h2>
<div class="flow-diagram">
<span class="step done">Datei</span><span class="step-sep"></span>
<span class="step done">Drucker</span><span class="step-sep"></span>
<span class="step now">Jetzt · Wenn frei · Nächster freier</span><span class="step-sep"></span>
<span class="step">Druck</span><span class="step-sep"></span>
<span class="step">Platte räumen</span>
</div>
<ol>
<li><strong>Datei wählen.</strong> Unter <a href="/dateien">Dateien</a> links in der Bibliothek. Der Slice-Ordner wird beobachtet, Hochladen geht per Knopf oder Ziehen.</li>
<li><strong>Drucker wählen.</strong> Die Kacheln zeigen live, was jeder Drucker gerade tut. Passt die Maschine nicht zur Datei, ist die Kachel blass.</li>
<li><strong>Wann.</strong> <em>Jetzt</em> startet sofort. <em>Wenn frei</em> reiht für diesen Drucker ein. <em>Nächster freier</em> lässt Grotti den ersten freien passenden Drucker nehmen.</li>
<li><strong>Starten.</strong> „Jetzt“ will zwei Klicks. Der zweite ist die Bestätigung.</li>
<li><strong>Während des Drucks</strong> zeigt die <a href="/">Wand</a> Fortschritt, Fertig-Uhrzeit und Temperaturen. Ein Klick auf die Karte öffnet das Cockpit mit Kamera, Steuerung, Bettnetz und 3D-Pfad. Die Seitenleiste zeigt immer, was läuft und was wartet.</li>
<li><strong>Danach</strong> ist die Platte belegt. Ein wartender Auftrag startet erst, wenn auf der Karte <em>Platte geräumt</em> gedrückt wurde, oder ein Ablauf sie selbst freischiebt.</li>
</ol>
<h2 id="ablauf">Ablauf (Kobra 3)</h2>
<p>Ein Ablauf ist eine Kette, die der Kobra 3 alleine abarbeitet und beliebig oft wiederholt:</p>
<div class="flow-diagram">
<span class="step">Drucken</span><span class="step-sep"></span>
<span class="step">Abkühlen</span><span class="step-sep"></span>
<span class="step">Schieben</span><span class="step-sep"></span>
<span class="step">Tasten</span><span class="step-sep"></span>
<span class="step">nochmal</span>
</div>
<ul>
<li><strong>Abkühlen</strong> wartet, bis Bett und Düse unter der Grenze sind. Danach löst sich PLA meist von selbst.</li>
<li><strong>Schieben</strong> fährt mit dem Extruder knapp über dem Bett von hinten nach vorn und schiebt das Teil in die Kiste.</li>
<li><strong>Tasten</strong> tippt mit aktivem LeviQ-Netz (ohne <code>BED_MESH_CLEAR</code>, ohne <code>G28 Z</code>). Tastpunkt einstellbar. Maß: <code>Result is z=</code> über der lokalen Platte. Über 1 mm stoppt den Ablauf.</li>
</ul>
<p>Einreihen unter <a href="/dateien?tab=ablauf">Dateien → Ablauf</a>. Der aktuelle Schritt steht auf der Druckerkarte und in der Seitenleiste. Abbrechen geht an beiden Stellen. Jeder Schritt landet unter <a href="/aktivitaet">Aktivität</a>.</p>
<h2 id="seiten">Seiten</h2>
<dl class="terms">
<dt><a href="/">Drucker</a></dt><dd>Die Wand. Eine Karte je Drucker mit Zustand, Fertig-Uhrzeit, Temperaturen und Ablauf-Schritten. Klick öffnet das Cockpit mit Kamera, Steuerung, Bettnetz und 3D-Pfad.</dd>
<dt><a href="/dateien">Dateien</a></dt><dd>Bibliothek und Senden. Spule und ACE-Plätze unter Optionen. Warnt, wenn das Filament nicht reicht.</dd>
<dt><a href="/kameras">Kameras</a></dt><dd>Alle Kamerabilder nebeneinander.</dd>
<dt><a href="/aktivitaet">Aktivität</a></dt><dd>Zeitstrahl: Starts, Schritte, Fertig, Fehler, Abbrüche, offline. Filter nach Drucker, Art und Zeitraum.</dd>
<dt><a href="/protokoll">Protokoll</a></dt><dd>Abgeschlossene Läufe mit Dauer, Gramm, Kosten und Urteil.</dd>
<dt><a href="/material">Material</a></dt><dd>Sorten, Spulen, Farben, ACE-Plätze, Etiketten.</dd>
<dt><a href="/projekte">Projekte</a></dt><dd>Aufträge mit Notiz und Kalkulation.</dd>
<dt><a href="/einstellungen">Einstellungen</a></dt><dd>Drucker, Telegram-Alerts, Energie.</dd>
</dl>
<h2 id="haengt">Wenn etwas hängt</h2>
<dl class="terms">
<dt>Auftrag wartet und startet nicht</dt><dd>Auf der Karte steht <em>Platte belegt</em>. Platte räumen, dann <em>Platte geräumt</em> drücken.</dd>
<dt>Drucker im Fehlerzustand</dt><dd>Auf der Karte <em>Fehler löschen</em>. Grotti räumt hängende Fehler vor einem Start selbst, wenn kein Druck aktiv ist.</dd>
<dt>Ablauf gestoppt</dt><dd>Die Seitenleiste zeigt den Grund. Ursache beheben, Ablauf neu einreihen. Unter <a href="/aktivitaet">Aktivität</a> steht, welcher Schritt zuletzt lief.</dd>
<dt>Drucker offline</dt><dd>Strom und WLAN prüfen. Grotti meldet, wenn er wieder da ist.</dd>
<dt>ACE startet nicht</dt><dd>Unter Optionen die ACE-Zuordnung prüfen. Ohne Zuordnung kommt oft „cannot parse“.</dd>
</dl>
<h2 id="begriffe">Begriffe</h2>
<dl class="terms">
<dt>Auftrag</dt><dd>Ein Eintrag in der Warteschlange: eine Datei für einen Drucker oder für den nächsten freien.</dd>
<dt>Ablauf</dt><dd>Auftrag mit mehreren Schritten und Wiederholungen. Nur Kobra 3.</dd>
<dt>Wenn frei</dt><dd>Startet, sobald der Drucker bereit und die Platte geräumt ist.</dd>
<dt>Platte belegt</dt><dd>Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.</dd>
<dt>Alert</dt><dd>Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.</dd>
<dt>Ereignis</dt><dd>Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.</dd>
</dl>
<h2>Technik</h2>
<p>Die Notizen zu Aufbau und Schnittstellen:</p>
<ul>
{% for name in docs %}<li><a href="/doku/{{ name }}">{{ name | replace('-', ' ') }}</a></li>{% endfor %}
</ul>
<p>Was sich je Version geändert hat, steht im <a href="/doku/changelog">Changelog</a>.</p>
</article>
</div>
{% endblock %}
{% block script %}
<script>
(() => {
const links = [...document.querySelectorAll(".doc-toc a[data-sec]")];
const sections = links.map((a) => document.getElementById(a.dataset.sec)).filter(Boolean);
if (!sections.length || !("IntersectionObserver" in window)) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
links.forEach((a) => a.classList.toggle("on", a.dataset.sec === entry.target.id));
});
}, { rootMargin: "0px 0px -70% 0px" });
sections.forEach((s) => observer.observe(s));
})();
</script>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "layout.html" %}
{% from "_doc_toc.html" import doc_toc with context %}
{% block body %}
<div class="doc-layout">
{{ doc_toc(current) }}
<article class="prose">
<p class="tiny"><a href="/doku">Doku</a> · {{ title }}</p>
{{ content }}
</article>
</div>
{% endblock %}
+349
View File
@@ -0,0 +1,349 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Einstellungen</h1>
<p class="tiny">Drucker, Benachrichtigungen und Energiekosten.</p>
</div>
</div>
<section class="card">
<h2>Dieser Browser</h2>
<p class="hint">Nur auf diesem Gerät / in diesem Browser. Anderer PC oder Inkognito bleibt unberührt.</p>
<div class="field" style="max-width:16rem">
<label for="display-mode">Anzeige</label>
<select id="display-mode">
<option value="desktop">Desktop</option>
<option value="shelf">Regal (Touch)</option>
</select>
</div>
<p class="hint">Regal: größere Bedienelemente, kompakte Navigation — ideal für ~1024×860 Touch am Regal. Shortcut: <code>?shelf=1</code></p>
<p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p>
</section>
<section class="card">
<h2>Automatik</h2>
<label class="check"><input type="checkbox" id="auto-queue"> Warteschlange „wenn frei“ und Abläufe automatisch starten</label>
<p class="hint">Aus = keine neuen Starts. Laufende Abläufe und gesperrte Drucker bleiben unberührt.</p>
<div class="actions"><button type="button" id="auto-save">Speichern</button></div>
<p id="auto-note"></p>
</section>
<section class="card">
<h2>Öffentliche Adresse</h2>
<p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z.&nbsp;B. localhost).</p>
<div class="field" style="max-width:28rem">
<label for="fqdn">FQDN</label>
<input id="fqdn" placeholder="https://grotti.example.de oder grotti.lan" autocomplete="off">
</div>
<p class="hint">Ohne Schema wird <code>https://</code> ergänzt. Mit Port: <code>http://10.10.1.5:8770</code>.</p>
<div class="actions"><button type="button" id="fqdn-save">Speichern</button></div>
<p id="fqdn-note"></p>
</section>
<section class="card">
<h2>Drucker</h2>
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.</p>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Host</th><th>Familie</th><th>Gesperrt</th><th></th></tr></thead>
<tbody id="printer-rows"></tbody>
</table>
</div>
<form id="printer-add" class="grid-2" style="margin-top:1rem">
<div class="field">
<label for="add-name">Name</label>
<input id="add-name" required placeholder="z. B. Kobra 3 Werkstatt">
</div>
<div class="field">
<label for="add-host">Host / IP</label>
<input id="add-host" required placeholder="10.10.1.60">
</div>
<div class="field">
<label for="add-family">Familie</label>
<select id="add-family">
<option value="">automatisch</option>
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
</select>
</div>
<div class="actions" style="align-self:end"><button type="submit">Hinzufügen</button></div>
</form>
<p id="printer-note"></p>
</section>
<section class="card">
<h2>Telegram</h2>
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
<div class="grid-2" style="margin-top:0.75rem">
<div class="field">
<label for="tg-token">Bot-Token</label>
<input id="tg-token" type="password" autocomplete="off" placeholder="123456:ABC…">
</div>
<div class="field">
<label for="tg-chat">Chat-ID</label>
<input id="tg-chat" placeholder="z. B. -100… oder User-ID">
</div>
</div>
<div class="grid-2" style="margin-top:0.75rem">
<label class="check"><input type="checkbox" id="al-fertig"> Fertig</label>
<label class="check"><input type="checkbox" id="al-fehler"> Fehler und Abbrüche</label>
<label class="check"><input type="checkbox" id="al-offline"> Offline</label>
<label class="check"><input type="checkbox" id="al-queue"> Queue (Fehler, Ablauf gestoppt)</label>
<label class="check"><input type="checkbox" id="al-ablauf"> Ablauf-Schritte</label>
<label class="check"><input type="checkbox" id="al-start"> Druckstart und Pause</label>
</div>
<p class="hint">Ereignisse stehen immer unter <a href="/aktivitaet">Aktivität</a>. Die Häkchen steuern nur Telegram.</p>
<div class="actions">
<button type="button" id="tg-save">Speichern</button>
<button type="button" class="ghost" id="tg-test">Test senden</button>
</div>
<p id="tg-note"></p>
</section>
<section class="card">
<h2>Energie</h2>
<p class="hint">Für Kosten im Protokoll. Filament zählt erst mit zugeordneter Spule.</p>
<div class="grid-2">
<div class="field"><label for="en-watts">Drucker W</label><input id="en-watts" type="number" step="1"></div>
<div class="field"><label for="en-kwh">€ / kWh</label><input id="en-kwh" type="number" step="0.01"></div>
<div class="field"><label for="en-dryer">Trockner W</label><input id="en-dryer" type="number" step="1"></div>
<div class="field"><label for="en-dryh">Trockenstunden</label><input id="en-dryh" type="number" step="0.1"></div>
<div class="field"><label for="en-waste">Ausschuss %</label><input id="en-waste" type="number" step="0.1"></div>
<div class="field"><label for="en-util">Auslastung %</label><input id="en-util" type="number" step="1"></div>
</div>
<div class="actions"><button type="button" id="en-save">Speichern</button></div>
<p id="en-note"></p>
</section>
{% endblock %}
{% block script %}
<script>
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
/* showNote aus api.js: Inline-Hinweis + Toast */
function renderPrinters(rows) {
const body = document.getElementById("printer-rows");
body.innerHTML = "";
if (!rows.length) {
body.innerHTML = '<tr><td class="empty" colspan="5">Keine Drucker.</td></tr>';
return;
}
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td><input data-f="name"></td><td><input data-f="host"></td><td><select data-f="family">
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
<option value="">—</option>
</select></td><td><label class="check"><input type="checkbox" data-f="locked"></label></td><td></td>`;
tr.querySelector('[data-f="name"]').value = row.name || "";
tr.querySelector('[data-f="host"]').value = row.host || "";
tr.querySelector('[data-f="family"]').value = row.family || "";
tr.querySelector('[data-f="locked"]').checked = !!row.locked;
const save = document.createElement("button");
save.type = "button";
save.textContent = "Speichern";
save.addEventListener("click", async () => {
try {
await api("/drucker/id/" + row.id, {
method: "PATCH",
json: {
name: tr.querySelector('[data-f="name"]').value,
host: tr.querySelector('[data-f="host"]').value,
family: tr.querySelector('[data-f="family"]').value,
locked: tr.querySelector('[data-f="locked"]').checked,
},
});
showNote("printer-note", null, "Gespeichert: " + tr.querySelector('[data-f="name"]').value);
load();
} catch (error) {
showNote("printer-note", error);
}
});
const del = document.createElement("button");
del.type = "button";
del.className = "ghost warn";
del.textContent = "Entfernen";
del.addEventListener("click", () => {
armButton(del, "Wirklich entfernen?", async () => {
try {
await api("/drucker/id/" + row.id, { method: "DELETE" });
showNote("printer-note", null, "Drucker entfernt.");
load();
} catch (error) {
showNote("printer-note", error);
}
});
});
tr.children[4].appendChild(save);
tr.children[4].appendChild(del);
body.appendChild(tr);
});
}
async function load() {
const [settings, printers] = await Promise.all([
api("/einstellungen"),
api("/drucker?live=0"),
]);
renderPrinters(printers);
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
document.getElementById("fqdn").value = settings.fqdn || "";
document.getElementById("tg-enabled").checked = !!(settings.telegram && settings.telegram.enabled);
document.getElementById("tg-token").value = "";
document.getElementById("tg-token").placeholder = settings.telegram && settings.telegram.bot_token_set
? ("gesetzt: " + (settings.telegram.bot_token || "••••"))
: "Bot-Token";
document.getElementById("tg-chat").value = (settings.telegram && settings.telegram.chat_id) || "";
const alerts = settings.alerts || {};
document.getElementById("al-fertig").checked = alerts.fertig !== false;
document.getElementById("al-fehler").checked = alerts.fehler !== false;
document.getElementById("al-offline").checked = alerts.offline !== false;
document.getElementById("al-queue").checked = alerts.queue !== false;
document.getElementById("al-ablauf").checked = !!alerts.ablauf;
document.getElementById("al-start").checked = !!alerts.start;
const en = settings.energy || {};
document.getElementById("en-watts").value = en.watts ?? 120;
document.getElementById("en-kwh").value = en.euro_per_kwh ?? 0.35;
document.getElementById("en-dryer").value = en.dryer_watts ?? 0;
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
document.getElementById("en-waste").value = en.waste_percent ?? 0;
document.getElementById("en-util").value = en.utilization ?? 50;
}
document.getElementById("auto-save").addEventListener("click", async () => {
try {
await api("/einstellungen", {
method: "PATCH",
json: { auto_queue: document.getElementById("auto-queue").checked },
});
showNote(
"auto-note",
null,
document.getElementById("auto-queue").checked ? "Automatik an." : "Automatik aus — nichts Neues startet."
);
} catch (error) {
showNote("auto-note", error);
}
});
document.getElementById("fqdn-save").addEventListener("click", async () => {
try {
const out = await api("/einstellungen", {
method: "PATCH",
json: { fqdn: document.getElementById("fqdn").value.trim() },
});
document.getElementById("fqdn").value = out.fqdn || "";
showNote(
"fqdn-note",
null,
out.fqdn
? "Gespeichert — Etiketten-QR: " + out.fqdn + "/material?spool=…"
: "Leer — Etiketten nutzen den Request-Host."
);
} catch (error) {
showNote("fqdn-note", error);
}
});
document.getElementById("printer-add").addEventListener("submit", async (event) => {
event.preventDefault();
try {
await api("/drucker", {
method: "POST",
json: {
name: document.getElementById("add-name").value,
host: document.getElementById("add-host").value,
family: document.getElementById("add-family").value,
},
});
document.getElementById("printer-add").reset();
showNote("printer-note", null, "Drucker angelegt.");
load();
} catch (error) {
showNote("printer-note", error);
}
});
document.getElementById("tg-save").addEventListener("click", async () => {
const token = document.getElementById("tg-token").value.trim();
const payload = {
telegram: {
enabled: document.getElementById("tg-enabled").checked,
chat_id: document.getElementById("tg-chat").value.trim(),
},
alerts: {
fertig: document.getElementById("al-fertig").checked,
fehler: document.getElementById("al-fehler").checked,
offline: document.getElementById("al-offline").checked,
queue: document.getElementById("al-queue").checked,
ablauf: document.getElementById("al-ablauf").checked,
start: document.getElementById("al-start").checked,
},
};
if (token) payload.telegram.bot_token = token;
try {
await api("/einstellungen", { method: "PATCH", json: payload });
showNote("tg-note", null, "Telegram gespeichert.");
load();
} catch (error) {
showNote("tg-note", error);
}
});
document.getElementById("tg-test").addEventListener("click", async () => {
try {
await api("/telegram/test", { method: "POST", json: {} });
showNote("tg-note", null, "Testnachricht gesendet.");
} catch (error) {
showNote("tg-note", error);
}
});
document.getElementById("en-save").addEventListener("click", async () => {
try {
await api("/einstellungen", {
method: "PATCH",
json: {
energy: {
watts: Number(document.getElementById("en-watts").value),
euro_per_kwh: Number(document.getElementById("en-kwh").value),
dryer_watts: Number(document.getElementById("en-dryer").value),
dry_hours: Number(document.getElementById("en-dryh").value),
waste_percent: Number(document.getElementById("en-waste").value),
utilization: Number(document.getElementById("en-util").value),
},
},
});
showNote("en-note", null, "Energie gespeichert.");
} catch (error) {
showNote("en-note", error);
}
});
(function initDisplayMode() {
const sel = document.getElementById("display-mode");
if (!sel) return;
try {
sel.value = localStorage.getItem("grotti-display") === "shelf" ? "shelf" : "desktop";
} catch (error) {
sel.value = "desktop";
}
sel.addEventListener("change", () => {
if (typeof setDisplayMode === "function") setDisplayMode(sel.value);
else {
try { localStorage.setItem("grotti-display", sel.value === "shelf" ? "shelf" : "desktop"); } catch (error) {}
document.documentElement.classList.toggle("shelf", sel.value === "shelf");
document.body.classList.toggle("shelf", sel.value === "shelf");
}
const note = document.getElementById("display-note");
if (note) {
note.hidden = false;
setTimeout(() => { note.hidden = true; }, 2500);
}
});
})();
load().catch((error) => showNote("printer-note", error));
</script>
{% endblock %}
+113
View File
@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
color: #111;
background: #f3f3f3;
}
.toolbar {
display: flex;
gap: 0.6rem;
padding: 0.8rem 1rem;
background: #1a1a1a;
color: #eee;
}
.toolbar a, .toolbar button {
color: #111;
background: #f97316;
border: 0;
border-radius: 8px;
padding: 0.4rem 0.85rem;
font: inherit;
font-weight: 650;
text-decoration: none;
cursor: pointer;
}
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
.sheet {
width: 70mm;
min-height: 40mm;
margin: 1.2rem auto;
padding: 3.5mm;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
display: grid;
grid-template-columns: 1fr 22mm;
gap: 3mm;
align-items: center;
}
.meta h1 {
margin: 0 0 2mm;
font-size: 4.2mm;
letter-spacing: -0.02em;
}
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
.dots { display: inline-flex; vertical-align: middle; margin-right: 1.5mm; }
.dot {
width: 3.2mm;
height: 3.2mm;
border-radius: 50%;
border: 0.2mm solid rgba(0,0,0,0.25);
margin-left: -1mm;
}
.dot:first-child { margin-left: 0; }
#qr { width: 20mm; height: 20mm; }
@media print {
body { background: #fff; }
.toolbar { display: none; }
.sheet {
margin: 0;
border: 0;
border-radius: 0;
page-break-inside: avoid;
}
}
</style>
</head>
<body>
<div class="toolbar">
<button type="button" onclick="window.print()">Drucken</button>
<a class="ghost" href="/material?spool={{ spool.id }}">Zurück</a>
</div>
<div class="sheet">
<div class="meta">
<h1>{{ spool.name }}</h1>
<p class="code">{{ spool.chargen_nr or spool.code }}</p>
<p>
{% if spool.colors %}
<span class="dots">
{% for color in spool.colors %}
<span class="dot" style="background:{{ color.hex }}"></span>
{% endfor %}
</span>
{% endif %}
{{ spool.color or spool.material_type }}
</p>
<p>{{ spool.material_type }}{% if spool.brand %} · {{ spool.brand }}{% endif %}</p>
<p>{{ '%.0f'|format(spool.remaining_g) }} g{% if spool.ace_slot %} · ACE {{ spool.ace_slot }}{% endif %}</p>
{% if spool.hersteller_charge %}
<p>Hersteller: {{ spool.hersteller_charge }}</p>
{% endif %}
</div>
<canvas id="qr" width="120" height="120"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/qrcode.min.js"></script>
<script>
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
width: 120,
margin: 1,
color: { dark: "#111111", light: "#ffffff" }
});
</script>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Ideen</h1>
<p class="tiny">Backlog und Notizen für später.</p>
</div>
</div>
<div class="split">
<section class="card table-wrap">
<table>
<thead><tr><th>Titel</th><th>Stand</th></tr></thead>
<tbody id="idea-rows"></tbody>
</table>
</section>
<section class="card">
<h2 id="idea-title">Neue Idee</h2>
<form id="idea-form">
<input id="idea-id" type="hidden">
<div class="field"><label for="idea-name">Titel</label><input id="idea-name" required></div>
<div class="field"><label for="idea-body">Notiz</label><textarea id="idea-body"></textarea></div>
<div class="field"><label for="idea-status">Stand</label>
<select id="idea-status">
<option value="offen">offen</option>
<option value="spaeter">später</option>
<option value="erledigt">erledigt</option>
</select></div>
<div class="actions">
<button type="submit">Merken</button>
<button type="button" class="ghost" id="idea-new">Neu</button>
<button type="button" class="warn" id="idea-delete"><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
</div>
</form>
<p id="idea-note"></p>
</section>
</div>
{% endblock %}
{% block script %}
<script>
let ideas = [];
const STATUS = { offen: "offen", spaeter: "später", erledigt: "erledigt" };
function fill(idea) {
document.getElementById("idea-id").value = idea ? idea.id : "";
document.getElementById("idea-name").value = idea ? idea.title : "";
document.getElementById("idea-body").value = idea ? idea.body : "";
document.getElementById("idea-status").value = idea ? idea.status : "offen";
document.getElementById("idea-title").textContent = idea ? idea.title : "Neue Idee";
document.getElementById("idea-delete").hidden = !idea;
}
function render() {
const body = document.getElementById("idea-rows");
body.innerHTML = "";
if (!ideas.length) {
body.innerHTML = '<tr><td class="empty" colspan="2">Noch nichts notiert.</td></tr>';
return;
}
ideas.forEach((idea) => {
const tr = document.createElement("tr");
tr.className = "pick";
tr.innerHTML = "<td></td><td></td>";
tr.children[0].textContent = idea.title;
tr.children[1].textContent = STATUS[idea.status] || idea.status;
tr.addEventListener("click", () => fill(idea));
body.appendChild(tr);
});
}
async function load() {
ideas = await api("/ideen");
render();
}
document.getElementById("idea-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("idea-id").value;
const payload = {
title: document.getElementById("idea-name").value,
body: document.getElementById("idea-body").value,
status: document.getElementById("idea-status").value,
};
try {
const saved = id
? await api("/ideen/" + id, { method: "PATCH", json: payload })
: await api("/ideen", { method: "POST", json: payload });
document.getElementById("idea-note").textContent = "Gemerkt.";
document.getElementById("idea-note").className = "ok";
await load();
fill(saved);
} catch (error) {
showNote("idea-note", error);
}
});
document.getElementById("idea-new").addEventListener("click", () => fill(null));
document.getElementById("idea-delete").addEventListener("click", (event) => {
const id = document.getElementById("idea-id").value;
if (!id) return;
armButton(event.currentTarget, "Idee löschen?", async () => {
try {
await api("/ideen/" + id, { method: "DELETE" });
toast("Idee gelöscht.");
await load();
fill(null);
} catch (error) {
showNote("idea-note", error);
}
});
});
load().then(() => fill(null)).catch((error) => showNote("idea-note", error));
</script>
{% endblock %}
+102
View File
@@ -0,0 +1,102 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Kameras</h1>
<p class="tiny">Livebilder aller Drucker.</p>
</div>
<span id="when"></span>
</div>
<div class="camera-grid" id="grid"></div>
{% endblock %}
{% block script %}
<script>
const labels = {
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline"
};
const tiles = new Map();
function shownState(printer) {
const state = printer.state || "offline";
const hot = (printer.nozzle_target || 0) > 40 || (printer.bed_target || 0) > 30;
if ((state === "standby" || state === "complete") && hot) return "heating";
return state;
}
function metaLine(printer) {
const state = shownState(printer);
const busy = state === "printing" || state === "paused";
const bits = [labels[state] || state];
if (busy) {
const pct = Math.round((printer.progress || 0) * 100);
bits.push(pct + " %");
const eta = finishClock(printer.remain);
if (eta) bits.push("fertig " + eta);
else if (hours(printer.remain)) bits.push("Rest " + hours(printer.remain));
}
const job = (printer.filename || "").replace(/\.gcode$/i, "").replace(/^(\d+-)+/, "");
if (busy && job) bits.push(job);
return bits.join(" · ");
}
function ensureTile(printer) {
let tile = tiles.get(printer.host);
if (tile) return tile;
tile = document.createElement("figure");
tile.className = "camera-tile";
tile.dataset.host = printer.host;
tile.innerHTML = `
<figcaption>
<span class="cam-name"></span>
<span class="host"></span>
</figcaption>
<p class="cam-meta tiny"></p>
<img alt="" data-role="cam">
<a class="cam-url" href="#"></a>`;
tiles.set(printer.host, tile);
document.getElementById("grid").appendChild(tile);
return tile;
}
async function refresh() {
const printers = await api("/drucker?live=1");
document.getElementById("when").textContent = new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
const grid = document.getElementById("grid");
if (!printers.length) {
tiles.clear();
grid.innerHTML = '<p class="empty">Keine Drucker eingetragen.</p>';
return;
}
if (grid.querySelector(".empty")) grid.innerHTML = "";
const seen = new Set();
for (const printer of printers) {
seen.add(printer.host);
const tile = ensureTile(printer);
const state = shownState(printer);
tile.className = "camera-tile " + state;
tile.querySelector(".cam-name").textContent = printer.name;
tile.querySelector(".host").textContent = printer.host;
tile.querySelector(".cam-meta").textContent = metaLine(printer);
const path = "/api/v1/kamera?host=" + encodeURIComponent(printer.host);
const shown = "/api/v1/kamera?host=" + printer.host;
const img = tile.querySelector('[data-role="cam"]');
img.alt = "Kamera " + printer.name;
img.src = withToken(path) + "&t=" + Math.floor(Date.now() / 2000);
img.classList.remove("broken");
img.onerror = () => img.classList.add("broken");
const link = tile.querySelector(".cam-url");
link.href = withToken(path);
link.textContent = shown;
}
for (const [host, tile] of [...tiles.entries()]) {
if (!seen.has(host)) {
tile.remove();
tiles.delete(host);
}
}
}
refresh().catch((error) => console.error(error));
setInterval(() => refresh().catch(() => {}), 5000);
</script>
{% endblock %}
+431
View File
@@ -0,0 +1,431 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="token" content="{{ token }}">
<title>{{ title }} · Grotti</title>
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=36">
<script>
(function () {
try {
if (!localStorage.getItem("grotti-display") && localStorage.getItem("farm-display")) {
localStorage.setItem("grotti-display", localStorage.getItem("farm-display"));
}
} catch (e) {}
try {
var q = new URLSearchParams(location.search);
if (q.has("shelf")) {
var on = q.get("shelf") === "1" || q.get("shelf") === "true";
localStorage.setItem("grotti-display", on ? "shelf" : "desktop");
q.delete("shelf");
var next = location.pathname + (q.toString() ? "?" + q : "") + location.hash;
history.replaceState(null, "", next);
}
} catch (e) {}
try {
if (localStorage.getItem("grotti-display") === "shelf") document.documentElement.classList.add("shelf");
var nav = localStorage.getItem("grotti-nav-collapsed");
if (nav === "1" || (nav == null && localStorage.getItem("grotti-display") === "shelf")) {
document.documentElement.classList.add("nav-collapsed");
}
} catch (e) {}
})();
</script>
</head>
<body class="app-shell">
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<symbol id="i-printer" viewBox="0 0 24 24"><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 9V3h12v6"/><rect x="6" y="14" width="12" height="8" rx="1"/></symbol>
<symbol id="i-camera" viewBox="0 0 24 24"><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-file" viewBox="0 0 24 24"><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-spool" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3"/></symbol>
<symbol id="i-folder" viewBox="0 0 24 24"><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"><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-list" viewBox="0 0 24 24"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></symbol>
<symbol id="i-activity" viewBox="0 0 24 24"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><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 9v4M12 17h.01"/></symbol>
<symbol id="i-flame" viewBox="0 0 24 24"><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-pause" viewBox="0 0 24 24"><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-play" viewBox="0 0 24 24"><path d="M5 3v18l15-9z"/></symbol>
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="2"/></symbol>
<symbol id="i-layers" viewBox="0 0 24 24"><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="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></symbol>
<symbol id="i-up" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></symbol>
<symbol id="i-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="m7 8 5-5 5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><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-queue" viewBox="0 0 24 24"><path d="M3 5h12"/><path d="M3 12h9"/><path d="M3 19h6"/><path d="m16 11 5 3-5 3z"/></symbol>
<symbol id="i-send" viewBox="0 0 24 24"><path d="m22 2-7 20-4-9-9-4z"/><path d="M22 2 11 13"/></symbol>
<symbol id="i-book" viewBox="0 0 24 24"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5A2.5 2.5 0 0 1 4 19.5"/><path d="M8 7h8M8 11h8"/></symbol>
<symbol id="i-cube" viewBox="0 0 24 24"><path d="m21 8-9-5-9 5v8l9 5 9-5z"/><path d="m3.3 7.2 8.7 4.8 8.7-4.8"/><path d="M12 22V12"/></symbol>
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></symbol>
<symbol id="i-sweep" viewBox="0 0 24 24"><path d="M3 21h18"/><path d="M9 8v8"/><path d="M15 8v8"/><path d="M5 12h14"/><path d="M7 16h10"/><path d="M12 3v5"/></symbol>
<symbol id="i-thermo" viewBox="0 0 24 24"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0"/><path d="M12 14v3"/></symbol>
<symbol id="i-settings" viewBox="0 0 24 24"><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-bulb" viewBox="0 0 24 24"><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 18h6M10 22h4"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></symbol>
<symbol id="i-filter" viewBox="0 0 24 24"><path d="M22 3H2l8 9.46V19l4 2v-8.54z"/></symbol>
<symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></symbol>
<symbol id="i-scan" viewBox="0 0 24 24"><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"/><rect x="7" y="7" width="10" height="10" rx="1"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6M14 11v6"/></symbol>
<symbol id="i-inbox" viewBox="0 0 24 24"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><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-image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="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-grid" viewBox="0 0 24 24"><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-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
<symbol id="i-panel" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="18" rx="1"/><path d="M14 8h7M14 12h7M14 16h7"/></symbol>
<symbol id="i-nozzle" viewBox="0 0 24 24"><path d="M8 3h8v4l-2 3v5l-2 6-2-6v-5L8 7z"/><path d="M9 7h6"/></symbol>
<symbol id="i-bed" viewBox="0 0 24 24"><path d="M4 16h16"/><path d="M5 16v3M19 16v3"/><path d="M6 12h12v4H6z"/><path d="M8 8h8"/></symbol>
<symbol id="i-chamber" viewBox="0 0 24 24"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/></symbol>
<symbol id="i-fan" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M12 4c2 2 2 5 0 6-2-1-2-4 0-6zM12 14c2 1 2 4 0 6-2-2-2-5 0-6zM4 12c2-2 5-2 6 0-1 2-4 2-6 0zM14 12c1-2 4-2 6 0-2 2-5 2-6 0z"/></symbol>
<symbol id="i-fan-chamber" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1.6"/><path d="M12 5.5c1.4 1.4 1.4 3.5 0 4.2-1.4-.7-1.4-2.8 0-4.2zM12 14.3c1.4.7 1.4 2.8 0 4.2-1.4-1.4-1.4-3.5 0-4.2zM5.5 12c1.4-1.4 3.5-1.4 4.2 0-.7 1.4-2.8 1.4-4.2 0zM14.3 12c.7-1.4 2.8-1.4 4.2 0-1.4 1.4-3.5 1.4-4.2 0z"/><rect x="3.5" y="3.5" width="17" height="17" rx="2" fill="none"/></symbol>
<symbol id="i-fan-aux" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"/></symbol>
</svg>
<aside class="sidebar">
<div class="brand-row">
<a class="brand" href="/">Grotti</a>
<button type="button" class="icon-btn nav-toggle" id="nav-toggle" title="Menü einklappen" aria-label="Menü einklappen" aria-pressed="false"><svg class="ico"><use href="#i-panel"/></svg></button>
</div>
<nav class="nav-main" aria-label="Hauptmenü">
<a href="/" class="{{ 'on' if page == 'wand' else '' }}" title="Drucker"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-send"/></svg><span>Dateien</span></a>
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
<a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a>
</nav>
<div class="live-panel" id="live-panel">
<details class="queue-box" data-box="run" id="run-box" open hidden>
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Läuft jetzt</span><span class="count hot" id="run-count">0</span></summary>
<ul class="queue-list" id="run-list"></ul>
</details>
<details class="queue-box" data-box="wait" id="wait-box" open>
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Wartet</span><span class="count" id="wait-count">0</span></summary>
<ul class="queue-list" id="wait-list"></ul>
<div class="queue-empty" id="wait-empty"><svg class="ico"><use href="#i-inbox"/></svg><span>Leer</span></div>
</details>
<details class="queue-box" data-box="todo" id="todo-box" open hidden>
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Aufgaben</span><span class="count todo" id="todo-count">0</span></summary>
<ul class="queue-list" id="todo-list"></ul>
</details>
<details class="queue-box" data-box="send" id="send-box" open hidden>
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Senden</span><span class="count" id="send-count">0</span></summary>
<ul class="queue-list" id="send-list"></ul>
</details>
<details class="queue-box" data-box="alerts" id="alert-box" open hidden>
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Alerts</span><span class="count bad" id="alert-count">0</span></summary>
<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>
</details>
</div>
<nav class="nav-secondary" aria-label="Weitere Seiten">
<a href="/einstellungen" class="{{ 'on' if page == 'einstellungen' else '' }}" title="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg><span>Einstellungen</span></a>
<a href="/ideen" class="{{ 'on' if page == 'ideen' else '' }}" title="Ideen"><svg class="ico"><use href="#i-bulb"/></svg><span>Ideen</span></a>
<a href="/doku" class="{{ 'on' if page == 'doku' else '' }}" title="Doku"><svg class="ico"><use href="#i-book"/></svg><span>Doku</span></a>
</nav>
<div class="side-foot">
<span class="side-copy">© by <a href="mailto:[email protected]">[email protected]</a></span>
<a href="/doku/changelog" title="Changelog">v{{ version }}</a>
</div>
</aside>
<div class="app-main">
<main>
{% block body %}{% endblock %}
</main>
</div>
<nav class="mobile-nav" aria-label="Schnellmenü">
<a href="/" class="{{ 'on' if page == 'wand' else '' }}"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk"><svg class="ico"><use href="#i-send"/></svg><span>Dateien</span></a>
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }} nav-shelf"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }} nav-shelf"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
</nav>
<script src="{{ url_for('static', filename='api.js') }}?v=23"></script>
<script>
(function applyShellClasses() {
const shelf = document.documentElement.classList.contains("shelf");
const collapsed = document.documentElement.classList.contains("nav-collapsed");
document.body.classList.toggle("shelf", shelf);
document.body.classList.toggle("nav-collapsed", collapsed);
const toggle = document.getElementById("nav-toggle");
if (toggle) {
toggle.setAttribute("aria-pressed", collapsed ? "true" : "false");
toggle.title = collapsed ? "Menü ausklappen" : "Menü einklappen";
}
})();
const LIVE_KEY = "grotti-live-closed";
const NAV_KEY = "grotti-nav-collapsed";
const DISPLAY_KEY = "grotti-display";
function setNavCollapsed(on) {
document.documentElement.classList.toggle("nav-collapsed", on);
document.body.classList.toggle("nav-collapsed", on);
try { localStorage.setItem(NAV_KEY, on ? "1" : "0"); } catch (error) {}
const toggle = document.getElementById("nav-toggle");
if (toggle) {
toggle.setAttribute("aria-pressed", on ? "true" : "false");
toggle.title = on ? "Menü ausklappen" : "Menü einklappen";
}
}
function setDisplayMode(mode) {
const shelf = mode === "shelf";
document.documentElement.classList.toggle("shelf", shelf);
document.body.classList.toggle("shelf", shelf);
try { localStorage.setItem(DISPLAY_KEY, shelf ? "shelf" : "desktop"); } catch (error) {}
if (shelf && localStorage.getItem(NAV_KEY) == null) setNavCollapsed(true);
}
document.getElementById("nav-toggle")?.addEventListener("click", () => {
setNavCollapsed(!document.body.classList.contains("nav-collapsed"));
});
(function restoreBoxes() {
let closed = [];
try { closed = JSON.parse(localStorage.getItem(LIVE_KEY) || "[]"); } catch (error) { closed = []; }
if (window.matchMedia("(max-width: 760px)").matches && !localStorage.getItem(LIVE_KEY)) closed = ["wait", "send"];
document.querySelectorAll("#live-panel details[data-box]").forEach((box) => {
if (closed.includes(box.dataset.box)) box.open = false;
box.addEventListener("toggle", () => {
const now = [...document.querySelectorAll("#live-panel details[data-box]")].filter((d) => !d.open).map((d) => d.dataset.box);
localStorage.setItem(LIVE_KEY, JSON.stringify(now));
});
});
})();
function queueTarget(job) {
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
}
async function cancelQueueItem(job, button) {
const running = job.status === "running";
const armed = running
? (job.is_ablauf ? "Ablauf stoppen?" : "Auftrag stoppen?")
: "Entfernen?";
const go = async () => {
if (button) button.disabled = true;
try {
await api("/druck-queue/" + job.id, { method: "PATCH", json: { status: "cancelled" } });
toast(running ? "Abgebrochen." : "Aus der Warteschlange genommen.");
} catch (error) {
toast(error.message, "bad");
}
refreshPrintQueue();
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
if (button) button.disabled = false;
};
if (button) armButton(button, armed, go);
else go();
}
window.cancelQueueItem = cancelQueueItem;
function runItemHtml(job) {
const rep = repeatText(job);
const steps = job.is_ablauf ? stepsHtml(job.step_labels, job.display_step_index != null ? job.display_step_index : job.step_index, true) : "";
return `<li class="queue-item running">
<div class="queue-row">
<a class="queue-link grow" href="/#${encodeURIComponent(job.host || "")}"><span class="queue-name">${escapeHtml(queueTarget(job))}</span></a>
${rep ? `<span class="queue-sub">${escapeHtml(rep)}</span>` : ""}
<span class="queue-tools"><button type="button" class="icon-btn danger" data-cancel-q="${job.id}" title="Abbrechen">${icon("x")}</button></span>
</div>
${steps}
<span class="queue-msg">${escapeHtml(phaseText(job))}</span>
<span class="queue-sub">${escapeHtml(jobLabel(job.filename))}</span>
</li>`;
}
function waitItemHtml(job, index, total) {
const isError = job.status === "error";
const label = job.is_ablauf ? "Ablauf · " + jobLabel(job.filename) : jobLabel(job.filename);
const rep = job.is_ablauf && (job.repeat_total || 1) > 1 ? job.repeat_total + "×" : "";
return `<li class="queue-item ${isError ? "error" : ""}">
<div class="queue-row">
<span class="grow"><span class="queue-name" title="${escapeHtml(job.filename)}">${escapeHtml(label)}</span></span>
<span class="queue-tools">
${!isError && index > 0 ? `<button type="button" class="icon-btn" data-prio-q="${job.id}" data-dir="-1" title="Höher">${icon("up")}</button>` : ""}
${!isError && index < total - 1 ? `<button type="button" class="icon-btn" data-prio-q="${job.id}" data-dir="1" title="Tiefer">${icon("down")}</button>` : ""}
${isError && job.is_ablauf ? `<button type="button" class="icon-btn" data-resume-q="${job.id}" title="Ab Schritt ${(Number(job.step_index) || 0) + 1} (${escapeHtml(job.step_label_current || "")}) weitermachen">${icon("play")}</button>` : ""}
<button type="button" class="icon-btn danger" ${isError ? `data-dismiss-q="${job.id}" title="Ausblenden"` : `data-cancel-q="${job.id}" title="Entfernen"`}>${icon("x")}</button>
</span>
</div>
<span class="queue-sub">${isError ? "fehlgeschlagen · " : "→ "}${escapeHtml(queueTarget(job))}${rep ? " · " + rep : ""}</span>
${isError ? `<span class="queue-msg">${escapeHtml(job.message || "")}</span>` : ""}
</li>`;
}
function directItemHtml(p) {
const pct = Math.round((Number(p.progress) || 0) * 100);
const eta = p.remain > 0 ? "noch " + hours(p.remain) + " · fertig " + finishClock(p.remain) : "";
return `<li class="queue-item running">
<div class="queue-row">
<a class="queue-link grow" href="/#${encodeURIComponent(p.host)}"><span class="queue-name">${escapeHtml(p.name || p.host)}</span></a>
<span class="queue-sub">${pct} %</span>
</div>
<span class="bar slim live"><span style="width:${pct}%"></span></span>
<span class="queue-msg">${escapeHtml(STATE_LABELS[p.state] || p.state)}${eta ? " · " + escapeHtml(eta) : ""}</span>
<span class="queue-sub">${escapeHtml(jobLabel(p.filename))}</span>
</li>`;
}
let queueCache = [];
async function refreshPrintQueue() {
let jobs;
let active = [];
try {
[jobs, active] = await Promise.all([api("/druck-queue"), api("/laeuft").catch(() => [])]);
} catch (error) { return; }
queueCache = jobs || [];
const running = queueCache.filter((job) => job.status === "running");
const queuedHosts = new Set(running.map((job) => job.host));
const direct = (active || []).filter((p) => !queuedHosts.has(p.host));
const waiting = queueCache.filter((job) => job.status === "waiting");
const errors = queueCache
.filter((job) => job.status === "error")
.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)))
.slice(0, 2);
const runBox = document.getElementById("run-box");
runBox.hidden = !(running.length || direct.length);
document.getElementById("run-count").textContent = running.length + direct.length;
document.getElementById("run-list").innerHTML = running.map(runItemHtml).join("") + direct.map(directItemHtml).join("");
const waitList = document.getElementById("wait-list");
document.getElementById("wait-count").textContent = waiting.length;
document.getElementById("wait-count").className = "count" + (errors.length ? " bad" : waiting.length ? " hot" : "");
document.getElementById("wait-empty").hidden = !!(waiting.length || errors.length);
document.getElementById("wait-box").classList.toggle("is-empty", !(waiting.length || errors.length));
waitList.innerHTML = errors.map((job) => waitItemHtml(job, 0, 1)).join("")
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join("");
const panel = document.getElementById("live-panel");
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
btn.onclick = () => {
const job = queueCache.find((item) => item.id === Number(btn.dataset.cancelQ));
if (job) cancelQueueItem(job, btn);
};
});
panel.querySelectorAll("[data-resume-q]").forEach((btn) => {
btn.onclick = () => {
const job = queueCache.find((item) => item.id === Number(btn.dataset.resumeQ));
if (!job) return;
const step = job.step_label_current || ("Schritt " + ((job.step_index || 0) + 1));
armButton(btn, "Weiter ab „" + step + "“?", async () => {
btn.disabled = true;
try {
await api("/druck-queue/" + job.id, { method: "PATCH", json: { status: "running", resume: true } });
toast("Ablauf geht weiter.");
} catch (error) { toast(error.message, "bad"); }
refreshPrintQueue();
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
btn.disabled = false;
});
};
});
panel.querySelectorAll("[data-dismiss-q]").forEach((btn) => {
btn.onclick = () => armButton(btn, "Ausblenden?", async () => {
try { await api("/druck-queue/" + btn.dataset.dismissQ, { method: "PATCH", json: { status: "dismissed" } }); } catch (error) { toast(error.message, "bad"); }
refreshPrintQueue();
});
});
panel.querySelectorAll("[data-prio-q]").forEach((btn) => {
btn.onclick = async () => {
const id = Number(btn.dataset.prioQ);
const dir = Number(btn.dataset.dir);
const idx = waiting.findIndex((job) => job.id === id);
const other = waiting[idx + dir];
if (!other) return;
try {
await api("/druck-queue/" + id, { method: "PATCH", json: { priority: Number(other.priority) + dir } });
} catch (error) { toast(error.message, "bad"); }
refreshPrintQueue();
};
});
}
async function refreshSendQueue() {
let jobs;
try { jobs = await api("/sende-auftraege"); } catch (error) { return; }
const active = (jobs || []).filter((job) => job.status === "pending" || job.status === "running");
const recent = active.length ? active : (jobs || []).slice(0, 2).filter((job) => job.status === "error");
const box = document.getElementById("send-box");
box.hidden = !recent.length;
document.getElementById("send-count").textContent = active.length;
document.getElementById("send-list").innerHTML = recent.map((job) => {
const pct = Math.max(0, Math.min(100, Number(job.progress) || 0));
const cls = job.status === "error" ? "bad" : job.status === "done" ? "good" : "";
return `<li class="queue-item ${job.status === "error" ? "error" : ""}">
<span class="queue-name">${escapeHtml(job.printer || "")}</span>
<span class="queue-msg">${escapeHtml(job.message || "")}</span>
<span class="send-progress-track slim"><span class="send-progress-bar ${cls}" style="width:${pct}%"></span></span>
</li>`;
}).join("");
}
async function refreshEvents() {
let data;
try { data = await api("/ereignisse?unread=1&limit=5"); } catch (error) { return; }
const items = data.items || [];
const unread = Number(data.unread != null ? data.unread : items.length);
const box = document.getElementById("alert-box");
box.hidden = !items.length;
document.getElementById("alert-count").textContent = unread;
document.getElementById("alert-list").innerHTML = items.map((item) => `<li class="queue-item">
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(item.printer || item.kind)}</span></span><span class="queue-sub">${escapeHtml(clockOf(item.created_at))}</span></div>
<span class="queue-msg">${escapeHtml(item.message)}</span>
</li>`).join("");
}
async function refreshTasks() {
let items;
try { items = await api("/aufgaben"); } catch (error) { return; }
items = items || [];
document.getElementById("todo-box").hidden = !items.length;
document.getElementById("todo-count").textContent = items.length;
const list = document.getElementById("todo-list");
list.innerHTML = items.map((p) => `<li class="queue-item">
<div class="queue-row">
<a class="queue-link grow" href="/#${encodeURIComponent(p.host)}"><span class="queue-name">${escapeHtml(p.name || p.host)}</span></a>
<span class="queue-tools"><button type="button" class="icon-btn done-btn" data-cleared="${escapeHtml(p.host)}" title="Platte geräumt (zweimal klicken)" aria-label="Platte geräumt">${icon("check")}</button></span>
</div>
<span class="queue-msg">Platte räumen${p.state === "complete" ? " · Druck fertig" : ""}</span>
${p.filename ? `<span class="queue-sub" title="${escapeHtml(p.filename)}">${escapeHtml(jobLabel(p.filename))}</span>` : ""}
</li>`).join("");
list.querySelectorAll("[data-cleared]").forEach((btn) => {
btn.onclick = () => armButton(btn, "leer?", async () => {
btn.disabled = true;
try {
await api("/drucker/" + encodeURIComponent(btn.dataset.cleared) + "/abraeumen", { method: "POST", json: { confirm: true } });
toast("Platte frei.");
} catch (error) { toast(error.message, "bad"); }
refreshTasks();
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
});
});
}
document.getElementById("events-clear").addEventListener("click", async (event) => {
event.preventDefault();
try { await api("/ereignisse/gelesen", { method: "POST", json: {} }); } catch (error) { /* still */ }
refreshEvents();
if (typeof window.onEventsRead === "function") window.onEventsRead();
});
window.refreshPrintQueue = refreshPrintQueue;
window.refreshSendQueue = refreshSendQueue;
refreshSendQueue();
refreshPrintQueue();
refreshEvents();
refreshTasks();
window.refreshTasks = refreshTasks;
setInterval(refreshTasks, 8000);
setInterval(refreshSendQueue, 2000);
setInterval(refreshPrintQueue, 4000);
setInterval(refreshEvents, 8000);
</script>
{% block script %}{% endblock %}
</body>
</html>
+838
View File
@@ -0,0 +1,838 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Material</h1>
<p class="tiny">Spulen am Drucker und im Regal. Sorten sind die Profile dazu.</p>
</div>
<div class="page-head-side">
<label class="scan-inline" for="scan-code">
<svg class="ico" aria-hidden="true"><use href="#i-scan"/></svg>
<input id="scan-code" autocomplete="off" placeholder="Code oder Charge · Enter" title="USB-Scanner oder Charge tippen">
</label>
<p id="scan-note" class="scan-note" hidden></p>
</div>
</div>
<div class="tabs mat-tabs" role="tablist">
<button type="button" class="on" data-view="spools" role="tab"><svg class="ico"><use href="#i-spool"/></svg>Spulen</button>
<button type="button" data-view="sorten" role="tab"><svg class="ico"><use href="#i-layers"/></svg>Sorten</button>
</div>
<div id="view-spools">
<div class="page-tools mat-tools">
<div class="filter-bar" id="spool-filter" role="group" aria-label="Ort">
<button type="button" class="fchip on" data-filter="all">Alle</button>
<button type="button" class="fchip" data-filter="loaded">Am Drucker</button>
<button type="button" class="fchip" data-filter="shelf">Regal</button>
<button type="button" class="fchip" data-filter="low">Wenig Rest</button>
</div>
<input id="spool-search" type="search" placeholder="Spule suchen…" autocomplete="off">
<button type="button" id="spool-new"><svg class="ico"><use href="#i-spool"/></svg>Neue Spule</button>
</div>
<p id="spool-list-note" class="tiny"></p>
<div class="split mat-split is-idle" id="spool-layout">
<section class="card table-wrap mat-list">
<div class="lib-head">
<h2>Spulen</h2>
<span class="count" id="spool-count">0</span>
</div>
<table>
<thead><tr><th>Spule</th><th>Charge</th><th>Sorte</th><th>Farbe</th><th>Ort</th><th class="num">Rest</th></tr></thead>
<tbody id="spool-rows"></tbody>
</table>
</section>
<section class="card mat-form" id="spool-panel" hidden>
<div class="mat-form-head">
<h2 id="spool-title">Neue Spule</h2>
<button type="button" class="icon-btn" id="spool-close" title="Schließen" aria-label="Schließen"></button>
</div>
<form id="spool-form">
<input id="spool-id" type="hidden">
<div class="field">
<label for="spool-material">Sorte <span class="tip" title="Profil mit Dichte, Preis und Volumen. Verwalten unter Reiter Sorten."><svg class="ico"><use href="#i-info"/></svg></span></label>
<select id="spool-material"></select>
<p id="spool-hint" class="field-help"></p>
</div>
<div class="field">
<label for="spool-name">Name</label>
<input id="spool-name" required>
</div>
<div class="grid-2">
<div class="field"><label for="spool-brand">Marke</label><input id="spool-brand"></div>
<div class="field"><label for="spool-type">Typ</label><input id="spool-type" placeholder="PLA"></div>
</div>
<div class="grid-2">
<div class="field">
<label for="spool-chargen">Grotti-Charge</label>
<input id="spool-chargen" readonly placeholder="wird vergeben">
</div>
<div class="field">
<label for="spool-hersteller">Hersteller-Charge</label>
<input id="spool-hersteller" placeholder="vom Rollenetikett">
</div>
</div>
<details class="mat-color" id="color-box">
<summary>
<svg class="ico caret"><use href="#i-down"/></svg>
<span class="grow">Farbe</span>
<span class="tiny" id="color-sum">noch keine</span>
</summary>
<div class="mat-color-body">
<div id="color-picked" class="picked"></div>
<div id="color-solids" class="color-grid"></div>
<div id="color-multi" class="color-grid"></div>
<label class="mix check"><input id="color-mix" type="checkbox"> Eigene Mischung</label>
</div>
</details>
<div class="grid-2" style="margin-top:0.55rem">
<div class="field">
<label for="spool-printer">Drucker</label>
<select id="spool-printer"></select>
</div>
<div class="field">
<label for="spool-slot">ACE-Platz <span class="tip" title="ACE 1 = Plätze 1–4, ACE 2 = 5–8. Leer = externe Spule am Drucker. Ohne Drucker = Regal."><svg class="ico"><use href="#i-info"/></svg></span></label>
<select id="spool-slot">
<option value="">nicht im ACE</option>
<option value="1">ACE 1 · Platz 1</option>
<option value="2">ACE 1 · Platz 2</option>
<option value="3">ACE 1 · Platz 3</option>
<option value="4">ACE 1 · Platz 4</option>
<option value="5">ACE 2 · Platz 1</option>
<option value="6">ACE 2 · Platz 2</option>
<option value="7">ACE 2 · Platz 3</option>
<option value="8">ACE 2 · Platz 4</option>
</select>
</div>
<div class="field"><label for="spool-price">€ pro kg</label><input id="spool-price" type="number" min="0" step="0.01" value="0"></div>
<div class="field"><label for="spool-left">Rest gramm</label><input id="spool-left" type="number" step="1" value="1000"></div>
<div class="field"><label for="spool-density">Dichte g/cm³</label><input id="spool-density" type="number" min="0.1" step="0.01" value="1.24"></div>
<div class="field"><label for="spool-vol">Volumen mm³/s</label><input id="spool-vol" type="number" min="0.1" step="0.1" value="12"></div>
</div>
<div class="actions">
<button type="submit">Merken</button>
<button type="button" class="ghost" id="spool-shelf" hidden>Ins Regal</button>
<button type="button" class="ghost" id="spool-label" hidden>Etikett</button>
<button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
</div>
</form>
<p id="spool-note"></p>
</section>
</div>
</div>
<div id="view-sorten" hidden>
<div class="page-tools mat-tools">
<input id="sorten-search" type="search" placeholder="Sorte suchen…" autocomplete="off">
<button type="button" id="sorten-new"><svg class="ico"><use href="#i-layers"/></svg>Neue Sorte</button>
</div>
<p id="sorten-list-note" class="tiny"></p>
<div class="split mat-split is-idle" id="sorten-layout">
<section class="card table-wrap mat-list">
<div class="lib-head">
<h2>Sorten</h2>
<span class="count" id="sorten-count">0</span>
</div>
<table>
<thead>
<tr>
<th>Sorte</th>
<th>Typ</th>
<th>Drucker</th>
<th class="num">Dichte</th>
<th class="num">mm³/s</th>
<th class="num">€/kg</th>
</tr>
</thead>
<tbody id="sorten-rows"></tbody>
</table>
</section>
<section class="card mat-form" id="sorten-panel" hidden>
<div class="mat-form-head">
<h2 id="sorten-title">Neue Sorte</h2>
<button type="button" class="icon-btn" id="sorten-close" title="Schließen"></button>
</div>
<form id="sorten-form">
<input id="sorten-id" type="hidden">
<div class="field">
<label for="sorten-name">Name</label>
<input id="sorten-name" required placeholder="z. B. Sunlu PLA+">
</div>
<div class="grid-2">
<div class="field"><label for="sorten-brand">Marke</label><input id="sorten-brand"></div>
<div class="field"><label for="sorten-type">Typ</label><input id="sorten-type" placeholder="PLA"></div>
</div>
<div class="field">
<label for="sorten-machines">Maschinen</label>
<select id="sorten-machines">
<option value="beide">beide</option>
<option value="kobra_3">nur Kobra 3</option>
<option value="kobra_s1">nur Kobra S1</option>
</select>
</div>
<div class="grid-2">
<div class="field"><label for="sorten-density">Dichte g/cm³</label><input id="sorten-density" type="number" min="0.1" step="0.01" value="1.24"></div>
<div class="field"><label for="sorten-price">€ pro kg</label><input id="sorten-price" type="number" min="0" step="0.01" value="0"></div>
<div class="field"><label for="sorten-vol">Volumen mm³/s</label><input id="sorten-vol" type="number" min="0.1" step="0.1" value="12"></div>
<div class="field"><label for="sorten-vol-s1">Volumen S1 <span class="tip" title="Leer = wie oben. Nur setzen, wenn S1 anders ist."><svg class="ico"><use href="#i-info"/></svg></span></label><input id="sorten-vol-s1" type="number" min="0.1" step="0.1" placeholder="wie oben"></div>
</div>
<div class="field">
<label for="sorten-note">Notiz</label>
<input id="sorten-note" placeholder="optional">
</div>
<div class="actions">
<button type="submit">Merken</button>
<button type="button" class="ghost" id="sorten-use">Spule damit anlegen</button>
<button type="button" class="warn" id="sorten-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
</div>
</form>
<p id="sorten-note-msg"></p>
</section>
</div>
</div>
{% endblock %}
{% block script %}
<script>
let spools = [];
let printers = [];
let materials = [];
let palette = { solids: [], multi: [] };
let picked = [];
let colorLabel = "";
let autoName = "";
let spoolFilter = "all";
let spoolSearch = "";
let sortenSearch = "";
let view = "spools";
let spoolPanelOpen = false;
let sortenPanelOpen = false;
document.getElementById("spool-close").innerHTML = icon("x");
document.getElementById("sorten-close").innerHTML = icon("x");
function dots(colors) {
const wrap = document.createElement("span");
wrap.className = "dots";
(colors || []).forEach((color) => {
const dot = document.createElement("span");
dot.className = "dot";
dot.style.background = color.hex;
dot.title = color.name;
wrap.appendChild(dot);
});
return wrap;
}
function slotLabel(slot) {
if (!slot) return "";
if (slot <= 4) return "ACE 1 · " + slot;
return "ACE 2 · " + (slot - 4);
}
function printerName(host) {
return (printers.find((p) => p.host === host) || {}).name || host || "";
}
function ortLabel(spool) {
if (!spool.printer_host) return "Regal";
const name = printerName(spool.printer_host);
if (spool.ace_slot) return name + " · " + slotLabel(spool.ace_slot);
return name + " · extern";
}
function selectedMaterial() {
const id = document.getElementById("spool-material").value;
return materials.find((row) => String(row.id) === id) || null;
}
function volumeOf(material) {
const host = document.getElementById("spool-printer").value;
const printer = printers.find((row) => row.host === host);
if (printer && printer.family === "kobra_s1" && material.volumetric_s1 != null) return material.volumetric_s1;
return material.volumetric;
}
function showHint() {
const material = selectedMaterial();
const node = document.getElementById("spool-hint");
if (!material) {
node.textContent = "";
return;
}
const host = document.getElementById("spool-printer").value;
const printer = printers.find((row) => row.host === host);
const parts = [];
if (printer && material.machines !== "beide" && material.machines !== printer.family) {
parts.push("Diese Sorte läuft bei dir " + material.machines_label + ".");
} else if (material.machines !== "beide") {
parts.push(material.machines_label.charAt(0).toUpperCase() + material.machines_label.slice(1) + ".");
}
if (material.note) parts.push(material.note);
node.textContent = parts.join(" ");
}
function applyMaterial(material, rename) {
if (!material) return;
document.getElementById("spool-material").value = String(material.id);
document.getElementById("spool-brand").value = material.brand;
document.getElementById("spool-type").value = material.material_type;
document.getElementById("spool-density").value = material.density;
document.getElementById("spool-price").value = material.price_per_kg;
document.getElementById("spool-vol").value = volumeOf(material);
const name = document.getElementById("spool-name");
if (rename && (!name.value || name.value === autoName)) {
autoName = material.name;
name.value = material.name;
}
showHint();
}
function colorSummary() {
if (!picked.length) return "noch keine";
return colorLabel || picked.map((c) => c.name).join(" / ");
}
function renderPicked() {
const node = document.getElementById("color-picked");
node.innerHTML = "";
const sum = document.getElementById("color-sum");
if (!picked.length) {
node.textContent = "Farbe tippen — einfarbig oder Mehrton.";
sum.textContent = "noch keine";
return;
}
node.appendChild(dots(picked));
const label = document.createElement("span");
label.textContent = colorLabel || picked.map((color) => color.name).join(" / ");
node.appendChild(label);
sum.textContent = colorSummary();
}
function renderPalette() {
const mix = document.getElementById("color-mix").checked;
const solids = document.getElementById("color-solids");
const multi = document.getElementById("color-multi");
solids.innerHTML = "";
multi.innerHTML = "";
palette.solids.forEach((item) => {
const button = document.createElement("button");
button.type = "button";
button.className = "swatch";
const on = mix
? picked.some((color) => color.hex === item.colors[0].hex)
: picked.length === 1 && picked[0].hex === item.colors[0].hex && colorLabel === item.name;
if (on) button.classList.add("on");
button.appendChild(dots(item.colors));
button.appendChild(document.createTextNode(item.name));
button.addEventListener("click", () => chooseSolid(item));
solids.appendChild(button);
});
palette.multi.forEach((item) => {
const button = document.createElement("button");
button.type = "button";
button.className = "swatch";
if (colorLabel === item.name) button.classList.add("on");
button.appendChild(dots(item.colors));
button.appendChild(document.createTextNode(item.name));
button.addEventListener("click", () => chooseMulti(item));
multi.appendChild(button);
});
renderPicked();
}
function chooseSolid(item) {
const color = item.colors[0];
if (document.getElementById("color-mix").checked) {
const index = picked.findIndex((row) => row.hex === color.hex);
if (index >= 0) picked.splice(index, 1);
else if (picked.length < 8) picked.push(color);
colorLabel = picked.map((row) => row.name).join(" / ");
} else {
picked = [color];
colorLabel = item.name;
}
renderPalette();
}
function chooseMulti(item) {
picked = item.colors.map((color) => ({ name: color.name, hex: color.hex }));
colorLabel = item.name;
document.getElementById("color-mix").checked = false;
renderPalette();
}
function setView(next) {
view = next;
document.querySelectorAll(".mat-tabs [data-view]").forEach((btn) => {
btn.classList.toggle("on", btn.dataset.view === view);
});
document.getElementById("view-spools").hidden = view !== "spools";
document.getElementById("view-sorten").hidden = view !== "sorten";
}
function openSpoolPanel() {
spoolPanelOpen = true;
document.getElementById("spool-panel").hidden = false;
document.getElementById("spool-layout").classList.remove("is-idle");
}
function closeSpoolPanel() {
spoolPanelOpen = false;
document.getElementById("spool-panel").hidden = true;
document.getElementById("spool-layout").classList.add("is-idle");
document.getElementById("spool-id").value = "";
document.getElementById("spool-note").textContent = "";
renderSpools();
}
function openSortenPanel() {
sortenPanelOpen = true;
document.getElementById("sorten-panel").hidden = false;
document.getElementById("sorten-layout").classList.remove("is-idle");
}
function closeSortenPanel() {
sortenPanelOpen = false;
document.getElementById("sorten-panel").hidden = true;
document.getElementById("sorten-layout").classList.add("is-idle");
document.getElementById("sorten-id").value = "";
document.getElementById("sorten-note-msg").textContent = "";
renderSorten();
}
function payload() {
return {
name: document.getElementById("spool-name").value,
brand: document.getElementById("spool-brand").value,
material_type: document.getElementById("spool-type").value,
material_id: document.getElementById("spool-material").value || null,
hersteller_charge: document.getElementById("spool-hersteller").value,
color: colorLabel,
colors: picked,
printer_host: document.getElementById("spool-printer").value,
ace_slot: document.getElementById("spool-slot").value || null,
price_per_kg: Number(document.getElementById("spool-price").value),
remaining_g: Number(document.getElementById("spool-left").value),
density: Number(document.getElementById("spool-density").value),
volumetric: Number(document.getElementById("spool-vol").value),
};
}
function fill(spool) {
openSpoolPanel();
document.getElementById("spool-id").value = spool ? spool.id : "";
document.getElementById("spool-name").value = spool ? spool.name : "";
document.getElementById("spool-brand").value = spool ? spool.brand : "";
document.getElementById("spool-type").value = spool ? spool.material_type : "";
document.getElementById("spool-chargen").value = spool ? (spool.chargen_nr || spool.code || "") : "";
document.getElementById("spool-hersteller").value = spool ? (spool.hersteller_charge || "") : "";
document.getElementById("spool-printer").value = spool ? spool.printer_host : "";
document.getElementById("spool-slot").value = spool && spool.ace_slot ? String(spool.ace_slot) : "";
document.getElementById("spool-price").value = spool ? spool.price_per_kg : 0;
document.getElementById("spool-left").value = spool ? spool.remaining_g : 1000;
document.getElementById("spool-density").value = spool ? spool.density : 1.24;
document.getElementById("spool-vol").value = spool ? spool.volumetric : 12;
document.getElementById("spool-material").value = spool && spool.material_id ? String(spool.material_id) : "";
picked = spool && spool.colors ? spool.colors.map((color) => ({ name: color.name, hex: color.hex })) : [];
colorLabel = spool ? spool.color : "";
document.getElementById("color-mix").checked = false;
autoName = "";
document.getElementById("spool-title").textContent = spool ? spool.name : "Neue Spule";
document.getElementById("spool-delete").hidden = !spool;
document.getElementById("spool-label").hidden = !spool;
document.getElementById("spool-shelf").hidden = !spool || !(spool.printer_host);
document.getElementById("color-box").open = !spool || !picked.length;
showHint();
renderPalette();
renderSpools();
document.getElementById("spool-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
}
function fillSorten(material) {
openSortenPanel();
document.getElementById("sorten-id").value = material ? material.id : "";
document.getElementById("sorten-name").value = material ? material.name : "";
document.getElementById("sorten-brand").value = material ? material.brand : "";
document.getElementById("sorten-type").value = material ? material.material_type : "PLA";
document.getElementById("sorten-machines").value = material ? material.machines : "beide";
document.getElementById("sorten-density").value = material ? material.density : 1.24;
document.getElementById("sorten-price").value = material ? material.price_per_kg : 0;
document.getElementById("sorten-vol").value = material ? material.volumetric : 12;
document.getElementById("sorten-vol-s1").value =
material && material.volumetric_s1 != null ? material.volumetric_s1 : "";
document.getElementById("sorten-note").value = material ? (material.note || "") : "";
document.getElementById("sorten-title").textContent = material ? material.name : "Neue Sorte";
document.getElementById("sorten-delete").hidden = !material;
document.getElementById("sorten-use").hidden = !material;
renderSorten();
}
function shownSpools() {
const q = spoolSearch.trim().toLowerCase();
return spools.filter((spool) => {
if (spoolFilter === "loaded" && !spool.printer_host) return false;
if (spoolFilter === "shelf" && spool.printer_host) return false;
if (spoolFilter === "low" && (Number(spool.remaining_g) || 0) >= 100) return false;
if (!q) return true;
const hay = [
spool.name, spool.chargen_nr, spool.code, spool.material_type, spool.color,
spool.brand, ortLabel(spool),
].join(" ").toLowerCase();
return hay.includes(q);
});
}
function shownSorten() {
const q = sortenSearch.trim().toLowerCase();
if (!q) return materials;
return materials.filter((m) =>
[m.name, m.brand, m.material_type, m.machines_label, m.note].join(" ").toLowerCase().includes(q)
);
}
function fillSelects() {
const select = document.getElementById("spool-printer");
const current = select.value;
select.innerHTML = '<option value="">Regal</option>';
printers.forEach((printer) => {
const option = document.createElement("option");
option.value = printer.host;
option.textContent = printer.name;
select.appendChild(option);
});
select.value = current;
const materialSelect = document.getElementById("spool-material");
const currentMaterial = materialSelect.value;
materialSelect.innerHTML = '<option value="">Sorte wählen</option>';
materials.forEach((material) => {
const option = document.createElement("option");
option.value = material.id;
option.textContent = material.name;
materialSelect.appendChild(option);
});
materialSelect.value = currentMaterial;
}
function renderSpools() {
document.querySelectorAll("#spool-filter .fchip").forEach((btn) => {
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
});
fillSelects();
const body = document.getElementById("spool-rows");
body.innerHTML = "";
const shown = shownSpools();
document.getElementById("spool-count").textContent = String(shown.length);
const note = document.getElementById("spool-list-note");
if (!spools.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("spool") + '<p>Noch keine Spule.</p><p class="tiny">„Neue Spule“ anlegen oder Code scannen.</p></div></td></tr>';
return;
}
if (!shown.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>';
return;
}
note.textContent = shown.length === spools.length
? (shown.length === 1 ? "1 Spule" : shown.length + " Spulen")
: shown.length + " von " + spools.length;
const ordered = [...shown].sort((a, b) => {
const ah = a.printer_host || "zzz";
const bh = b.printer_host || "zzz";
return ah.localeCompare(bh, "de") || (Number(a.ace_slot) || 0) - (Number(b.ace_slot) || 0) || a.name.localeCompare(b.name, "de");
});
let lastPlace = null;
ordered.forEach((spool) => {
const group = spool.printer_host ? printerName(spool.printer_host) : "Regal";
if (group !== lastPlace) {
lastPlace = group;
const head = document.createElement("tr");
head.className = "mat-group";
head.innerHTML = `<td colspan="6">${escapeHtml(group)}</td>`;
body.appendChild(head);
}
const tr = document.createElement("tr");
tr.className = "pick";
if (spoolPanelOpen && String(spool.id) === document.getElementById("spool-id").value) tr.classList.add("on");
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td class=\"num\"></td>";
tr.children[0].textContent = spool.name;
tr.children[1].textContent = spool.chargen_nr || spool.code || "";
tr.children[1].classList.add("nowrap");
tr.children[2].textContent = spool.material_type;
const colorCell = tr.children[3];
if (spool.colors && spool.colors.length) colorCell.appendChild(dots(spool.colors));
const caption = document.createElement("span");
caption.className = "mat-color-cap";
caption.textContent = spool.color ? " " + spool.color : "";
colorCell.appendChild(caption);
tr.children[4].textContent = ortLabel(spool);
tr.children[4].classList.add("nowrap");
tr.children[4].title = ortLabel(spool);
const rest = Number(spool.remaining_g) || 0;
tr.children[5].textContent = grams(rest);
if (rest < 100) tr.children[5].classList.add("msg");
tr.addEventListener("click", () => fill(spool));
body.appendChild(tr);
});
}
function renderSorten() {
fillSelects();
const body = document.getElementById("sorten-rows");
body.innerHTML = "";
const shown = shownSorten();
document.getElementById("sorten-count").textContent = String(shown.length);
const note = document.getElementById("sorten-list-note");
if (!materials.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("layers") + '<p>Noch keine Sorte.</p></div></td></tr>';
return;
}
if (!shown.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Sorte passt.</p></div></td></tr>';
return;
}
note.textContent = shown.length === 1 ? "1 Sorte" : shown.length + " Sorten";
shown.forEach((material) => {
const tr = document.createElement("tr");
tr.className = "pick";
if (sortenPanelOpen && String(material.id) === document.getElementById("sorten-id").value) tr.classList.add("on");
const speeds = material.volumetric_s1 != null && material.volumetric_s1 !== material.volumetric
? material.volumetric + " · S1 " + material.volumetric_s1
: String(material.volumetric);
tr.innerHTML = "<td></td><td></td><td class=\"nowrap\"></td><td class=\"num\"></td><td class=\"num\"></td><td class=\"num\"></td>";
tr.children[0].textContent = material.name;
tr.children[1].textContent = material.material_type;
tr.children[2].textContent = material.machines_label;
tr.children[3].textContent = String(material.density).replace(".", ",");
tr.children[4].textContent = speeds;
tr.children[5].textContent = euro(material.price_per_kg);
tr.addEventListener("click", () => fillSorten(material));
body.appendChild(tr);
});
}
function render() {
renderSpools();
renderSorten();
}
async function load() {
[spools, printers, materials, palette] = await Promise.all([
api("/spulen"),
api("/drucker?live=0"),
api("/materialien"),
api("/farben"),
]);
render();
renderPalette();
}
function setScanNote(text, ok) {
const note = document.getElementById("scan-note");
if (!text) {
note.hidden = true;
note.textContent = "";
return;
}
note.hidden = false;
note.textContent = text;
note.className = "scan-note " + (ok ? "ok" : "msg");
}
document.querySelectorAll(".mat-tabs [data-view]").forEach((btn) => {
btn.addEventListener("click", () => setView(btn.dataset.view));
});
document.getElementById("spool-filter").addEventListener("click", (event) => {
const btn = event.target.closest("[data-filter]");
if (!btn) return;
spoolFilter = btn.dataset.filter || "all";
renderSpools();
});
document.getElementById("spool-search").addEventListener("input", (event) => {
spoolSearch = event.target.value || "";
renderSpools();
});
document.getElementById("sorten-search").addEventListener("input", (event) => {
sortenSearch = event.target.value || "";
renderSorten();
});
document.getElementById("spool-close").addEventListener("click", closeSpoolPanel);
document.getElementById("sorten-close").addEventListener("click", closeSortenPanel);
document.getElementById("spool-material").addEventListener("change", () => applyMaterial(selectedMaterial(), true));
document.getElementById("spool-printer").addEventListener("change", () => {
const material = selectedMaterial();
if (material) document.getElementById("spool-vol").value = volumeOf(material);
showHint();
});
document.getElementById("color-mix").addEventListener("change", renderPalette);
document.getElementById("spool-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("spool-id").value;
const data = payload();
const host = data.printer_host;
const slot = data.ace_slot;
try {
const base = { ...data, printer_host: "", ace_slot: null };
let saved = id
? await api("/spulen/" + id, { method: "PATCH", json: base })
: await api("/spulen", { method: "POST", json: base });
if (host) {
saved = await api("/spulen/" + saved.id + "/einlegen", {
method: "POST",
json: { host, ace_slot: slot },
});
}
toast("Spule gemerkt.");
document.getElementById("spool-note").textContent = "Gemerkt.";
document.getElementById("spool-note").className = "ok";
await load();
fill(saved);
} catch (error) {
showNote("spool-note", error);
}
});
document.getElementById("spool-new").addEventListener("click", () => {
fill(null);
document.getElementById("spool-material").focus();
});
document.getElementById("spool-shelf").addEventListener("click", async () => {
const id = document.getElementById("spool-id").value;
if (!id) return;
try {
const saved = await api("/spulen/" + id, { method: "PATCH", json: { printer_host: "", ace_slot: null } });
toast("Spule liegt im Regal.");
await load();
fill(saved);
} catch (error) {
showNote("spool-note", error);
}
});
document.getElementById("spool-label").addEventListener("click", () => {
const id = document.getElementById("spool-id").value;
if (id) window.open("/etikett/" + id, "_blank", "noopener");
});
document.getElementById("spool-delete").addEventListener("click", () => {
const id = document.getElementById("spool-id").value;
if (!id) return;
armButton(document.getElementById("spool-delete"), "Löschen?", async () => {
await api("/spulen/" + id, { method: "DELETE" });
toast("Spule gelöscht.");
await load();
closeSpoolPanel();
});
});
document.getElementById("sorten-new").addEventListener("click", () => {
fillSorten(null);
document.getElementById("sorten-name").focus();
});
document.getElementById("sorten-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("sorten-id").value;
const volS1 = document.getElementById("sorten-vol-s1").value;
const data = {
name: document.getElementById("sorten-name").value.trim(),
brand: document.getElementById("sorten-brand").value.trim(),
material_type: document.getElementById("sorten-type").value.trim() || "PLA",
machines: document.getElementById("sorten-machines").value,
density: Number(document.getElementById("sorten-density").value) || 1.24,
price_per_kg: Number(document.getElementById("sorten-price").value) || 0,
volumetric: Number(document.getElementById("sorten-vol").value) || 12,
volumetric_s1: volS1 === "" ? null : Number(volS1),
note: document.getElementById("sorten-note").value.trim(),
};
try {
const saved = id
? await api("/materialien/" + id, { method: "PATCH", json: data })
: await api("/materialien", { method: "POST", json: data });
toast("Sorte gemerkt.");
showNote("sorten-note-msg", null, "Gemerkt.");
await load();
fillSorten(saved);
} catch (error) {
showNote("sorten-note-msg", error);
}
});
document.getElementById("sorten-use").addEventListener("click", () => {
const id = document.getElementById("sorten-id").value;
const material = materials.find((row) => String(row.id) === id);
if (!material) return;
setView("spools");
fill(null);
applyMaterial(material, true);
document.getElementById("color-box").open = true;
});
document.getElementById("sorten-delete").addEventListener("click", () => {
const id = document.getElementById("sorten-id").value;
if (!id) return;
armButton(document.getElementById("sorten-delete"), "Löschen?", async () => {
try {
await api("/materialien/" + id, { method: "DELETE" });
toast("Sorte gelöscht.");
await load();
closeSortenPanel();
} catch (error) {
showNote("sorten-note-msg", error);
}
});
});
async function openByCode(raw) {
const code = (raw || "").trim();
if (!code) return;
try {
let spool = null;
if (/^\d+$/.test(code)) {
spool = spools.find((row) => String(row.id) === code) || null;
}
if (!spool && code.includes("spool=")) {
const match = code.match(/[?&]spool=(\d+)/);
if (match) spool = spools.find((row) => String(row.id) === match[1]) || null;
}
if (!spool) spool = await api("/spulen/by-code/" + encodeURIComponent(code));
setView("spools");
fill(spool);
setScanNote("Spule " + (spool.chargen_nr || spool.name) + " geöffnet.", true);
document.getElementById("scan-code").select();
} catch (error) {
setScanNote(error.message || String(error), false);
}
}
document.getElementById("scan-code").addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
openByCode(event.target.value);
}
});
load().then(() => {
closeSpoolPanel();
closeSortenPanel();
const params = new URLSearchParams(location.search);
const spoolId = params.get("spool");
if (spoolId) {
const spool = spools.find((row) => String(row.id) === spoolId);
if (spool) fill(spool);
else openByCode(spoolId);
}
}).catch((error) => showNote("spool-note", error));
</script>
{% endblock %}
+195
View File
@@ -0,0 +1,195 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Projekte</h1>
<p class="tiny">Gruppiere Dateien und Läufe.</p>
</div>
</div>
<div class="split">
<section class="card">
<h2>Liste</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Status</th><th class="num">Dateien</th></tr></thead>
<tbody id="project-rows"></tbody>
</table>
</div>
</section>
<section class="card">
<h2 id="project-title">Neu</h2>
<form id="project-form">
<input id="project-id" type="hidden">
<div class="field"><label for="project-name">Name</label><input id="project-name" required></div>
<div class="field"><label for="project-notes">Notiz</label><textarea id="project-notes"></textarea></div>
<div class="field"><label for="project-status">Status</label>
<select id="project-status">
<option value="idee">Idee</option>
<option value="bereit">Bereit</option>
<option value="druckt">Druckt</option>
<option value="fertig">Fertig</option>
</select></div>
<div class="actions">
<button type="submit">Merken</button>
<button type="button" class="ghost" id="project-new">Neu</button>
<button type="button" class="warn" id="project-delete">Löschen</button>
</div>
</form>
<p id="project-note"></p>
<h2 class="section-gap">Kalkulation</h2>
<label>Spule</label>
<select id="quote-spool"></select>
<div class="grid-2">
<div><label>Teilegewicht g</label><input id="quote-weight" type="number" min="0" step="1" value="0"></div>
<div><label>Stück</label><input id="quote-qty" type="number" min="1" step="1" value="1"></div>
<div><label>Ausschuss %</label><input id="quote-waste" type="number" min="0" step="1" value="0"></div>
<div><label>Auslastung %</label><input id="quote-util" type="number" min="1" max="100" step="1" value="50"></div>
<div><label>Preis €</label><input id="quote-price" type="number" min="0" step="0.01" value="0"></div>
<div>
<label>Preis gilt</label>
<select id="quote-mode">
<option value="perPart">pro Stück</option>
<option value="perKg">pro kg Teile</option>
</select>
</div>
</div>
<div class="actions"><button type="button" id="quote-go">Rechnen</button></div>
<div class="nums" id="quote-result"></div>
</section>
</div>
{% endblock %}
{% block script %}
<script>
const statusLabel = { idee: "Idee", bereit: "Bereit", druckt: "Druckt", fertig: "Fertig" };
let projects = [];
let spools = [];
function fill(project) {
document.getElementById("project-id").value = project ? project.id : "";
document.getElementById("project-name").value = project ? project.name : "";
document.getElementById("project-notes").value = project ? project.notes : "";
document.getElementById("project-status").value = project ? project.status : "idee";
document.getElementById("project-title").textContent = project ? project.name : "Neu";
document.getElementById("project-delete").hidden = !project;
const quote = project && project.quote && project.quote.inputs;
if (quote) {
document.getElementById("quote-weight").value = quote.weight_g;
document.getElementById("quote-qty").value = quote.qty;
document.getElementById("quote-waste").value = quote.waste_percent;
document.getElementById("quote-util").value = quote.utilization;
document.getElementById("quote-price").value = quote.price;
document.getElementById("quote-mode").value = quote.price_mode || "perPart";
}
if (project && project.result) paint(project.result);
else if (project && project.quote && project.quote.result) paint(project.quote.result);
else document.getElementById("quote-result").innerHTML = "";
}
function paint(result) {
document.getElementById("quote-result").innerHTML = `
<div class="num"><span>bleibt gesamt</span><b>${euro(result.margin)}</b></div>
<div class="num"><span>bleibt pro Stunde</span><b>${euro(result.per_hour)}</b></div>
<div class="num"><span>Filament</span><b>${euro(result.filament_cost)}</b></div>
<div class="num"><span>Strom</span><b>${euro(result.energy_cost)}</b></div>
<div class="num"><span>Zeit</span><b>${hours(result.hours * 3600) || "0 min"}</b></div>
<div class="num"><span>Filamentmenge</span><b>${grams(result.filament_g)}</b></div>`;
}
function render() {
const body = document.getElementById("project-rows");
body.innerHTML = "";
if (!projects.length) {
body.innerHTML = '<tr><td class="empty" colspan="3"><div class="empty-state">' + icon("folder") + '<p>Noch kein Projekt.</p></div></td></tr>';
}
projects.forEach((project) => {
const tr = document.createElement("tr");
tr.className = "pick";
tr.innerHTML = "<td></td><td></td><td></td>";
tr.children[0].textContent = project.name;
tr.children[1].textContent = statusLabel[project.status] || project.status;
tr.children[2].textContent = project.file_count || 0;
tr.addEventListener("click", async () => {
const full = await api("/projekte/" + project.id);
fill(full);
if (full.files && full.files[0] && full.files[0].weight_g && !full.quote.inputs) {
document.getElementById("quote-weight").value = full.files[0].weight_g;
}
});
body.appendChild(tr);
});
const select = document.getElementById("quote-spool");
select.innerHTML = '<option value="">ohne Spule</option>';
spools.forEach((spool) => {
const option = document.createElement("option");
option.value = spool.id;
option.textContent = spool.name;
select.appendChild(option);
});
}
async function load() {
[projects, spools] = await Promise.all([api("/projekte"), api("/spulen")]);
const settings = await api("/einstellungen");
document.getElementById("quote-waste").value = settings.energy.waste_percent;
document.getElementById("quote-util").value = settings.energy.utilization;
render();
fill(null);
}
document.getElementById("project-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("project-id").value;
const payload = {
name: document.getElementById("project-name").value,
notes: document.getElementById("project-notes").value,
status: document.getElementById("project-status").value,
};
try {
const saved = id
? await api("/projekte/" + id, { method: "PATCH", json: payload })
: await api("/projekte", { method: "POST", json: payload });
showNote("project-note", null);
document.getElementById("project-note").textContent = "Gemerkt.";
await load();
fill(saved);
} catch (error) {
showNote("project-note", error);
}
});
document.getElementById("project-new").addEventListener("click", () => fill(null));
document.getElementById("project-delete").addEventListener("click", (event) => {
const id = document.getElementById("project-id").value;
if (!id) return;
armButton(event.currentTarget, "Dateien bleiben — löschen?", async () => {
try {
await api("/projekte/" + id, { method: "DELETE" });
toast("Projekt gelöscht.");
load();
} catch (error) {
showNote("project-note", error);
}
});
});
document.getElementById("quote-go").addEventListener("click", async () => {
try {
const payload = await api("/kalkulation", {
method: "POST",
json: {
project_id: document.getElementById("project-id").value || null,
spool_id: document.getElementById("quote-spool").value || null,
weight_g: Number(document.getElementById("quote-weight").value),
qty: Number(document.getElementById("quote-qty").value),
waste_percent: Number(document.getElementById("quote-waste").value),
utilization: Number(document.getElementById("quote-util").value),
price: Number(document.getElementById("quote-price").value),
price_mode: document.getElementById("quote-mode").value,
},
});
paint(payload.result);
} catch (error) {
showNote("project-note", error);
}
});
load().catch((error) => showNote("project-note", error));
</script>
{% endblock %}
+221
View File
@@ -0,0 +1,221 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Protokoll</h1>
<p class="tiny">Abgeschlossene und laufende Drucke mit Dauer, Material und Kosten.</p>
</div>
<div class="page-head-side">
<button type="button" id="sync"><svg class="ico"><use href="#i-refresh"/></svg>Läufe holen</button>
</div>
</div>
<div class="page-tools">
<select id="run-filter-printer" title="Drucker">
<option value="">Alle Drucker</option>
</select>
<select id="run-filter-verdict" title="Urteil">
<option value="">Alle Urteile</option>
<option value="offen">offen</option>
<option value="gut">gut</option>
<option value="ausschuss">Ausschuss</option>
</select>
<label class="check"><input type="checkbox" id="run-filter-spool"> Spule fehlt</label>
</div>
<p id="run-note"></p>
<div class="card table-wrap">
<table>
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Gramm</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
<tbody id="run-rows"></tbody>
</table>
</div>
{% endblock %}
{% block script %}
<script>
const states = {
completed: "fertig", cancelled: "abgebrochen", error: "Fehler",
in_progress: "druckt", interrupted: "unterbrochen",
};
let runs = [];
let spools = [];
function fillPrinterFilter() {
const select = document.getElementById("run-filter-printer");
const current = select.value;
const names = [...new Set(runs.map((run) => run.printer_name).filter(Boolean))].sort((a, b) => a.localeCompare(b, "de"));
select.innerHTML = '<option value="">Alle Drucker</option>';
names.forEach((name) => {
const option = document.createElement("option");
option.value = name;
option.textContent = name;
select.appendChild(option);
});
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function filteredRuns() {
const printer = document.getElementById("run-filter-printer").value;
const verdict = document.getElementById("run-filter-verdict").value;
const needSpool = document.getElementById("run-filter-spool").checked;
return runs.filter((run) => {
if (printer && run.printer_name !== printer) return false;
if (verdict === "offen" && run.verdict) return false;
if (verdict && verdict !== "offen" && run.verdict !== verdict) return false;
if (needSpool && !(run.needs_spool || (!run.deducted && run.weight_g > 0))) return false;
return true;
});
}
function render() {
fillPrinterFilter();
const body = document.getElementById("run-rows");
body.innerHTML = "";
const shown = filteredRuns();
if (!runs.length) {
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("list") + '<p>Noch kein Lauf.</p><p class="tiny">„Läufe holen“ liest die Drucker aus.</p></div></td></tr>';
return;
}
if (!shown.length) {
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("filter") + '<p>Kein Lauf passt zum Filter.</p></div></td></tr>';
return;
}
shown.forEach((run) => {
const tr = document.createElement("tr");
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
tr.children[0].textContent = run.printer_name;
tr.children[1].textContent = (run.filename || "").split("/").pop();
tr.children[2].textContent = states[run.state] || run.state;
tr.children[3].textContent = hours(run.duration_s);
tr.children[4].textContent = run.weight_g ? grams(run.weight_g) : "";
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
tr.children[5].textContent = cost ? euro(cost) : "";
const verdict = document.createElement("select");
[["", "offen"], ["gut", "gut"], ["ausschuss", "Ausschuss"]].forEach(([value, label]) => {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
verdict.appendChild(option);
});
verdict.value = run.verdict || "";
verdict.addEventListener("change", async () => {
try {
await api("/laeufe/" + run.id, { method: "PATCH", json: { verdict: verdict.value } });
} catch (error) {
showNote("run-note", error);
}
});
tr.children[6].appendChild(verdict);
if (run.needs_spool || !run.deducted) {
const select = document.createElement("select");
const blank = document.createElement("option");
blank.value = "";
blank.textContent = run.needs_spool ? "Spule wählen" : "keine";
select.appendChild(blank);
const mine = spools.filter((s) => s.printer_host === run.printer_host);
const others = spools.filter((s) => s.printer_host !== run.printer_host);
const addOpt = (spool, prefix) => {
const option = document.createElement("option");
option.value = spool.id;
option.textContent = (prefix ? prefix + " " : "") + spool.name;
select.appendChild(option);
};
mine.forEach((s) => addOpt(s, ""));
if (mine.length && others.length) {
const sep = document.createElement("option");
sep.disabled = true;
sep.textContent = "— andere —";
select.appendChild(sep);
}
others.forEach((s) => addOpt(s, s.printer_host ? "·" : "Regal ·"));
if (run.spool_id) select.value = String(run.spool_id);
select.addEventListener("change", async () => {
if (!select.value) return;
try {
await api("/laeufe/" + run.id, { method: "PATCH", json: { spool_id: Number(select.value) } });
load();
} catch (error) {
showNote("run-note", error);
}
});
tr.children[7].appendChild(select);
} else {
const spool = spools.find((item) => item.id === run.spool_id);
tr.children[7].textContent = spool ? spool.name : "abgezogen";
}
if (run.file_id) {
const again = document.createElement("button");
again.type = "button";
again.className = "ghost tiny-btn";
again.textContent = "Nochmal";
again.title = "Denselben Druck in „wenn frei“ einreihen";
again.addEventListener("click", () => {
armButton(again, "Nochmal einreihen?", async () => {
again.disabled = true;
try {
let aceMap = [];
let bedLevel = false;
try {
const last = await api(
"/senden-zuletzt?host=" + encodeURIComponent(run.printer_host || "")
+ "&file_id=" + encodeURIComponent(run.file_id)
);
if (last) {
aceMap = last.ace_map || [];
bedLevel = !!last.bed_leveling;
}
} catch (error) { /* ohne Map weiter */ }
const payload = await api("/senden", {
method: "POST",
json: {
file_id: run.file_id,
hosts: run.printer_host ? [run.printer_host] : [],
spool_id: run.spool_id || null,
confirm: true,
when_free: true,
ace_maps: run.printer_host && aceMap.length
? { [run.printer_host]: aceMap }
: {},
bed_leveling: bedLevel,
},
});
toast((payload.count || (payload.jobs || []).length || 1) + " in Warteschlange „wenn frei“.");
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
} catch (error) {
toast(error.message, "bad");
} finally {
again.disabled = false;
}
});
});
tr.children[8].appendChild(again);
}
body.appendChild(tr);
});
}
async function load() {
[runs, spools] = await Promise.all([api("/laeufe"), api("/spulen")]);
render();
}
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
document.getElementById(id).addEventListener("change", render);
document.getElementById(id).addEventListener("input", render);
});
document.getElementById("sync").addEventListener("click", async () => {
const button = document.getElementById("sync");
button.disabled = true;
try {
const result = await api("/laeufe/abgleich", { method: "POST" });
document.getElementById("run-note").textContent = result.gelesen + " Läufe, " + result.abgezogen + " abgezogen.";
document.getElementById("run-note").className = "ok";
await load();
} catch (error) {
showNote("run-note", error);
} finally {
button.disabled = false;
}
});
load().catch((error) => showNote("run-note", error));
</script>
{% endblock %}
File diff suppressed because it is too large. Load diff
+218
View File
@@ -0,0 +1,218 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Warteschlange</h1>
<p class="tiny">Wartende und laufende Aufträge. Direktstarts erscheinen unter Läuft. Priorität mit den Pfeilen ändern.</p>
</div>
<div class="page-head-side">
<button type="button" class="ghost" id="q-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
</div>
</div>
<div class="filter-bar" id="q-filter" role="group" aria-label="Status">
<button type="button" class="fchip on" data-status="">Alle aktiv</button>
<button type="button" class="fchip" data-status="running">Läuft</button>
<button type="button" class="fchip" data-status="waiting">Wartet</button>
<button type="button" class="fchip" data-status="error">Fehler</button>
<button type="button" class="fchip" data-status="history">Historie</button>
</div>
<p id="q-note" class="tiny"></p>
<div class="card table-wrap">
<table>
<thead>
<tr>
<th>Status</th>
<th>Datei</th>
<th>Ziel</th>
<th>Schritte</th>
<th>Meldung</th>
<th></th>
</tr>
</thead>
<tbody id="q-rows"></tbody>
</table>
</div>
{% endblock %}
{% block script %}
<script>
const STATUS_LABEL = {
running: "läuft", waiting: "wartet", error: "Fehler",
done: "fertig", cancelled: "abgebrochen", dismissed: "verworfen",
};
let jobs = [];
let liveDirect = [];
let filter = "";
function targetOf(job) {
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
}
function directRows() {
return liveDirect.map((p) => {
const pct = Math.round((Number(p.progress) || 0) * 100);
const eta = p.remain > 0 ? "noch " + hours(p.remain) + " · fertig " + finishClock(p.remain) : "";
const state = STATE_LABELS[p.state] || p.state || "läuft";
return {
id: "live:" + p.host,
status: "running",
direct: true,
host: p.host,
printer_name: p.name || p.host,
filename: p.filename || "",
message: state + (pct ? " · " + pct + " %" : "") + (eta ? " · " + eta : ""),
progress: pct,
};
});
}
function shownJobs() {
const direct = directRows();
if (filter === "history") {
return jobs.filter((j) => ["done", "cancelled", "dismissed"].includes(j.status));
}
if (filter === "running") {
return jobs.filter((j) => j.status === "running").concat(direct);
}
if (filter) return jobs.filter((j) => j.status === filter);
return jobs.filter((j) => ["running", "waiting", "error"].includes(j.status)).concat(direct);
}
function render() {
document.querySelectorAll("#q-filter .fchip").forEach((b) => {
b.classList.toggle("on", (b.dataset.status || "") === filter);
});
const body = document.getElementById("q-rows");
const shown = shownJobs();
if (!shown.length) {
const hint = jobs.length
? "Nichts in dieser Ansicht."
: (liveDirect.length ? "Keine Queue-Aufträge — laufende Direktstarts unter „Läuft“." : "Noch kein Eintrag.");
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p></div></td></tr>';
document.getElementById("q-note").textContent = "";
return;
}
const waiting = jobs.filter((j) => j.status === "waiting");
body.innerHTML = shown.map((job) => {
const st = job.status;
if (job.direct) {
return `<tr data-direct="${escapeHtml(job.host)}">
<td>${chipHtml("printing", "läuft")}</td>
<td><span title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span></td>
<td><a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a></td>
<td>—</td>
<td class="tiny">${escapeHtml(job.message || "")}</td>
<td class="queue-tools"><a class="icon-btn" href="/#${encodeURIComponent(job.host)}" title="Zur Wand">${icon("printer")}</a></td>
</tr>`;
}
const waitIdx = waiting.findIndex((j) => j.id === job.id);
const steps = job.is_ablauf
? stepsHtml(job.step_labels, job.display_step_index != null ? job.display_step_index : job.step_index, true)
: "";
const rep = repeatText(job);
const tools = [];
if (st === "waiting" && waitIdx > 0) {
tools.push(`<button type="button" class="icon-btn" data-prio="${job.id}" data-dir="-1" title="Höher">${icon("up")}</button>`);
}
if (st === "waiting" && waitIdx >= 0 && waitIdx < waiting.length - 1) {
tools.push(`<button type="button" class="icon-btn" data-prio="${job.id}" data-dir="1" title="Tiefer">${icon("down")}</button>`);
}
if (st === "error" && job.is_ablauf) {
tools.push(`<button type="button" class="icon-btn" data-resume="${job.id}" title="Fortsetzen">${icon("play")}</button>`);
tools.push(`<button type="button" class="icon-btn danger" data-dismiss="${job.id}" title="Verwerfen">${icon("x")}</button>`);
} else if (st === "waiting" || st === "running") {
tools.push(`<button type="button" class="icon-btn danger" data-cancel="${job.id}" title="Abbrechen">${icon("x")}</button>`);
}
return `<tr data-id="${job.id}">
<td>${chipHtml(st === "running" ? "printing" : st === "waiting" ? "waiting" : st === "error" ? "error" : "complete", STATUS_LABEL[st] || st)}</td>
<td><span title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>${rep ? " · " + escapeHtml(rep) : ""}</td>
<td>${job.host ? `<a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a>` : escapeHtml(targetOf(job))}</td>
<td>${steps || "—"}</td>
<td class="tiny">${escapeHtml(job.message || "")}</td>
<td class="queue-tools">${tools.join("")}</td>
</tr>`;
}).join("");
document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge";
}
async function load() {
try {
const [queue, active] = await Promise.all([
api("/druck-queue?limit=120"),
api("/laeuft").catch(() => []),
]);
jobs = queue || [];
const queuedHosts = new Set(jobs.filter((j) => j.status === "running").map((j) => j.host));
liveDirect = (active || []).filter((p) => p.host && !queuedHosts.has(p.host));
render();
} catch (error) {
document.getElementById("q-rows").innerHTML =
`<tr><td class="empty" colspan="6">${escapeHtml(error.message)}</td></tr>`;
}
}
document.getElementById("q-filter").addEventListener("click", (event) => {
const btn = event.target.closest("[data-status]");
if (!btn) return;
filter = btn.dataset.status || "";
render();
});
document.getElementById("q-refresh").addEventListener("click", load);
document.getElementById("q-rows").addEventListener("click", async (event) => {
const cancel = event.target.closest("[data-cancel]");
if (cancel) {
const job = jobs.find((j) => j.id === Number(cancel.dataset.cancel));
if (job) await window.cancelQueueItem(job, cancel);
load();
return;
}
const resume = event.target.closest("[data-resume]");
if (resume) {
armButton(resume, "Fortsetzen?", async () => {
try {
await api("/druck-queue/" + resume.dataset.resume, {
method: "PATCH", json: { status: "running", resume: true },
});
toast("Ablauf geht weiter.");
load();
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
} catch (error) { toast(error.message, "bad"); }
});
return;
}
const dismiss = event.target.closest("[data-dismiss]");
if (dismiss) {
armButton(dismiss, "Verwerfen?", async () => {
try {
await api("/druck-queue/" + dismiss.dataset.dismiss, {
method: "PATCH", json: { status: "dismissed" },
});
load();
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
} catch (error) { toast(error.message, "bad"); }
});
return;
}
const prio = event.target.closest("[data-prio]");
if (prio) {
const id = Number(prio.dataset.prio);
const dir = Number(prio.dataset.dir);
const waiting = jobs.filter((j) => j.status === "waiting");
const idx = waiting.findIndex((j) => j.id === id);
const other = waiting[idx + dir];
if (!other) return;
try {
await api("/druck-queue/" + id, {
method: "PATCH", json: { priority: Number(other.priority) + dir },
});
load();
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
} catch (error) { toast(error.message, "bad"); }
}
});
window.onQueueChanged = load;
load();
setInterval(load, 5000);
</script>
{% endblock %}