Grotti 2.0.0: eigenes Repo, Umbenennung von Farm.
This commit is contained in:
commit
b2e6443c68
69 files changed
+18719
No files matched your search
@@ -0,0 +1,8 @@
|
||||
# Zugangsdaten und Dateiablage bleiben lokal
|
||||
config.json
|
||||
data/
|
||||
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
_tmp_*
|
||||
@@ -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
@@ -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).
|
||||
@@ -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/`.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 < 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).
|
||||
@@ -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.
|
||||
@@ -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**.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
Whitespace-only changes.
+111
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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
@@ -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 ""
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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 ''
|
||||
);
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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).
|
||||
@@ -0,0 +1,5 @@
|
||||
flask>=3.1
|
||||
psycopg[binary]>=3.2
|
||||
paho-mqtt>=2.1
|
||||
cryptography>=42
|
||||
markdown>=3.6
|
||||
@@ -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
@@ -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>
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Whitespace-only changes.
+1328
File diff suppressed because it is too large.
Load diff
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
Vendored
+7
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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. 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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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> <a href="/aktivitaet">alle →</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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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 %}
|
||||
Reference in new issue
Block a user