Grotti 2.10.0: Orca-Senden, Slice-Ordner, ED-Strom, Hygiene.

Slice-Watch mit Ordnern/Filtern, Orca-Nachbearbeitung, Cockpit-Verlauf
mit ED, Zeitzone im Browser. Tasten-Grenze und README-API angeglichen,
toter Code entfernt, Requirements gepinnt.
This commit is contained in:
Dominic Gundel committed 2026-10-18 00:41:15 +02:00
1 parent efd0801cd1
commit 9e22cebb81
48 files changed
+2253 -416

No files matched your search

+4
View File
@@ -2,6 +2,10 @@
config.json
data/
# Slice-/Watch-Ordner: nur der Platzhalter im Git, Inhalt lokal
slice/*
!slice/.gitkeep
.venv/
__pycache__/
*.pyc
+32
View File
@@ -4,6 +4,38 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
## [Unreleased]
## [2.10.0] – 2026-10-18
### Neu
- Einstellungen: Slice-Ordner (`watch_dir`) setzen; Repo-Ordner `slice/` als Standard (Inhalt gitignored).
- Orca-Nachbearbeitung: `scripts/orca_senden.py` lädt den Export nach Grotti und öffnet `/dateien?file=…` (Drucker, ACE, Einreihen in der UI); Anleitung unter Doku.
- Bibliothek: Ordner unter dem Slice-Pfad anlegen und Watch-Dateien verschieben (ID/Projekt bleiben); API `…/dateien/ordner`, `…/verschieben`.
- Bibliothek: Filter Material und Ordner, Sortierung (Neueste / Name / Material / Ordner / Druckzeit / Gewicht), Suche auch in Material und Projekt; Filter bleiben im Browser; Gruppenköpfe bei Sort Material/Ordner.
- Cockpit: Verlauf (Temp/ED) des aktuellen oder letzten Drucks, Link zum Bericht; gemeinsame Chart-Hilfe in `api.js`.
- Druckbericht-Verlauf: Zeitachse (links °C, rechts ED %), gestrichelte Soll-Linien, Einschaltdauer Düse/Bett; Aufheizen wird dichter erfasst.
- Stromschätzung aus ED: Einstellungen → Düse-/Bett-Watt; Bericht zeigt kWh (ED oder pauschal).
- Einstellungen → Dieser Browser: Zeitzone für Anzeigezeiten (Browser automatisch oder z. B. Europe/Berlin).
### Geändert
- Energie-Defaults: Grundlast 40 W (Elektronik), Düse 60 W, Bett 220 W; Hinweis in den Einstellungen, wenn Düse/Bett 0 (nur pauschal).
- Druckbericht: Start/Ende als lesbare Ortszeit (`17.10.2026, 15:09 – 15:46`) statt ISO.
- Sidebar **Läuft jetzt**: eine Zeile pro Drucker (Name · % · Fertig-Uhr), Fortschritt in der Zeile; Klick öffnet die Warteschlange, × bricht ab.
- Spulenakte: KPI-Kacheln statt Fließtext; Druckzeilen mit Datum, Charge `D-…`, Kurzname, Gramm und Status.
- Material-Spulenformular: Marke, Chargen und Technik unter „Weitere Angaben“ eingeklappt.
- Druck-Etikett: „Soll … g“ statt „Ziel“ (Slicer-Filamentmenge, wie im Bericht).
- Tasten-Grenze im Ablauf: dokumentierter Default 3 mm (kalt an der Düse sonst Fehlalarm); Anleitung und Technik-Notiz angepasst.
- `requirements.txt` mit festen Versionen; `requirements-dev.txt` mit pytest.
- Unbenutzte Helfer entfernt (`printers.load_printers`, Mesh-Vergleich 3×3, `fetch_bed_mesh`, `last_sample_at`, …).
### Behoben
- Druckbericht: Objekt-Etiketten kollidieren nicht mehr, wenn dieselbe Chargennummer schon an einem anderen Lauf hängt (Seite `/protokoll/<id>` lieferte 500).
- Orca-Nachbearbeitung: Export unter dem Slice-Ordner (inkl. Unterordner) wird als Watch-Datei übernommen statt flachem Upload; Temp-Dateien `.gcode.pp` erkannt.
- Material: Rest-Gramm im Spulenformular akzeptiert Dezimalwerte (Abzug lieferte z. B. 501,34 g — Browser blockierte bei `step=1`).
## [2.5.1] – 2026-10-17
### Neu
+20 -10
View File
@@ -30,7 +30,7 @@ copy config.example.json config.json
| `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. |
| `watch_dir` | Slice-Ordner (unter Einstellungen setzen). Leer = nur Upload. |
| `token` | Wenn gesetzt, braucht die API `Authorization: Bearer <token>`. |
| `fqdn` | Öffentliche Basis-URL für Spulen-Etiketten-QR. Leer = Request-Host. |
| `energy` | Watt und Strompreis für die Kosten im Protokoll. |
@@ -75,17 +75,27 @@ Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bear
| `POST /auth/setup`, `/auth/login`, `/auth/logout` | Ersten Admin, Login, Logout |
| `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens |
| `GET /version` | Version |
| `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe |
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag |
| `POST /drucker` · `PATCH`/`DELETE /drucker/<id>` | Drucker anlegen/ändern (Admin) |
| `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`, `POST /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange (`confirm` beim Einreihen) |
| `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 |
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE |
| `POST /drucker/<host>/abraeumen` | Platte geräumt (`confirm`) |
| `GET /kamera/<host>`, `/vorschau/<host>` | Snapshot / Job-Thumbnail |
| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek, Upload, Metadaten |
| `POST /dateien/neu-lesen`, `/dateien/ordner`, `/dateien/<id>/verschieben` | Watch-Sync, Ordner, Verschieben |
| `POST /senden`, `GET /sende-auftraege` | Sofort senden (`confirm`), Fortschritt |
| `GET`/`POST /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange (`confirm` beim Einreihen) |
| `GET /laeuft`, `/aufgaben` | Direktstarts und Platte-belegt |
| `POST /ablauf`, `/schieben-testen` | Ablauf Kobra 3 / Schieben-Test |
| `GET /spulen`, `POST /spulen`, `POST /spulen/<id>/einlegen` | Spulen und Einlegen |
| `GET /spulen/<id>/akte`, `/spulen/by-code` | Spulenakte, Scan `S-…` |
| `GET`/`POST /materialien`, `/farben` | Sorten und Farbpalette |
| `GET /laeufe`, `/laeufe/aktuell`, `/laeufe/<id>` | Protokoll und Druckbericht |
| `GET /statistik`, `/statistik.csv` | Auslastung und CSV |
| `GET`/`POST /projekte`, `/ideen` | Projekte und Ideen-Backlog |
| `GET /ereignisse`, `POST /ereignisse/gelesen` | Aktivität und Alerts |
| `POST /kalkulation` | Vorkalkulation Material+Strom |
MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN` (siehe `docs/internal/MCP.md`).
+5 -2
View File
@@ -2,7 +2,7 @@
"host": "127.0.0.1",
"port": 8770,
"data_dir": "data",
"watch_dir": "",
"watch_dir": "slice",
"database_url": "postgresql://[email protected]:5432/grotti",
"token": "",
"secret_key": "",
@@ -16,7 +16,9 @@
"redirect_uri": ""
},
"energy": {
"watts": 120,
"watts": 40,
"nozzle_watts": 60,
"bed_watts": 220,
"euro_per_kwh": 0.35,
"dryer_watts": 0,
"dry_hours": 0,
@@ -33,6 +35,7 @@
"fehler": true,
"offline": true,
"queue": true,
"spule": true,
"ablauf": false,
"start": false
}
+13 -4
View File
@@ -1,18 +1,22 @@
# Dateien senden
Stand **15.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
Stand **17.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
![Dateien: Bibliothek und Senden](../web/static/doku/dateien-flow.png)
## Bibliothek
- Suche, Filter nach Maschine und Projekt
- Suche nach Name, Material und Projekt
- Filter: Maschine, Projekt, Material, Ordner (Unterordner unter dem Slice-Ordner; Uploads als „Upload“)
- Sortierung: Neueste, Name, Material, Ordner, Druckzeit, Gewicht — bei Material/Ordner mit Gruppenköpfen
- Auswahl der Filter bleibt im Browser merken
- Vorschaubild, Material, Zeit, Gewicht, Schichthöhe
- Info-Popup mit Slice-Metadaten (Kopf und Footer)
- **Hochladen** per Knopf oder Drag & Drop
- **Ordner neu lesen**, wenn `watch_dir` in der Config auf den Slice-Ordner zeigt
- Mit gesetztem Slice-Ordner ([Einstellungen](Anleitung-Einstellungen.md)): **Ordner anlegen**, Watch-Dateien per Auswahl **verschieben** (Datei bleibt in der Bibliothek dieselbe, inkl. Projekt)
- **Ordner neu lesen**, wenn unter Einstellungen ein Slice-Ordner gesetzt ist
Ohne `watch_dir` geht nur manueller Upload — das reicht für den Alltag.
Ohne Slice-Ordner geht nur manueller Upload — das reicht für den Alltag. Uploads liegen flach unter „Upload“ und lassen sich in der Bibliothek nicht in Slice-Ordner schieben.
## Senden: drei Schritte
@@ -43,7 +47,12 @@ Große Dateien brauchen oft **eine Minute**. In der Sidebar unter **Senden** sie
- Bei „Wenn frei“: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md)
- Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map)
## Aus Orca
Nach dem G-code-Export kann Orca die Datei direkt nach Grotti laden und diese Seite mit der Datei öffnen — siehe [Orca an Grotti](Anleitung-Orca.md).
## Weiter
- [Orca an Grotti](Anleitung-Orca.md)
- [ACE und Spulen](Anleitung-ACE.md)
- [Warteschlange](Anleitung-Warteschlange.md)
+1 -1
View File
@@ -27,7 +27,7 @@ copy config.example.json config.json
| `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, den Grotti beobachtet. Leer = nur manueller Upload. |
| `watch_dir` | Slice-Ordner (meist unter **Einstellungen** setzen). Leer = nur manueller Upload. Standard-Vorschlag: Ordner `slice` im Repo. |
| `token` | Optionaler Shared-API-Token (Übergang). Mit Benutzerverwaltung nutzt du API-Tokens unter Einstellungen. |
| `fqdn` | Öffentliche Basis-URL für QR auf Spulen-Etiketten. Leer = Request-Host. |
| `auto_queue` | `false` stoppt neue „wenn frei“-Starts. |
+7 -2
View File
@@ -1,17 +1,22 @@
# Einstellungen
Stand **15.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie.
Stand **17.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie.
![Einstellungen: Drucker](../web/static/doku/einstellungen-drucker.png)
## Dieser Browser
- **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche.md).
- **Zeitzone** — Browser automatisch oder fest (z. B. Europe/Berlin) für Protokoll, Bericht und Aktivität.
## Automatik
Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen „wenn frei“-Starts. Laufende Abläufe und manuelle „Jetzt“-Starts bleiben möglich.
## Slice-Ordner
Pfad zum Ordner, den die Bibliothek beobachtet (Export aus Orca/Anycubic, **Ordner neu lesen**, Unterordner). **Standardordner** setzt `slice` im Grotti-Repo. Leer = nur Hochladen. Nur Admin; der Ordner wird beim Speichern angelegt, falls er fehlt.
## Drucker
Tabelle: Name, Host, Familie, **gesperrt**.
@@ -36,7 +41,7 @@ MCP (optional): Token erzeugen, dann `python -m grotti.mcp_server` mit `GROTTI_U
## Energie
Watt und Strompreis für die Kosten im **Protokoll**. Optional Trockner/Ausschuss/Auslastung für die Kalkulation.
**Grundlast W** (Elektronik/Motion, nicht die Heizer) und Strompreis für die Kosten im **Protokoll**. **Düse W** / **Bett W** zusammen mit ED-Proben im Verlauf → Schätzung „aus ED“; stehen sie auf 0, bleibt es bei Dauer × Grundlast („pauschal“). Typisch Kobra: Grundlast ~40 W, Düse ~60 W, Bett ~200–300 W. Beim Speichern warnt die Seite, wenn Düse oder Bett noch 0 sind. Optional Trockner/Ausschuss/Auslastung für die Kalkulation.
## Weiter
+1 -1
View File
@@ -28,7 +28,7 @@ Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Dru
Unter **Statistik** Auslastung, Erfolg, Verbrauch und CSV-Export. Zeitraum per Knopf (Tag, Woche, Monat, Gestern, Letzte Woche, Letzter Monat) oder eigene Von/Bis-Daten.
Druck-Etiketten (`D-…`) schon während des Drucks: an der Wand **Etikett**, im Druckbericht **Etikett Druck** / **Etiketten Objekte**. Auf dem Bogen steht das Zielgewicht. Bei Ausschuss die gedruckten Bögen wegwerfen; die Charge bleibt im Protokoll.
Druck-Etiketten (`D-…`) schon während des Drucks: an der Wand **Etikett**, im Druckbericht **Etikett Druck** / **Etiketten Objekte**. Auf dem Bogen steht das Sollgewicht aus dem Slicer (geplante Filamentmenge). Bei Ausschuss die gedruckten Bögen wegwerfen; die Charge bleibt im Protokoll.
Abzug nach dem Druck nimmt das Ist aus `filament_used` (Abbruch nur anteilig). Mehrfarb-Anteile sind als Schätzwert gekennzeichnet. Restgramm darf unter 0 gehen.
+2 -1
View File
@@ -16,7 +16,7 @@ Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Brow
| Box | Inhalt |
| --- | --- |
| **Läuft jetzt** | Laufende Aufträge und Abläufe, Schrittleiste, Abbrechen. Auch Direktstarts ohne Queue-Zeile. |
| **Läuft jetzt** | Eine Zeile pro Drucker (Name · % · Fertig-Uhr); Klick öffnet die Warteschlange, × bricht ab. Auch Direktstarts ohne Queue-Zeile. |
| **Wartet** | Wartende Aufträge: Priorität, Abbrechen. Gestoppte Abläufe mit Fortsetzen. |
| **Aufgaben** | Platte belegt ohne laufenden Ablauf — **geräumt** (Zwei-Klick). |
| **Senden** | Uploads mit MB-Fortschritt. |
@@ -28,6 +28,7 @@ Unter **Einstellungen → Dieser Browser**:
- **Desktop** — normale Dichte
- **Regal (Touch)** — größere Bedienelemente, kompaktere Nav, Sidebar oft eingeklappt
- **Zeitzone** — Browser oder fest (z. B. Europe/Berlin) für Uhrzeiten in Protokoll und Bericht
Shortcut in der URL: `?shelf=1` bzw. `?shelf=0`. Nur dieser Browser (`localStorage`).
+55
View File
@@ -0,0 +1,55 @@
# Orca an Grotti
Stand **17.10.2026**. Nach dem Export in OrcaSlicer landet der G-code in der Bibliothek und die Seite [Dateien senden](Anleitung-Dateien.md) öffnet sich mit der Datei schon gewählt. Drucker, ACE-Platz und Einreihen wählst du dort wie gewohnt.
## Einmal einrichten
1. Grotti starten und im Browser anmelden (die Session bleibt für die Senden-Seite).
2. Unter **Einstellungen → API-Token** ein Token anlegen (Rolle Operator reicht). Den Wert `grt_…` nur einmal sichtbar.
3. Datei `data/orca-senden.json` anlegen (gleicher Ordner wie `config.json`):
```json
{
"url": "http://127.0.0.1:8770",
"token": "grt_…"
}
```
`url` ist die Adresse, unter der du Grotti im Browser öffnest.
4. Im **Prozessprofil** (Druckqualität, z. B. „0.20mm Standard“): **Sonstiges → Nachbearbeitungsskripte**, eine Zeile:
```text
D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_senden.py
```
Gilt für **OrcaSlicer** und **AnycubicSlicerNext**. Pfade anpassen. Der Slicer hängt den Export-Pfad selbst an.
Unter **Gerät / Drucker** keinen Print-Host (Moonraker) eintragen — sonst geht der Job am Grotti-Senden vorbei.
## Ablauf
1. In Orca slicen und **G-code exportieren** (Platte schreiben). Als Ziel den Slice-Ordner wählen — **Unterordner** (z. B. `slice\Gridfinity\…`) gehen: Grotti legt die Datei dort ab und liest den Watch neu. Export außerhalb des Slice-Ordners landet als Upload (ohne Ordner-Verschieben).
2. Der Browser öffnet `/dateien?file=…` mit der neuen Datei.
3. Drucker wählen, unter Optionen Farbe → ACE-Platz, **Jetzt** / **Wenn frei** / **Nächster freier**, dann **Starten**.
Test ohne Orca:
```powershell
D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_senden.py C:\pfad\datei.gcode
```
## Störungen
| Symptom | Check |
| --- | --- |
| Meldungsfenster „Config fehlt“ | `data/orca-senden.json` anlegen |
| „Grotti nicht erreichbar“ | Server läuft? `url` stimmt? |
| „Token fehlt oder stimmt nicht“ | Token neu anlegen, in der JSON eintragen |
| Browser ohne Datei / Login | Einmal anmelden; Deep-Link behält `?file=` nach dem Login |
| Skript läuft nicht | Export wirklich speichern; Zeile im **Druckerprofil**; Python-Pfad prüfen |
## Weiter
- [Dateien senden](Anleitung-Dateien.md)
- [ACE und Spulen](Anleitung-ACE.md)
+1
View File
@@ -38,6 +38,7 @@ Inhalt:
- Status, Temps inkl. Bauraum wo verfügbar, Lüfter
- **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole. Mit ACE zusätzlich Trockner (Temp, Dauer, Start/Stopp) und pro bestücktem Platz **an den Drucker** (Sorte und Farbe aus der Grotti-Spule)
- **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an
- **Verlauf** Temperatur und ED des aktuellen oder letzten Drucks; Link zum Druckbericht
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
- **Bettnetz**-Heatmap, wenn vorhanden
+2 -2
View File
@@ -16,7 +16,7 @@ Pro Auftrag:
Unter **Läuft** siehst du auch Direktstarts ohne Queue-Zeile (Link zur Wand).
Dieselbe Information steht kompakt in der Sidebar (**Läuft jetzt** / **Wartet**).
Dieselbe Information steht kompakt in der Sidebar (**Läuft jetzt** / **Wartet**). Unter **Läuft jetzt** eine Zeile pro Drucker; Klick öffnet diese Seite, × bricht ab.
## Wann startet ein wartender Auftrag?
@@ -34,7 +34,7 @@ Drucken → Abkühlen → Schieben → Tasten → (Wiederholung)
| --- | --- |
| **Abkühlen** | Wartet, bis Bett und Düse unter der Grenze sind. PLA löst sich oft von selbst. |
| **Schieben** | Extruder schiebt das Teil von hinten nach vorn in die Kiste. |
| **Tasten** | LeviQ-Netz tippt; Maß aus `Result is z=`. Über 1 mm stoppt den Ablauf. |
| **Tasten** | LeviQ-Netz tippt; Maß aus `Result is z=`. Über 3 mm stoppt den Ablauf (Default; enger geht unter Einstellungen/Ablauf). |
Aktueller Schritt: auf der Druckerkarte und in der Sidebar. Abbrechen an beiden Stellen. Jeder Schritt landet unter **Aktivität**.
+1 -1
View File
@@ -17,7 +17,7 @@ Unter **Dateien** erscheint die Karte **Ablauf**, sobald mindestens eine Kobra 3
- **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.
- **Tasten** — kein `G28 Z`. **Mit aktivem LeviQ-Netz** (kein `BED_MESH_CLEAR`): `Result` = Höhe über der lokalen Platte. Bezugspunkt + Tastpunkt (x/y) einstellbar. Maß: letztes `Result is z=` aus dem Store. Über `max_delta_mm` (Default 3 mm) = Restteil. Enge 1 mm/2,5 mm-Werte aus alten Abläufen werden auf 3 mm angehoben (kalt an der Düse sonst Fehlalarm).
Darunter **wie oft hintereinander**. Einreihen legt einen Eintrag in die Warteschlange „Wenn frei“. **Schieben testen** fährt die Bahn auf einer freien Kobra 3 (Bestätigung „Bett ist leer“). Werte merkt sich der Browser lokal.
+2 -2
View File
@@ -12,9 +12,9 @@ Etikett: `/etikett/<id>`, Druck über den Browser. QR per lokalem `qrcode.min.js
## Druck-Chargen
`D-YYYYMMDD-0001` vergibt der Watcher am aktiven Lauf (History-Job, sonst Live). `adopt_live_run` übernimmt die Nummer. Objekt-Etiketten `D-…-01` sobald Objektnamen da sind.
`D-YYYYMMDD-0001` vergibt der Watcher am aktiven Lauf (History-Job, sonst Live). Die nächste Nummer zählt Läufe und schon vergebene Objekt-Etiketten, damit eine Charge nicht doppelt entsteht. `adopt_live_run` übernimmt die Nummer nur auf einen fertigen History-Lauf ohne eigene Charge. Objekt-Etiketten (`D-…-01`) wandern nur mit, wenn der Ziel-Lauf dieselbe Charge behält; sonst fallen sie mit dem Live-Lauf weg und werden unter der verbleibenden Charge neu vergeben.
Druck über `/etikett/lauf/<id>?mode=druck|objekt`. Auf dem Bogen: Charge, **Zielgewicht** (Slicer-Soll), Drucker, Material (Abzug oder Send/ACE/Lauf-Spule), QR 20 mm im 70‑mm-Bogen. Wand: Knopf **Etikett** bei Druck/Pause. Urteil Ausschuss ändert die Charge nicht — gedruckte Bögen verwerfen.
Druck über `/etikett/lauf/<id>?mode=druck|objekt`. Auf dem Bogen: Charge, **Soll … g** (Slicer-Filamentmenge), Drucker, Material (Abzug oder Send/ACE/Lauf-Spule), QR 20 mm im 70‑mm-Bogen. Wand: Knopf **Etikett** bei Druck/Pause. Urteil Ausschuss ändert die Charge nicht — gedruckte Bögen verwerfen.
## 3D-Pfad
+3 -1
View File
@@ -1,6 +1,8 @@
# Grotti-Dateien Metadaten
`stored_path` liegt nur in der DB und in Rohzeilen (`get_file_row`, `list_file_rows`). Die API-Antwort (`file_out` / `GET /api/v1/dateien`) enthält den Pfad bewusst nicht.
`stored_path` liegt nur in der DB und in Rohzeilen (`get_file_row`, `list_file_rows`). Die API-Antwort (`file_out` / `GET /api/v1/dateien`) enthält den Pfad bewusst nicht — stattdessen berechnet `folder` den Relativ-Elternordner unter `watch_dir` (Uploads → `Upload`).
Verschieben einer Watch-Datei: Dateisystem unter `watch_dir` + `UPDATE files.stored_path` über dieselbe ID (`library.move_file`), damit Projekt/Spule erhalten bleiben. Reiner Re-Scan nach manuellem Verschieben würde die ID verlieren.
`POST /api/v1/dateien/neu-lesen` liest Watch-Ordner neu und ruft `refresh_file_meta` auf Rohzeilen auf — nicht auf `list_files`, sonst `KeyError: 'stored_path'`.
+2 -2
View File
@@ -7,7 +7,7 @@ Stand **17.10.2026**.
- `runs`: zusätzlich `weight_planned_g`, `weight_used_g`, `weight_source`, `filament_used_mm`/`filament_total_mm`, `progress_last`, `estimated_s`, `chargen_nr`, `message`, `objects`
- `run_spools`: Abzug je Spule (`is_estimate` bei Mehrfarb)
- `run_segments`: Abschnitte bei Rollenwechsel (Einlegen während Druck/Pause)
- `run_samples`: Temperatur/Fortschritt ca. alle 30 s (Watcher)
- `run_samples`: Temperatur/Soll/ED (`nozzle_power`/`bed_power`) — alle ~30 s, beim Aufheizen ~10 s; Proben schon vor `printing`, wenn Targets gesetzt und Auftrag/Live-Lauf bekannt
- `run_labels`: Objekt-Chargen `D-…-01`
- `events.run_id`: optionaler Bezug zum Lauf
@@ -19,7 +19,7 @@ Stand **17.10.2026**.
## Live
`StateWatcher` hängt am offenen History-Job oder legt `live:…` an, schreibt Proben, vergibt `chargen_nr`, merkt Objektnamen. Vor neuem Start: `abandon_live_runs`. `sync_history` übernimmt Proben/Segmente/Charge (`adopt_live_run`, nicht auf ältere fertige Jobs derselben Datei) und setzt bei Bedarf `needs_clear`.
`StateWatcher` hängt am offenen History-Job oder legt `live:…` an, schreibt Proben, vergibt `chargen_nr`, merkt Objektnamen. Vor neuem Start: `abandon_live_runs`. `sync_history` übernimmt Proben/Segmente (`adopt_live_run`, nicht auf ältere fertige Jobs derselben Datei). Die Live-Charge geht nur auf einen fertigen Lauf ohne Nummer über; Objekt-Etiketten nur zusammen mit dieser Charge. Sonst werden sie neu vergeben, statt eine fremde `D-…-01` zu blockieren.
## Statistik-Filter
+11 -10
View File
@@ -1,20 +1,20 @@
# Grotti: Alltag und Oberfläche
Stand **14.10.2026**, v1.4.0.
Stand **18.10.2026**, v2.10.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.
- **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. Zeitzone für Anzeigezeiten (`grotti-timezone`).
- 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).
- Hauptmenü: Drucker, Dateien, Kameras, Aktivität, Warteschlange, Protokoll, Statistik, 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.
- **Läuft jetzt**: eine Zeile pro Drucker (Name · % · Fertig-Uhr, Fortschritt in der Zeile); bei Abläufen Schritt `n/m` statt Schrittleiste; Klick → `/warteschlange`, × bricht ab; 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.
@@ -23,14 +23,14 @@ Stand **14.10.2026**, v1.4.0.
## 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).
- Karte je Drucker: Zustandschip, Fertig-Uhrzeit groß, Fortschrittsbalken, drei Kacheln (Rest, Schicht, Läuft), Temperaturbalken Düse/Bett mit Ziel, Datei, Vorschaubild. 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.
- **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 (inkl. ACE-Trockner und ACE-Plätze schreiben), **Verlauf** (aktueller/letzter Druck mit Temp/ED, Link Bericht), 3D-Pfad (Exclude-Object), Bettnetz. E-Stop nur bei printing/paused/heating, Zwei-Klick. Live vom Watcher. API `GET /api/v1/laeufe/aktuell?host=`.
## 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.
- Links Bibliothek: Suche (Name/Material/Projekt), Filter Maschine/Projekt/Material/Ordner, Sortierung inkl. Gruppenköpfe; Filter in `localStorage` `grotti-lib-v1`. Vorschaubild; Zeile zeigt Material, Zeit, Gewicht, Schichthöhe, Maschine, Projekt; Details per Info-Popup. API-Feld `folder` (Relativpfad unter `watch_dir`, Uploads → `Upload`). Mit `watch_dir`: Ordner anlegen (`POST /dateien/ordner`), Watch-Datei verschieben (`POST /dateien/<id>/verschieben`, `stored_path` per ID). Metadaten aus G-code-Kopf **und** Footer. Hochladen / Ordner neu lesen. Orca-Nachbearbeitung: `scripts/orca_senden.py`.
- 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`.
@@ -38,18 +38,19 @@ Stand **14.10.2026**, v1.4.0.
## Material
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf (`S-…` Spule, `D-…` Druckbericht).
- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal. Akte mit Verbrauch und Leer-in.
- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal. Akte mit Verbrauch und Leer-in (KPI-Kacheln).
- Sorten: Profile (Dichte, mm³/s, Preis, Maschinen). Anlegen/ändern/löschen; „Spule damit anlegen“ wechselt zum Spulen-Reiter.
## Protokoll und Statistik
- Protokoll-Zeile → Druckbericht `/protokoll/<id>` (Soll/Ist, Spulen, Temperatur, Charge, Etiketten). Charge schon am laufenden Druck; Etikett an der Wand und im Bericht.
- Statistik `/statistik`: KPIs, nach Drucker/Material, Zeitraum-Presets, CSV mit denselben Filtern.
- Protokoll-Zeile → Druckbericht `/protokoll/<id>` (Soll/Ist, Spulen, Temperatur/ED-Verlauf, Charge, Etiketten). Charge schon am laufenden Druck; Etikett an der Wand und im Bericht.
- Statistik `/statistik`: KPIs (Läufe, Zeit, Verbrauch, Kosten, Erfolg, Ausschuss), nach Drucker/Material, Zeitraum-Presets, CSV mit denselben Filtern.
## 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.
- Ablauf-Tasten: Grenze `max_delta_mm` Default 3 mm.
## Aktivität
+1 -1
View File
@@ -18,7 +18,7 @@ Ab 12.10.2026: Start legt Hintergrund-Aufträge an (`pending` → `running` →
## Slice-Ordner / Upload
`watch_dir` in `config.json` zeigt auf den Slice-Ordner der Bibliothek. Ohne Ordner geht Hochladen über die Dateien-Seite trotzdem.
Slice-Ordner unter **Einstellungen** (persistiert als `watch_dir`). Ohne Ordner geht Hochladen über die Dateien-Seite trotzdem.
**Dateinamen:** Moonraker-Multipart nutzt nur noch einen ASCII-`filename` (`moonraker.safe_upload_name`). Früher standen Nicht-ASCII-Zeichen in `filename*=UTF-8''…`, während `filename=` Unterstriche bekam — gklib speicherte A, Start suchte B → **10115 not found**.
+53 -8
View File
@@ -4,15 +4,20 @@ import json
import secrets
from pathlib import Path
from grotti.errors import GrottiError
ROOT = Path(__file__).resolve().parent.parent
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
CONFIG_PATH = ROOT / "config.json"
DEFAULT_WATCH_REL = "slice"
def _energy(raw):
energy = raw.get("energy") or {}
return {
"watts": float(energy.get("watts", 120)),
"watts": float(energy.get("watts", 40)),
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
"bed_watts": float(energy.get("bed_watts", 220)),
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
"dryer_watts": float(energy.get("dryer_watts", 0)),
"dry_hours": float(energy.get("dry_hours", 0)),
@@ -99,10 +104,7 @@ def load():
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()
watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
return {
"host": host,
"port": int(raw.get("port") or 8770),
@@ -130,11 +132,48 @@ def mask_token(token):
return token[:4] + "…" + token[-2:]
def parse_watch_dir(value, *, create=False, missing_ok=False):
"""Pfad aus Einstellungen/Config → (Path|None, gespeicherter String).
Relativ zum Repo-Root. Leer = aus. create legt den Ordner an.
"""
text = str(value or "").strip()
if not text:
return None, ""
path = Path(text)
if not path.is_absolute():
path = (ROOT / path).resolve()
else:
path = path.resolve()
if path.exists() and not path.is_dir():
raise GrottiError("Der Slice-Ordner ist keine Ordner.", "watch")
if create:
try:
path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise GrottiError("Slice-Ordner konnte nicht angelegt werden: %s" % exc, "watch") from exc
elif not path.is_dir():
if missing_ok:
return None, text
raise GrottiError("Slice-Ordner fehlt: %s" % path, "watch")
try:
stored = path.relative_to(ROOT.resolve()).as_posix()
except ValueError:
stored = str(path)
return path, stored
def default_watch_suggestion():
"""Absoluter Standardpfad (Repo/slice) für die Einstellungen."""
return str((ROOT / DEFAULT_WATCH_REL).resolve())
def settings_public(cfg):
tg = cfg.get("telegram") or {}
oauth = cfg.get("oauth") or {}
return {
"watch_dir": str(cfg.get("watch_dir") or ""),
"watch_dir_default": default_watch_suggestion(),
"auto_queue": bool(cfg.get("auto_queue", True)),
"fqdn": str(cfg.get("fqdn") or ""),
"legacy_token_set": bool(cfg.get("token")),
@@ -155,8 +194,8 @@ def settings_public(cfg):
}
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."""
def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None, watch_dir=None):
"""Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
path = cfg.get("_path") or CONFIG_PATH
raw = dict(cfg.get("_raw") or {})
if path.exists():
@@ -167,13 +206,19 @@ def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None,
if auto_queue is not None:
raw["auto_queue"] = bool(auto_queue)
cfg["auto_queue"] = bool(auto_queue)
if watch_dir is not None:
resolved, stored = parse_watch_dir(watch_dir, create=True, missing_ok=False)
raw["watch_dir"] = stored
cfg["watch_dir"] = resolved
if fqdn is not None:
normalized = normalize_fqdn(fqdn)
raw["fqdn"] = normalized
cfg["fqdn"] = normalized
if energy is not None:
raw["energy"] = {
"watts": float(energy.get("watts", 120)),
"watts": float(energy.get("watts", 40)),
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
"bed_watts": float(energy.get("bed_watts", 220)),
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
"dryer_watts": float(energy.get("dryer_watts", 0)),
"dry_hours": float(energy.get("dry_hours", 0)),
+90 -2
View File
@@ -1,5 +1,7 @@
"""Dieselbe Rechnung wie in der Druckkalkulation."""
from datetime import datetime
def calculate(job, material):
part_g = float(job.get("weight_g") or 0) * max(1, float(job.get("qty") or 1))
@@ -37,6 +39,92 @@ def calculate(job, material):
}
def energy_cost(duration_s, energy):
def _parse_ts(value):
if value is None:
return None
if isinstance(value, datetime):
return value.timestamp()
text = str(value).strip()
if not text:
return None
try:
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
def _avg_power(a, b):
vals = [float(v) for v in (a, b) if isinstance(v, (int, float)) and not isinstance(v, bool)]
if not vals:
return 0.0
return max(0.0, min(1.0, sum(vals) / len(vals)))
def _ed_print_kwh(samples, base_w, nozzle_w, bed_w):
"""Trapez aus Grundlast + ED×Heizerleistung. None wenn zu wenig ED-Proben."""
if not samples or len(samples) < 2:
return None
total = 0.0
ed_steps = 0
# Max. Intervall: 3× Normal-Sample (90 s), damit Lücken nicht die ED fortschreiben
max_dt = 90.0
for i in range(len(samples) - 1):
a, b = samples[i], samples[i + 1]
t0 = _parse_ts(a.get("sampled_at"))
t1 = _parse_ts(b.get("sampled_at"))
if t0 is None or t1 is None or t1 <= t0:
continue
dt = min(t1 - t0, max_dt)
pn0, pn1 = a.get("nozzle_power"), b.get("nozzle_power")
pb0, pb1 = a.get("bed_power"), b.get("bed_power")
has_ed = any(
isinstance(v, (int, float)) and not isinstance(v, bool)
for v in (pn0, pn1, pb0, pb1)
)
if has_ed:
ed_steps += 1
ed_n = _avg_power(pn0, pn1)
ed_b = _avg_power(pb0, pb1)
watts = float(base_w) + float(nozzle_w) * ed_n + float(bed_w) * ed_b
total += (watts / 1000.0) * (dt / 3600.0)
if ed_steps < 1:
return None
return total
def energy_estimate(duration_s, energy, samples=None):
"""Strom aus ED-Proben oder pauschal Dauer × Watt.
Quelle ``ed`` nur wenn Düse-/Bett-Watt gesetzt und Proben mit power vorliegen.
"""
energy = energy or {}
euro = float(energy.get("euro_per_kwh") or 0)
dry_kwh = (float(energy.get("dryer_watts") or 0) / 1000) * float(energy.get("dry_hours") or 0)
base_w = float(energy.get("watts") or 0)
nozzle_w = float(energy.get("nozzle_watts") or 0)
bed_w = float(energy.get("bed_watts") or 0)
print_kwh = None
source = "flat"
if (nozzle_w > 0 or bed_w > 0) and samples:
print_kwh = _ed_print_kwh(samples, base_w, nozzle_w, bed_w)
if print_kwh is not None:
source = "ed"
if print_kwh is None:
hours = float(duration_s or 0) / 3600
return (float(energy["watts"]) / 1000) * hours * float(energy["euro_per_kwh"])
print_kwh = (base_w / 1000) * hours
source = "flat"
kwh = print_kwh + dry_kwh
return {
"kwh": kwh,
"print_kwh": print_kwh,
"dry_kwh": dry_kwh,
"cost": kwh * euro,
"source": source,
}
def energy_cost(duration_s, energy, samples=None):
return energy_estimate(duration_s, energy, samples)["cost"]
+2 -101
View File
@@ -55,8 +55,8 @@ DEFAULT_SWEEP = {
# 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.
# Result is z= gegen Grenze. 3 mm: kalter Kunststoff an der Düse
# tastet sonst oft 1–2 mm und meldet Restteil.
"max_delta_mm": 3.0,
"timeout_min": 2,
"x": 125.0,
@@ -454,11 +454,6 @@ def sweep_lower_to_push_script(sweep):
])
def sweep_probe_height_script(sweep):
"""Kompatibel: hoch, hinten, langsam auf z_push."""
return sweep_go_back_high_script(sweep) + "\n" + sweep_lower_to_push_script(sweep)
def assert_clear_before_lower(live, sweep, min_z_mm=15.0):
"""Vor dem Senken: Toolhead muss noch klar über dem Bett stehen."""
pos = (live or {}).get("position") or []
@@ -562,100 +557,6 @@ def check_lift_script(check):
])
def _axis_coords(lo, hi, count):
if count <= 1:
return [(lo + hi) / 2.0]
return [lo + (hi - lo) * i / (count - 1) for i in range(count)]
def _matrix_ok(matrix):
if not isinstance(matrix, list) or not matrix:
return False
width = None
for row in matrix:
if not isinstance(row, list) or not row:
return False
if width is None:
width = len(row)
if len(row) != width:
return False
for value in row:
if not isinstance(value, (int, float)):
return False
return True
def interpolate_mesh_z(mesh, x, y):
"""Bilinear aus Roh-probed_matrix. Klipper: Zeile 0 = mesh_min Y."""
matrix = mesh.get("matrix") if isinstance(mesh, dict) else None
if not _matrix_ok(matrix):
return None
mesh_min = mesh.get("mesh_min") or [0, 0]
mesh_max = mesh.get("mesh_max") or [0, 0]
try:
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
except (TypeError, ValueError, IndexError):
return None
rows = len(matrix)
cols = len(matrix[0])
if cols < 1 or rows < 1:
return None
if abs(x1 - x0) < 1e-9 or abs(y1 - y0) < 1e-9:
return float(matrix[0][0])
tx = 0.0 if cols == 1 else (float(x) - x0) / (x1 - x0) * (cols - 1)
ty = 0.0 if rows == 1 else (float(y) - y0) / (y1 - y0) * (rows - 1)
tx = max(0.0, min(float(cols - 1), tx))
ty = max(0.0, min(float(rows - 1), ty))
c0 = int(tx)
r0 = int(ty)
c1 = min(cols - 1, c0 + 1)
r1 = min(rows - 1, r0 + 1)
fx = tx - c0
fy = ty - r0
z00 = float(matrix[r0][c0])
z10 = float(matrix[r0][c1])
z01 = float(matrix[r1][c0])
z11 = float(matrix[r1][c1])
z0 = z00 * (1 - fx) + z10 * fx
z1 = z01 * (1 - fx) + z11 * fx
return z0 * (1 - fy) + z1 * fy
def mesh_delta_mm(baseline, measured):
"""Max |Δ| und Mittel |Δ|: Messpunkte gegen interpoliertes Baseline-Netz."""
if not isinstance(baseline, dict) or not _matrix_ok(baseline.get("matrix")):
raise GrottiError("Kein Bettnetz zum Vergleich — vorher nivellieren.", "mesh")
if not isinstance(measured, dict) or not _matrix_ok(measured.get("matrix")):
raise GrottiError("Neues Bettnetz fehlt nach dem Antasten.", "mesh")
matrix = measured["matrix"]
mesh_min = measured.get("mesh_min") or [0, 0]
mesh_max = measured.get("mesh_max") or [0, 0]
try:
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
except (TypeError, ValueError, IndexError) as exc:
raise GrottiError("Messnetz ohne gültigen Bereich.", "mesh") from exc
rows = len(matrix)
cols = len(matrix[0])
xs = _axis_coords(x0, x1, cols)
ys = _axis_coords(y0, y1, rows)
deltas = []
for ri, y in enumerate(ys):
for ci, x in enumerate(xs):
expected = interpolate_mesh_z(baseline, x, y)
if expected is None:
raise GrottiError("Baseline-Netz lässt sich nicht interpolieren.", "mesh")
deltas.append(abs(float(matrix[ri][ci]) - expected))
if not deltas:
raise GrottiError("Keine Messpunkte zum Vergleich.", "mesh")
return {
"max_delta_mm": max(deltas),
"mean_delta_mm": sum(deltas) / len(deltas),
"points": len(deltas),
}
def step_label(step):
if not isinstance(step, dict):
return ""
+2 -1
View File
@@ -270,7 +270,8 @@ def apply_deduction(conn, run, energy, metadata=None):
return False
used_total = sum(item["weight_g"] for item in entries)
filament = sum(item["cost"] for item in entries)
power = energy_cost(run["duration_s"], energy)
samples = store.list_run_samples(conn, run["id"])
power = energy_cost(run["duration_s"], energy, samples)
# run_spools vor deducted=true, sonst bleibt die Akte leer bei Abbruch dazwischen
store.replace_run_spools(conn, run["id"], entries)
marked = store.mark_deducted(
+137 -1
View File
@@ -1,5 +1,7 @@
"""Watch-Ordner, Upload und Vorschaubild."""
import re
import shutil
import time
import zipfile
from pathlib import Path
@@ -10,6 +12,8 @@ from grotti.parse import embedded_png, inspect_file, safe_zip_name
_LAST_WATCH_SYNC = 0.0
_WATCH_MIN_INTERVAL_S = 20
_FOLDER_PART = re.compile(r"^[A-Za-z0-9._\- äöüÄÖÜß]+$")
_MAX_FOLDER_DEPTH = 6
def skip_path(path, data_dir):
@@ -36,7 +40,7 @@ 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."}
return {"gelesen": 0, "hinweis": "Unter Einstellungen den Slice-Ordner setzen."}
if not folder.exists():
return {"gelesen": 0, "hinweis": "Der Watch-Ordner fehlt: %s" % folder}
now = time.monotonic()
@@ -113,6 +117,138 @@ def unique_path(directory, filename):
return candidate
def require_watch_dir(cfg):
folder = cfg.get("watch_dir")
if not folder:
raise GrottiError("Kein Slice-Ordner gesetzt — unter Einstellungen festlegen.", "watch")
root = Path(folder).resolve()
if not root.is_dir():
raise GrottiError("Der Slice-Ordner fehlt: %s" % root, "watch")
return root
def normalize_folder(rel):
"""Relativpfad unter watch_dir; leer = Wurzel. Kein .., begrenzte Tiefe."""
text = str(rel or "").strip().replace("\\", "/").strip("/")
if not text:
return ""
parts = []
for part in text.split("/"):
part = part.strip()
if not part or part in (".", ".."):
raise GrottiError("Ungültiger Ordnername.", "ordner")
if not _FOLDER_PART.match(part):
raise GrottiError(
"Ordnername nur Buchstaben, Ziffern, Leerzeichen, . _ -.",
"ordner",
)
parts.append(part)
if len(parts) > _MAX_FOLDER_DEPTH:
raise GrottiError("Ordner zu tief verschachtelt (max. %s)." % _MAX_FOLDER_DEPTH, "ordner")
return "/".join(parts)
def path_under_watch(watch_dir, rel_folder, name=None):
"""Absoluter Pfad unter watch_dir; wirft bei Escape."""
root = Path(watch_dir).resolve()
folder = normalize_folder(rel_folder)
target = root if not folder else (root / folder)
if name:
target = target / safe_filename(name)
resolved = target.resolve()
if resolved != root and root not in resolved.parents:
raise GrottiError("Pfad außerhalb des Slice-Ordners.", "ordner")
return resolved
def list_folders(cfg):
"""Unterordner unter watch_dir (auch leer), relativ, sortiert."""
try:
root = require_watch_dir(cfg)
except GrottiError:
return []
data_dir = cfg.get("data_dir")
found = set()
for path in root.rglob("*"):
if not path.is_dir():
continue
if data_dir is not None and skip_path(path, data_dir):
continue
try:
rel = path.resolve().relative_to(root).as_posix()
except ValueError:
continue
if rel and rel != ".":
found.add(rel)
return sorted(found, key=lambda s: s.lower())
def create_folder(cfg, rel):
root = require_watch_dir(cfg)
folder = normalize_folder(rel)
if not folder:
raise GrottiError("Ordnername fehlt.", "ordner")
target = path_under_watch(root, folder)
target.mkdir(parents=True, exist_ok=True)
return folder
def delete_folder(cfg, rel):
root = require_watch_dir(cfg)
folder = normalize_folder(rel)
if not folder:
raise GrottiError("Die Wurzel lässt sich nicht löschen.", "ordner")
target = path_under_watch(root, folder)
if not target.is_dir():
raise GrottiError("Der Ordner fehlt.", "ordner", 404)
try:
next(target.iterdir())
raise GrottiError("Ordner ist nicht leer.", "ordner")
except StopIteration:
pass
target.rmdir()
return folder
def move_file(conn, cfg, file_id, folder):
"""Watch-Datei in Unterordner (oder Wurzel) verschieben; ID bleibt."""
root = require_watch_dir(cfg)
folder = normalize_folder(folder)
row = store.get_file_row(conn, file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
if row.get("source") != "watch":
raise GrottiError(
"Nur Dateien aus dem Slice-Ordner lassen sich verschieben.",
"watch",
)
src = Path(row.get("stored_path") or "")
if not src.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
dest_dir = path_under_watch(root, folder)
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / src.name
if dest.resolve() == src.resolve():
return store.file_out(row, watch_dir=root)
if dest.exists():
dest = unique_path(dest_dir, src.name)
try:
shutil.move(str(src), str(dest))
except OSError as exc:
raise GrottiError("Verschieben fehlgeschlagen: %s" % exc, "ordner") from exc
stat = dest.stat()
store.update_file_location(
conn,
file_id,
str(dest.resolve()),
name=dest.name,
size_bytes=stat.st_size,
mtime=stat.st_mtime,
)
row = store.get_file_row(conn, file_id)
return store.file_out(row, watch_dir=root)
def save_upload(conn, filename, content, cfg, project_id=None):
directory = cfg["data_dir"] / "eingang"
directory.mkdir(parents=True, exist_ok=True)
+12 -30
View File
@@ -50,31 +50,6 @@ def _raw_matrix(points):
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
@@ -118,11 +93,6 @@ class PrinterError(Exception):
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:
@@ -776,6 +746,16 @@ def fan_values(status):
return speed, rpm
def _heater_power(entry):
"""Klipper-Einschaltdauer 0…1 (ED), sonst None."""
if not isinstance(entry, dict):
return None
power = entry.get("power")
if isinstance(power, (int, float)) and not isinstance(power, bool):
return max(0.0, min(1.0, float(power)))
return None
def _object_names(host):
now = time.time()
cached = OBJECT_CACHE.get(host)
@@ -1078,8 +1058,10 @@ def query(host):
"elapsed": stats.get("print_duration") or 0,
"nozzle": extruder.get("temperature"),
"nozzle_target": extruder.get("target"),
"nozzle_power": _heater_power(extruder),
"bed": bed.get("temperature"),
"bed_target": bed.get("target"),
"bed_power": _heater_power(bed),
"chamber": chamber,
"chamber_target": chamber_target,
"filament_used": filament_used,
-23
View File
@@ -1,7 +1,5 @@
"""Die Drucker: Familie aus dem Namen, die Liste selbst liegt in PostgreSQL."""
import json
FAMILY_LABELS = {"kobra_s1": "Kobra S1", "kobra_3": "Kobra 3"}
@@ -21,24 +19,3 @@ def family_from_text(text):
if "kobra 3" in low or "kobra3" in low:
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
+6 -1
View File
@@ -282,11 +282,16 @@ CREATE TABLE IF NOT EXISTS run_samples (
layer integer,
progress double precision,
filament_used_mm double precision,
mmu_tool smallint
mmu_tool smallint,
nozzle_power double precision,
bed_power double precision
);
CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at);
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS nozzle_power double precision;
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS bed_power double precision;
-- Objekt-Chargen für Etiketten
CREATE TABLE IF NOT EXISTS run_labels (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+230 -44
View File
@@ -3,6 +3,7 @@
import json
import uuid
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from psycopg.types.json import Json
@@ -30,7 +31,21 @@ def project_out(row, files=None, file_count=None):
return data
def file_out(row):
def file_folder(stored_path, source, watch_dir=None):
"""Relativpfad des Elternordners unter watch_dir; Uploads → „Upload“."""
if (source or "") == "upload":
return "Upload"
if not stored_path or not watch_dir:
return ""
try:
rel = Path(stored_path).resolve().relative_to(Path(watch_dir).resolve())
except (ValueError, OSError):
return ""
parent = rel.parent.as_posix()
return "" if parent in (".", "") else parent
def file_out(row, watch_dir=None):
# stored_path bleibt intern (get_file_row / list_file_rows), nicht in der API.
from grotti.parse import path_has_ace_trailer
@@ -54,6 +69,7 @@ def file_out(row):
"mtime": row["mtime"],
"slice_meta": row.get("slice_meta") or {},
"ace_trailer": ace_trailer,
"folder": file_folder(stored, row.get("source"), watch_dir),
"preview": "/api/v1/dateien/%s/vorschau" % row["id"] if row["thumb_entry"] else "",
}
@@ -227,8 +243,8 @@ def list_file_rows(conn):
return conn.execute("SELECT * FROM files ORDER BY mtime DESC, name").fetchall()
def list_files(conn):
return [file_out(row) for row in list_file_rows(conn)]
def list_files(conn, watch_dir=None):
return [file_out(row, watch_dir=watch_dir) for row in list_file_rows(conn)]
def get_file_row(conn, file_id):
@@ -291,7 +307,7 @@ def insert_upload(conn, meta):
return file_out(row)
def update_file(conn, file_id, fields):
def update_file(conn, file_id, fields, watch_dir=None):
sets = []
values = []
for key in ("project_id", "spool_id", "printer_family", "weight_g", "estimated_s", "thumb_entry", "kind", "plate"):
@@ -306,13 +322,33 @@ def update_file(conn, file_id, fields):
values.append(Json(fields.get("slice_meta") or {}))
if not sets:
row = get_file_row(conn, file_id)
return file_out(row) if row else None
return file_out(row, watch_dir=watch_dir) if row else None
values.append(file_id)
row = conn.execute(
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
values,
).fetchone()
return file_out(row) if row else None
return file_out(row, watch_dir=watch_dir) if row else None
def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None, mtime=None):
"""Pfad nach Verschieben auf der Platte — ID und Zuordnungen bleiben."""
sets = ["stored_path = %s"]
values = [stored_path]
if name is not None:
sets.append("name = %s")
values.append(name)
if size_bytes is not None:
sets.append("size_bytes = %s")
values.append(size_bytes)
if mtime is not None:
sets.append("mtime = %s")
values.append(mtime)
values.append(file_id)
return conn.execute(
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
values,
).fetchone()
def delete_file(conn, file_id):
@@ -766,6 +802,34 @@ def get_run_row(conn, run_id):
return conn.execute("SELECT * FROM runs WHERE id = %s", (run_id,)).fetchone()
def latest_run_for_host(conn, host):
"""Aktueller oder letzter Lauf mit Proben fürs Cockpit-Verlauf."""
host = (host or "").strip()
if not host:
return None
runs = list_runs(conn, limit=12, host=host)
if not runs:
return None
active = [r for r in runs if (r.get("state") or "") in ("in_progress", "printing", "paused")]
candidates = active + [r for r in runs if r not in active]
best = None
best_n = -1
for run in candidates:
row = conn.execute(
"SELECT count(*) AS n FROM run_samples WHERE run_id = %s",
(run["id"],),
).fetchone()
n = int(row["n"] or 0) if row else 0
if (run.get("state") or "") in ("in_progress", "printing", "paused"):
run["_sample_count"] = n
return run
if n >= 2 and n > best_n:
best = run
best_n = n
best["_sample_count"] = n
return best
def get_run_by_chargen(conn, code):
text = (code or "").strip()
if not text:
@@ -806,21 +870,33 @@ def assign_run_spool(conn, run_id, spool_id):
def next_druck_chargen(conn):
"""Nächste Druck-Charge des Tages. Objekt-Etiketten (`D-…-01`) blockieren die Nummer."""
day = datetime.now(timezone.utc).strftime("%Y%m%d")
prefix = "D-%s-" % day
batch_re = "^D-%s-[0-9]{4}$" % day
object_re = "^D-%s-[0-9]{4}-[0-9]{2}$" % day
row = conn.execute(
"""
SELECT chargen_nr FROM runs
WHERE chargen_nr LIKE %s
ORDER BY chargen_nr DESC
SELECT n FROM (
SELECT chargen_nr AS n FROM runs
WHERE chargen_nr ~ %s
UNION
SELECT CASE
WHEN chargen_nr ~ %s THEN regexp_replace(chargen_nr, '-[0-9]{2}$', '')
ELSE chargen_nr
END AS n
FROM run_labels
WHERE chargen_nr ~ %s OR chargen_nr ~ %s
) AS nums
ORDER BY n DESC
LIMIT 1
""",
(prefix + "%",),
(batch_re, object_re, batch_re, object_re),
).fetchone()
number = 1
if row and row["chargen_nr"]:
if row and row["n"]:
try:
number = int(str(row["chargen_nr"]).rsplit("-", 1)[-1]) + 1
number = int(str(row["n"]).rsplit("-", 1)[-1]) + 1
except ValueError:
number = 1
return "%s%04d" % (prefix, number)
@@ -1203,7 +1279,7 @@ def adopt_live_run(conn, host, filename, real_run_id):
return
live_id = live["id"]
live_chargen = (live.get("chargen_nr") or "").strip()
for table in ("run_samples", "run_segments", "run_spools", "run_labels"):
for table in ("run_samples", "run_segments", "run_spools"):
conn.execute(
"UPDATE %s SET run_id = %%s WHERE run_id = %%s" % table,
(real_run_id, live_id),
@@ -1238,6 +1314,26 @@ def adopt_live_run(conn, host, filename, real_run_id):
else:
# Live-Charge war verfrüht — nicht übernehmen
conn.execute("UPDATE runs SET chargen_nr = NULL WHERE id = %s", (live_id,))
# Etiketten nur mit derselben Charge. Sonst bleiben sie am Live-Lauf und
# fallen beim Löschen weg, statt eine fremde Nummer zu blockieren.
real_after = get_run_row(conn, real_run_id)
real_chargen = ((real_after or {}).get("chargen_nr") or "").strip()
if live_chargen and real_chargen == live_chargen:
conn.execute(
"""
DELETE FROM run_labels AS src
WHERE src.run_id = %s
AND EXISTS (
SELECT 1 FROM run_labels AS dst
WHERE dst.run_id = %s AND dst.object_name = src.object_name
)
""",
(live_id, real_run_id),
)
conn.execute(
"UPDATE run_labels SET run_id = %s WHERE run_id = %s",
(real_run_id, live_id),
)
conn.execute("DELETE FROM runs WHERE id = %s", (live_id,))
@@ -1272,26 +1368,36 @@ def add_run_sample(conn, run_id, sample):
INSERT INTO run_samples (
run_id, sampled_at, nozzle, nozzle_target, bed, bed_target, chamber,
fan, speed_factor, extrude_factor, layer, progress,
filament_used_mm, mmu_tool
filament_used_mm, mmu_tool, nozzle_power, bed_power
) VALUES (
%(run_id)s, COALESCE(%(sampled_at)s, now()), %(nozzle)s, %(nozzle_target)s,
%(bed)s, %(bed_target)s, %(chamber)s, %(fan)s, %(speed_factor)s,
%(extrude_factor)s, %(layer)s, %(progress)s, %(filament_used_mm)s, %(mmu_tool)s
%(extrude_factor)s, %(layer)s, %(progress)s, %(filament_used_mm)s, %(mmu_tool)s,
%(nozzle_power)s, %(bed_power)s
)
RETURNING id
""",
{"run_id": run_id, "sampled_at": sample.get("sampled_at"), **sample},
{
"run_id": run_id,
"sampled_at": sample.get("sampled_at"),
"nozzle": sample.get("nozzle"),
"nozzle_target": sample.get("nozzle_target"),
"bed": sample.get("bed"),
"bed_target": sample.get("bed_target"),
"chamber": sample.get("chamber"),
"fan": sample.get("fan"),
"speed_factor": sample.get("speed_factor"),
"extrude_factor": sample.get("extrude_factor"),
"layer": sample.get("layer"),
"progress": sample.get("progress"),
"filament_used_mm": sample.get("filament_used_mm"),
"mmu_tool": sample.get("mmu_tool"),
"nozzle_power": sample.get("nozzle_power"),
"bed_power": sample.get("bed_power"),
},
).fetchone()
def last_sample_at(conn, run_id):
row = conn.execute(
"SELECT sampled_at FROM run_samples WHERE run_id = %s ORDER BY sampled_at DESC LIMIT 1",
(run_id,),
).fetchone()
return row["sampled_at"] if row else None
def list_run_samples(conn, run_id, limit=2000):
rows = conn.execute(
"""
@@ -1318,6 +1424,8 @@ def list_run_samples(conn, run_id, limit=2000):
"progress": row["progress"],
"filament_used_mm": row["filament_used_mm"],
"mmu_tool": row["mmu_tool"],
"nozzle_power": row.get("nozzle_power"),
"bed_power": row.get("bed_power"),
})
return out
@@ -1384,6 +1492,72 @@ def list_run_segments(conn, run_id):
]
def _object_chargen(base, index):
return "%s-%02d" % (base, index)
def _label_owner(conn, code):
return conn.execute(
"""
SELECT l.id, l.run_id, l.chargen_nr, COALESCE(r.chargen_nr, '') AS run_chargen
FROM run_labels l
JOIN runs r ON r.id = l.run_id
WHERE l.chargen_nr = %s
""",
(code,),
).fetchone()
def _claim_object_chargen(conn, base, start, reserved, depth=0):
"""Freie Objekt-Charge ab start. Nummer einer anderen Lauf-Charge wird dorthin umgehängt."""
if depth > 8:
raise RuntimeError("Objekt-Charge lässt sich nicht vergeben")
number = max(1, int(start or 1))
while number < 1000:
code = _object_chargen(base, number)
if code in reserved:
number += 1
continue
owner = _label_owner(conn, code)
if not owner:
return code
owner_base = (owner["run_chargen"] or "").strip()
if owner_base == base:
number += 1
continue
if owner_base:
moved = _claim_object_chargen(conn, owner_base, 1, reserved | {code}, depth + 1)
conn.execute(
"UPDATE run_labels SET chargen_nr = %s WHERE id = %s AND chargen_nr = %s",
(moved, owner["id"], code),
)
if not _label_owner(conn, code):
return code
else:
conn.execute("DELETE FROM run_labels WHERE id = %s AND chargen_nr = %s", (owner["id"], code))
if not _label_owner(conn, code):
return code
number += 1
raise RuntimeError("Keine freie Objekt-Charge")
def _insert_run_label(conn, run_id, name, code):
from psycopg.errors import UniqueViolation
try:
with conn.transaction():
conn.execute(
"""
INSERT INTO run_labels (run_id, object_name, chargen_nr, excluded)
VALUES (%s, %s, %s, false)
""",
(run_id, name, code),
)
return ""
except UniqueViolation as exc:
return (exc.diag.constraint_name if exc.diag else "") or "unique"
def ensure_run_labels(conn, run_id, objects, qty=1):
"""Objekt- oder Stückzahl-Etiketten anlegen. Charge vom Lauf vorausgesetzt."""
run = get_run_row(conn, run_id)
@@ -1399,6 +1573,7 @@ def ensure_run_labels(conn, run_id, objects, qty=1):
for row in existing
if (row["object_name"] or "").strip()
}
reserved = {(row["chargen_nr"] or "") for row in existing}
names = []
for item in objects or []:
if isinstance(item, dict):
@@ -1412,29 +1587,44 @@ def ensure_run_labels(conn, run_id, objects, qty=1):
if not existing and not names:
count = max(1, int(qty or 1))
for index in range(count):
code = base if count == 1 else "%s-%02d" % (base, index + 1)
conn.execute(
"""
INSERT INTO run_labels (run_id, object_name, chargen_nr, excluded)
VALUES (%s, %s, %s, false)
""",
(run_id, "" if count == 1 else "Teil %s" % (index + 1), code),
)
label_name = "" if count == 1 else "Teil %s" % (index + 1)
placed = False
for _try in range(5):
if count == 1 and _try == 0 and base not in reserved and not _label_owner(conn, base):
code = base
else:
code = _claim_object_chargen(conn, base, index + 1 + _try, reserved)
clash = _insert_run_label(conn, run_id, label_name, code)
if not clash:
reserved.add(code)
placed = True
break
reserved.add(code)
if "object_name" in clash:
break
if not placed and count == 1:
break
elif names:
index = len(known_names)
for name, excluded in names:
if excluded or name in known_names:
continue
index += 1
code = "%s-%02d" % (base, index)
conn.execute(
"""
INSERT INTO run_labels (run_id, object_name, chargen_nr, excluded)
VALUES (%s, %s, %s, false)
""",
(run_id, name, code),
)
placed = False
for _try in range(5):
code = _claim_object_chargen(conn, base, index + _try, reserved)
clash = _insert_run_label(conn, run_id, name, code)
if not clash:
reserved.add(code)
known_names.add(name)
placed = True
break
reserved.add(code)
if "object_name" in clash:
known_names.add(name)
break
if not placed:
continue
rows = conn.execute(
"SELECT * FROM run_labels WHERE run_id = %s ORDER BY id",
(run_id,),
@@ -1477,10 +1667,6 @@ def list_run_events(conn, run_id, limit=50):
return [event_out(row) for row in rows]
def attach_event_run(conn, event_id, run_id):
conn.execute("UPDATE events SET run_id = %s WHERE id = %s", (run_id, event_id))
def exclusive_until(until):
"""Reines Datum YYYY-MM-DD zählt inklusiv: Grenze = Folgetag."""
if until is None or until == "":
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "2.5.1"
__version__ = "2.10.0"
+68 -8
View File
@@ -10,6 +10,7 @@ from grotti import moonraker, notify, store, usage
from grotti.db import connect
SAMPLE_EVERY_S = 30
SAMPLE_HEAT_S = 10
class StateWatcher:
@@ -100,9 +101,28 @@ class StateWatcher:
and state == "printing"
)
if starting:
store.abandon_live_runs(conn, host)
if state in ("printing", "paused"):
run = self._track_print(conn, host, name, filename, live, state)
# Aufheiz-Proben behalten: Live-Lauf gleicher Datei nicht verwerfen
keep = None
existing = store.find_live_run(conn, host)
if existing:
same = (
not filename
or not existing.get("filename")
or existing.get("filename") == filename
)
if same:
keep = existing["id"]
if filename and existing.get("filename") != filename:
conn.execute(
"UPDATE runs SET filename = %s WHERE id = %s",
(filename, keep),
)
store.abandon_live_runs(conn, host, keep_id=keep)
sample_now = self._should_sample(conn, host, live, state)
heating = sample_now and state not in ("printing", "paused")
if sample_now:
force = starting or (heating and not prev.get("heating"))
run = self._track_print(conn, host, name, filename, live, state, force_sample=force)
run_id = run["id"] if run else None
if prev_offline:
@@ -187,12 +207,39 @@ class StateWatcher:
self._prev[host] = {
"state": state,
"offline": False,
"heating": heating,
"filename": filename,
"spool_warn": spool_warn if state == "printing" else False,
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") else None),
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") or heating else None),
}
def _track_print(self, conn, host, name, filename, live, state):
def _should_sample(self, conn, host, live, state):
"""Proben bei Druck/Pause und beim Aufheizen mit Bezug zu einem Auftrag."""
if state in ("printing", "paused"):
return True
if state in ("error", "offline", ""):
return False
nozzle_t = usage.as_float(live.get("nozzle_target"))
bed_t = usage.as_float(live.get("bed_target"))
if nozzle_t <= 40 and bed_t <= 30:
return False
if live.get("filename"):
return True
if store.running_print_on_host(conn, host):
return True
return bool(store.find_live_run(conn, host))
def _track_print(self, conn, host, name, filename, live, state, force_sample=False):
if not filename:
running = store.running_print_on_host(conn, host)
if running:
filename = running.get("filename") or ""
if not filename:
existing = store.find_live_run(conn, host)
if existing:
filename = existing.get("filename") or ""
if not filename and state not in ("printing", "paused"):
return None
sent = store.latest_send(conn, host, filename) if filename else None
spool_id = (sent or {}).get("spool_id")
run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id)
@@ -214,7 +261,7 @@ class StateWatcher:
"duration_s": usage.as_float(live.get("elapsed")),
})
store.ensure_run_chargen(conn, run["id"])
self._maybe_sample(conn, run["id"], live, used_mm)
self._maybe_sample(conn, run["id"], live, used_mm, force=force_sample)
return store.get_run_row(conn, run["id"])
def _merge_objects(self, current, exclude):
@@ -243,10 +290,21 @@ class StateWatcher:
known[name] = {"name": name, "excluded": True}
return list(known.values())
def _maybe_sample(self, conn, run_id, live, used_mm):
def _heat_gap(self, live):
"""Abstand Ist↔Soll — während Aufheizen dichter samplen."""
gaps = []
for key, tkey in (("nozzle", "nozzle_target"), ("bed", "bed_target")):
target = usage.as_float(live.get(tkey))
if target <= 0:
continue
gaps.append(abs(usage.as_float(live.get(key)) - target))
return max(gaps) if gaps else 0
def _maybe_sample(self, conn, run_id, live, used_mm, force=False):
now = time.monotonic()
last = self._last_sample.get(run_id, 0)
if now - last < SAMPLE_EVERY_S:
interval = SAMPLE_HEAT_S if self._heat_gap(live) > 3 else SAMPLE_EVERY_S
if not force and now - last < interval:
return
self._last_sample[run_id] = now
store.add_run_sample(conn, run_id, {
@@ -263,6 +321,8 @@ class StateWatcher:
"progress": live.get("progress"),
"filament_used_mm": used_mm,
"mmu_tool": live.get("mmu_tool"),
"nozzle_power": live.get("nozzle_power"),
"bed_power": live.get("bed_power"),
})
def _ensure_start_segment(self, conn, host, filename, live, run_id):
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
pytest==9.1.1
+7 -5
View File
@@ -1,6 +1,8 @@
flask>=3.1
psycopg[binary]>=3.2
paho-mqtt>=2.1
cryptography>=42
markdown>=3.6
flask==3.1.3
psycopg[binary]==3.3.6
psycopg-pool==3.3.3
paho-mqtt==2.1.0
cryptography==50.0.2
markdown==3.11.1
mcp>=1.2
waitress==3.0.2
+1
View File
@@ -0,0 +1 @@
"""Hilfsskripte (Orca-Nachbearbeitung u. a.)."""
+332
View File
@@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""Orca/Anycubic-Nachbearbeitung: G-code nach Grotti laden und Senden-Seite öffnen.
Wenn SLIC3R_PP_OUTPUT_NAME unter dem Slice-Ordner liegt (auch Unterordner),
wird die Datei dorthin geschrieben und der Watch neu gelesen — nicht als Upload.
Sonst: Upload über die API.
Config: data/orca-senden.json mit url und token (grt_…).
"""
from __future__ import annotations
import json
import mimetypes
import os
import shutil
import sys
import uuid
import webbrowser
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
REPO_ROOT = Path(__file__).resolve().parent.parent
CONFIG_PATH = REPO_ROOT / "data" / "orca-senden.json"
DEBUG_LOG = REPO_ROOT / "data" / "orca-senden-last.log"
ALLOWED_SUFFIXES = (".gcode", ".gcode.3mf", ".3mf", ".gcode.pp", ".pp")
def show_error(message: str) -> None:
text = str(message or "Unbekannter Fehler.")
if sys.platform == "win32":
try:
import ctypes
ctypes.windll.user32.MessageBoxW(0, text, "Grotti · Orca senden", 0x10)
return
except Exception:
pass
print(text, file=sys.stderr)
def write_debug(argv: list[str], extra: str = "") -> None:
try:
DEBUG_LOG.parent.mkdir(parents=True, exist_ok=True)
lines = [
"argv:",
*[repr(item) for item in argv],
"",
"env:",
"SLIC3R_PP_HOST=%s" % (os.environ.get("SLIC3R_PP_HOST") or ""),
"SLIC3R_PP_OUTPUT_NAME=%s" % (os.environ.get("SLIC3R_PP_OUTPUT_NAME") or ""),
"",
extra,
]
DEBUG_LOG.write_text("\n".join(lines), encoding="utf-8")
except OSError:
pass
def load_config(path: Path = CONFIG_PATH) -> dict[str, str]:
if not path.is_file():
raise FileNotFoundError(
"Config fehlt: %s\nLege url und token (Einstellungen → API-Token) an." % path
)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError("Config ungültig: %s" % exc) from exc
if not isinstance(data, dict):
raise ValueError("Config muss ein JSON-Objekt sein.")
url = str(data.get("url") or "").strip().rstrip("/")
token = str(data.get("token") or "").strip()
if not url:
raise ValueError("Config: url fehlt (z. B. http://127.0.0.1:8770).")
if not token:
raise ValueError("Config: token fehlt (grt_… aus Einstellungen).")
return {"url": url, "token": token}
def is_gcode_path(path: Path) -> bool:
name = path.name.lower()
return any(name.endswith(suffix) for suffix in ALLOWED_SUFFIXES)
def output_target() -> Path | None:
env_name = (os.environ.get("SLIC3R_PP_OUTPUT_NAME") or "").strip().strip('"')
if not env_name:
return None
return Path(env_name)
def upload_name_for(path: Path) -> str:
"""Schöner Dateiname: SLIC3R_PP_OUTPUT_NAME, sonst Pfad ohne .pp."""
target = output_target()
if target is not None and target.name:
base = target.name
if not is_gcode_path(Path(base)) and path.name.lower().endswith(".pp"):
if not base.lower().endswith((".gcode", ".3mf")):
base = base + ".gcode"
return base
name = path.name
if name.lower().endswith(".pp"):
name = name[:-3]
if not name.lower().endswith((".gcode", ".3mf")):
name = name + ".gcode"
return name
def find_gcode_arg(argv: list[str]) -> Path:
"""Pfad aus argv oder SLIC3R_PP_OUTPUT_NAME; Temp-Dateien enden oft auf .pp."""
candidates: list[Path] = []
for raw in argv[1:]:
text = (raw or "").strip().strip('"')
if not text or text.startswith("-"):
continue
candidates.append(Path(text))
for candidate in reversed(candidates):
if not is_gcode_path(candidate):
continue
if candidate.is_file():
return candidate.resolve()
raise FileNotFoundError("Datei fehlt: %s" % candidate)
for candidate in reversed(candidates):
if candidate.is_file():
return candidate.resolve()
env_path = output_target()
if env_path is not None and env_path.is_file():
return env_path.resolve()
detail = "argv=%s" % (argv[1:] or ["(leer)"])
raise ValueError(
"Kein G-code-Pfad in den Argumenten.\n"
"G-code exportieren (nicht nur slicen).\n"
"%s\nDetails: data/orca-senden-last.log" % detail
)
def send_page_url(base_url: str, file_id: int) -> str:
return "%s/dateien?file=%s" % (base_url.rstrip("/"), int(file_id))
def _api_json(
base_url: str,
token: str,
method: str,
path: str,
*,
data: bytes | None = None,
headers: dict[str, str] | None = None,
timeout: float = 120.0,
) -> Any:
hdrs = {
"Authorization": "Bearer %s" % token,
"Accept": "application/json",
}
if headers:
hdrs.update(headers)
request = Request(
"%s/api/v1%s" % (base_url.rstrip("/"), path),
data=data,
method=method,
headers=hdrs,
)
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read()
if not raw:
return None
return json.loads(raw.decode("utf-8"))
except HTTPError as exc:
detail = ""
try:
detail = json.loads(exc.read().decode("utf-8")).get("error") or ""
except Exception:
detail = str(exc.reason or exc)
raise RuntimeError("API %s fehlgeschlagen (%s): %s" % (path, exc.code, detail or exc)) from exc
except URLError as exc:
raise RuntimeError(
"Grotti nicht erreichbar (%s). Läuft der Server?" % (exc.reason or exc)
) from exc
def fetch_watch_dir(base_url: str, token: str) -> Path | None:
payload = _api_json(base_url, token, "GET", "/einstellungen")
if not isinstance(payload, dict):
return None
text = str(payload.get("watch_dir") or "").strip()
if not text:
return None
path = Path(text)
return path if path.is_dir() else None
def under_watch(watch_dir: Path, target: Path) -> tuple[str, str] | None:
"""(folder_relativ, dateiname) wenn target unter watch_dir liegt."""
try:
rel = target.resolve().relative_to(watch_dir.resolve())
except ValueError:
return None
parts = rel.parts
if not parts:
return None
name = parts[-1]
folder = "/".join(parts[:-1]) if len(parts) > 1 else ""
return folder, name
def place_in_watch(src: Path, dest: Path) -> Path:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(str(src), str(dest))
return dest.resolve()
def rescan(base_url: str, token: str) -> None:
_api_json(base_url, token, "POST", "/dateien/neu-lesen", data=b"{}", headers={"Content-Type": "application/json"})
def find_library_file(base_url: str, token: str, *, name: str, folder: str) -> dict[str, Any] | None:
rows = _api_json(base_url, token, "GET", "/dateien")
if not isinstance(rows, list):
return None
folder_key = folder or ""
matches = []
for row in rows:
if not isinstance(row, dict):
continue
if (row.get("name") or "") != name:
continue
row_folder = row.get("folder") or ""
if row.get("source") == "watch":
if row_folder == folder_key or (not folder_key and row_folder in ("", None)):
matches.append(row)
elif not folder_key and row_folder in ("", "Upload"):
matches.append(row)
if not matches:
# Name reicht, neueste ID
matches = [row for row in rows if isinstance(row, dict) and (row.get("name") or "") == name]
if not matches:
return None
return max(matches, key=lambda row: int(row.get("id") or 0))
def _multipart_body(filename: str, content: bytes, field: str = "file") -> tuple[bytes, str]:
boundary = "----GrottiOrca%s" % uuid.uuid4().hex
guessed = mimetypes.guess_type(filename)[0] or "application/octet-stream"
head = (
"--%s\r\n"
'Content-Disposition: form-data; name="%s"; filename="%s"\r\n'
"Content-Type: %s\r\n"
"\r\n"
) % (boundary, field, filename.replace('"', ""), guessed)
tail = "\r\n--%s--\r\n" % boundary
body = head.encode("utf-8") + content + tail.encode("ascii")
return body, "multipart/form-data; boundary=%s" % boundary
def upload_file(
base_url: str,
token: str,
path: Path,
filename: str | None = None,
timeout: float = 120.0,
) -> dict[str, Any]:
content = path.read_bytes()
name = filename or upload_name_for(path)
body, content_type = _multipart_body(name, content)
payload = _api_json(
base_url,
token,
"POST",
"/dateien",
data=body,
headers={"Content-Type": content_type},
timeout=timeout,
)
if not isinstance(payload, dict) or "id" not in payload:
raise RuntimeError("Unerwartete Antwort vom Upload.")
return payload
def open_browser(url: str) -> None:
webbrowser.open(url)
def deliver(base_url: str, token: str, src: Path) -> int:
"""Datei in Slice-Unterordner legen oder hochladen; gibt file_id zurück."""
watch = fetch_watch_dir(base_url, token)
target = output_target()
if watch is not None and target is not None:
placed = under_watch(watch, target)
if placed is not None:
folder, name = placed
dest = place_in_watch(src, target if target.suffix else target.with_suffix(".gcode"))
# Name aus Zielpfad, falls ohne Endung
if dest.name:
name = dest.name
rescan(base_url, token)
found = find_library_file(base_url, token, name=name, folder=folder)
if not found:
raise RuntimeError(
"Datei liegt unter %s, aber Grotti kennt sie noch nicht. "
"Unter Dateien „Ordner neu lesen“." % dest
)
return int(found["id"])
saved = upload_file(base_url, token, src, filename=upload_name_for(src))
return int(saved["id"])
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv if argv is None else argv)
write_debug(argv)
try:
config = load_config()
path = find_gcode_arg(argv)
write_debug(
argv,
"chosen=%s\nupload_as=%s\noutput=%s"
% (path, upload_name_for(path), output_target() or ""),
)
file_id = deliver(config["url"], config["token"], path)
write_debug(argv, "file_id=%s" % file_id)
open_browser(send_page_url(config["url"], file_id))
except Exception as exc:
write_debug(argv, "error=%s" % exc)
show_error(str(exc))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
View File
Whitespace-only changes.
+49
View File
@@ -0,0 +1,49 @@
"""Slice-Ordner (watch_dir) in config."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from grotti import config
from grotti.errors import GrottiError
class WatchDirTest(unittest.TestCase):
def test_empty(self):
path, stored = config.parse_watch_dir("")
self.assertIsNone(path)
self.assertEqual(stored, "")
def test_relative_under_root(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
target = root / "slice"
target.mkdir()
with patch.object(config, "ROOT", root):
path, stored = config.parse_watch_dir("slice", create=False)
self.assertEqual(path, target.resolve())
self.assertEqual(stored, "slice")
def test_create(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
with patch.object(config, "ROOT", root):
path, stored = config.parse_watch_dir("slice", create=True)
self.assertTrue(path.is_dir())
self.assertEqual(stored, "slice")
def test_file_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
file_path = root / "not-a-dir"
file_path.write_text("x", encoding="utf-8")
with patch.object(config, "ROOT", root):
with self.assertRaises(GrottiError):
config.parse_watch_dir(str(file_path), create=False)
if __name__ == "__main__":
unittest.main()
+51
View File
@@ -0,0 +1,51 @@
"""ED-Stromschätzung."""
import unittest
from datetime import datetime, timezone
from grotti.cost import energy_estimate
def sample_at(offset_s, np=None, bp=None):
base = datetime(2026, 10, 17, 15, 0, tzinfo=timezone.utc).timestamp()
at = datetime.fromtimestamp(base + offset_s, tz=timezone.utc).isoformat()
return {"sampled_at": at, "nozzle_power": np, "bed_power": bp}
class EnergyEdTests(unittest.TestCase):
def test_flat_without_heater_watts(self):
est = energy_estimate(3600, {"watts": 100, "euro_per_kwh": 0.5}, [
sample_at(0, np=1.0, bp=1.0),
sample_at(1800, np=1.0, bp=1.0),
])
self.assertEqual(est["source"], "flat")
self.assertAlmostEqual(est["kwh"], 0.1)
self.assertAlmostEqual(est["cost"], 0.05)
def test_ed_caps_long_gaps(self):
# 60 min Lücke → nur 90 s werden gezählt
samples = [sample_at(0, np=0.5, bp=0.0), sample_at(3600, np=0.5, bp=0.0)]
est = energy_estimate(3600, {
"watts": 0,
"nozzle_watts": 100,
"bed_watts": 0,
"euro_per_kwh": 1,
}, samples)
self.assertEqual(est["source"], "ed")
self.assertAlmostEqual(est["print_kwh"], (100 * 0.5 / 1000) * (90 / 3600), places=6)
def test_ed_dense_samples_full_hour(self):
samples = [sample_at(i * 30, np=1.0, bp=0.0) for i in range(0, 121)]
est = energy_estimate(3600, {
"watts": 0,
"nozzle_watts": 200,
"bed_watts": 0,
"euro_per_kwh": 2,
}, samples)
self.assertEqual(est["source"], "ed")
self.assertAlmostEqual(est["print_kwh"], 0.2, places=3)
self.assertAlmostEqual(est["cost"], 0.4, places=3)
if __name__ == "__main__":
unittest.main()
+4 -1
View File
@@ -61,7 +61,10 @@ class EjectTests(unittest.TestCase):
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({}))
sweep = eject.normalize_sweep({})
back = eject.sweep_go_back_high_script(sweep)
lower = eject.sweep_lower_to_push_script(sweep)
script = back + "\n" + lower
# Y hinten (Default 250) vor dem Senken auf z_push
y_idx = script.find("Y250")
z_push_idx = script.find("Z2.00")
+103
View File
@@ -0,0 +1,103 @@
"""Tests für scripts/orca_senden.py ohne laufenden Server."""
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts import orca_senden as orca # noqa: E402
class OrcaSendenHelpersTest(unittest.TestCase):
def test_send_page_url(self):
self.assertEqual(
orca.send_page_url("http://127.0.0.1:8770/", 42),
"http://127.0.0.1:8770/dateien?file=42",
)
def test_find_gcode_arg_last_matching(self):
with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp)
gcode = folder / "teil.gcode"
gcode.write_text("; test\n", encoding="utf-8")
other = folder / "notes.txt"
other.write_text("x", encoding="utf-8")
found = orca.find_gcode_arg(["orca_senden.py", str(other), str(gcode)])
self.assertEqual(found, gcode.resolve())
def test_find_gcode_arg_pp_temp(self):
with tempfile.TemporaryDirectory() as tmp:
pp = Path(tmp) / "platte.gcode.pp"
pp.write_text("; temp\n", encoding="utf-8")
found = orca.find_gcode_arg(["orca_senden.py", str(pp)])
self.assertEqual(found, pp.resolve())
self.assertEqual(orca.upload_name_for(pp), "platte.gcode")
def test_under_watch_subfolder(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "slice"
dest = root / "Gridfinity" / "teil.gcode"
dest.parent.mkdir(parents=True)
dest.write_text("; x\n", encoding="utf-8")
placed = orca.under_watch(root, dest)
self.assertEqual(placed, ("Gridfinity", "teil.gcode"))
def test_under_watch_outside(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "slice"
root.mkdir()
other = Path(tmp) / "elsewhere" / "teil.gcode"
other.parent.mkdir()
other.write_text("; x\n", encoding="utf-8")
self.assertIsNone(orca.under_watch(root, other))
def test_find_gcode_arg_missing(self):
with self.assertRaises(ValueError):
orca.find_gcode_arg(["orca_senden.py"])
def test_find_gcode_arg_file_gone(self):
with self.assertRaises(FileNotFoundError):
orca.find_gcode_arg(["orca_senden.py", r"C:\gibt\es\nicht.gcode"])
def test_load_config_ok(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "orca-senden.json"
path.write_text(
json.dumps({"url": "http://127.0.0.1:8770/", "token": "grt_abc"}),
encoding="utf-8",
)
cfg = orca.load_config(path)
self.assertEqual(cfg["url"], "http://127.0.0.1:8770")
self.assertEqual(cfg["token"], "grt_abc")
def test_load_config_missing(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "fehlt.json"
with self.assertRaises(FileNotFoundError):
orca.load_config(path)
def test_load_config_empty_token(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "orca-senden.json"
path.write_text(json.dumps({"url": "http://x", "token": ""}), encoding="utf-8")
with self.assertRaises(ValueError):
orca.load_config(path)
def test_main_shows_error_without_config(self):
with patch.object(orca, "CONFIG_PATH", Path("/tmp/does-not-exist-orca.json")):
with patch.object(orca, "show_error") as shown:
code = orca.main(["orca_senden.py", "x.gcode"])
self.assertEqual(code, 1)
self.assertTrue(shown.called)
if __name__ == "__main__":
unittest.main()
+67 -3
View File
@@ -190,7 +190,11 @@ def create_app(cfg):
if not user:
if wants_json() or path.startswith("/api/"):
return jsonify({"error": "Anmeldung nötig.", "code": "auth"}), 401
return redirect(url_for("login_page", next=path))
# Query behalten (z. B. /dateien?file=12 nach Orca-Upload)
next_path = path
if request.query_string:
next_path = path + "?" + request.query_string.decode("utf-8", "replace")
return redirect(url_for("login_page", next=next_path))
g.user = user
ensure_csrf()
@@ -490,6 +494,7 @@ def create_app(cfg):
"Anleitung-Oberflaeche",
"Anleitung-Wand",
"Anleitung-Dateien",
"Anleitung-Orca",
"Anleitung-ACE",
"Anleitung-Warteschlange",
"Anleitung-Material",
@@ -510,6 +515,7 @@ def create_app(cfg):
"Anleitung-Oberflaeche": "Oberfläche",
"Anleitung-Wand": "Druckerwand",
"Anleitung-Dateien": "Dateien senden",
"Anleitung-Orca": "Orca an Grotti",
"Anleitung-ACE": "ACE und Spulen",
"Anleitung-Warteschlange": "Warteschlange",
"Anleitung-Material": "Material und mehr",
@@ -695,8 +701,15 @@ def create_app(cfg):
alerts = data.get("alerts") if isinstance(data.get("alerts"), dict) else None
auto_queue = data.get("auto_queue") if "auto_queue" in data else None
fqdn = data.get("fqdn") if "fqdn" in data else None
watch_dir = data.get("watch_dir") if "watch_dir" in data else None
return jsonify(grotti_config.save_settings(
cfg, energy=energy, telegram=telegram, alerts=alerts, auto_queue=auto_queue, fqdn=fqdn,
cfg,
energy=energy,
telegram=telegram,
alerts=alerts,
auto_queue=auto_queue,
fqdn=fqdn,
watch_dir=watch_dir,
))
@app.post("/api/v1/telegram/test")
@@ -906,7 +919,7 @@ def create_app(cfg):
@app.get("/api/v1/dateien")
def file_list():
library.sync_watch(conn(), cfg, force=False)
return jsonify(store.list_files(conn()))
return jsonify(store.list_files(conn(), watch_dir=cfg.get("watch_dir")))
@app.post("/api/v1/dateien/neu-lesen")
def file_rescan():
@@ -914,6 +927,31 @@ def create_app(cfg):
result["metadaten"] = library.refresh_file_meta(conn(), force=True)
return jsonify(result)
@app.get("/api/v1/dateien/ordner")
def file_folders():
return jsonify({"folders": library.list_folders(cfg)})
@app.post("/api/v1/dateien/ordner")
def file_folder_create():
data = body()
folder = library.create_folder(cfg, data.get("folder"))
return jsonify({"folder": folder, "folders": library.list_folders(cfg)}), 201
@app.delete("/api/v1/dateien/ordner")
def file_folder_delete():
data = body()
folder = library.delete_folder(cfg, data.get("folder"))
return jsonify({"folder": folder, "folders": library.list_folders(cfg)})
@app.post("/api/v1/dateien/<int:file_id>/verschieben")
def file_move(file_id):
data = body()
folder = data.get("folder")
if folder is None:
folder = ""
moved = library.move_file(conn(), cfg, file_id, folder)
return jsonify(moved)
@app.post("/api/v1/dateien")
def file_upload():
upload = request.files.get("file")
@@ -1799,6 +1837,28 @@ def create_app(cfg):
def run_list():
return jsonify(store.list_runs(conn()))
@app.get("/api/v1/laeufe/aktuell")
def run_aktuell():
"""Aktueller oder letzter Temperatur-/ED-Verlauf fürs Cockpit (host=)."""
host = require_host(request.args.get("host", ""))
run = store.latest_run_for_host(conn(), host)
if not run:
return jsonify({"run": None, "samples": [], "energy": None})
run_id = run["id"]
samples = store.list_run_samples(conn(), run_id)
from grotti.cost import energy_estimate
energy = energy_estimate(run.get("duration_s"), cfg["energy"], samples)
if len(samples) > 180:
step = max(1, len(samples) // 120)
samples = samples[::step]
return jsonify({
"run": run,
"samples": samples,
"energy": energy,
"sample_count": int(run.get("_sample_count") or len(samples)),
})
@app.get("/api/v1/laeufe/<int:run_id>")
def run_get(run_id):
row = store.get_run_row(conn(), run_id)
@@ -1814,6 +1874,9 @@ def create_app(cfg):
file_row = store.get_file_row(conn(), run["file_id"]) if run.get("file_id") else None
project = store.get_project(conn(), run["project_id"]) if run.get("project_id") else None
samples = store.list_run_samples(conn(), run_id)
from grotti.cost import energy_estimate
energy = energy_estimate(run.get("duration_s"), cfg["energy"], samples)
# Anzeige auf ca. 1 Punkt / Minute verdichten
if len(samples) > 180:
step = max(1, len(samples) // 120)
@@ -1827,6 +1890,7 @@ def create_app(cfg):
"events": store.list_run_events(conn(), run_id),
"file": store.file_out(file_row) if file_row else None,
"project": project,
"energy": energy,
})
@app.post("/api/v1/laeufe/abgleich")
+120 -1
View File
@@ -89,7 +89,126 @@ 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" });
return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: appTimeZone() });
}
const TZ_KEY = "grotti-timezone";
function appTimeZone() {
try {
const value = localStorage.getItem(TZ_KEY);
if (value && value !== "auto") return value;
} catch (error) { /* Browser */ }
return undefined;
}
function formatDateTime(iso, opts) {
if (!iso) return "—";
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return "—";
const options = {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: appTimeZone(),
...(opts || {}),
};
return at.toLocaleString("de-DE", options);
}
function formatDateRange(startIso, endIso) {
const start = startIso ? new Date(startIso) : null;
const end = endIso ? new Date(endIso) : null;
if (start && Number.isNaN(start.getTime())) return "—";
if (!start && !end) return "—";
if (start && !end) return formatDateTime(startIso) + " → …";
if (!start && end) return "… → " + formatDateTime(endIso);
const sameDay = start.toDateString() === end.toDateString();
const left = formatDateTime(startIso);
if (sameDay) {
const right = end.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
timeZone: appTimeZone(),
});
return left + " – " + right;
}
return left + " – " + formatDateTime(endIso);
}
function tempChartHtml(samples) {
if (!samples || samples.length < 2) {
return '<p class="tiny">Noch zu wenig Proben für den Verlauf.</p>';
}
const times = samples.map((s) => new Date(s.sampled_at).getTime());
const t0 = times[0];
const t1 = times[times.length - 1];
const span = Math.max(1, t1 - t0);
const temps = samples.flatMap((s) => [s.nozzle, s.bed, s.chamber, s.nozzle_target, s.bed_target].filter((v) => v != null));
const minT = Math.min(...temps, 0);
const maxT = Math.max(...temps, 100);
const hasEd = samples.some((s) => s.nozzle_power != null || s.bed_power != null);
const w = 640;
const h = 210;
const padL = 44;
const padR = hasEd ? 44 : 28;
const padT = 22;
const padB = 30;
const x = (ms) => padL + ((ms - t0) / span) * (w - padL - padR);
const yT = (v) => padT + (1 - (Number(v) - minT) / Math.max(1, maxT - minT)) * (h - padT - padB);
const yEd = (v) => padT + (1 - Math.max(0, Math.min(1, Number(v) || 0))) * (h - padT - padB);
function poly(key, color, dashed, scaleEd) {
const pts = [];
samples.forEach((s, i) => {
const v = s[key];
if (v == null) return;
const yy = scaleEd ? yEd(Number(v)) : yT(v);
pts.push(`${x(times[i]).toFixed(1)},${yy.toFixed(1)}`);
});
if (pts.length < 2) return "";
const dash = dashed ? ' stroke-dasharray="5 4"' : "";
return `<polyline fill="none" stroke="${color}" stroke-width="${dashed ? 1.2 : 1.7}"${dash} points="${pts.join(" ")}" />`;
}
const tickCount = Math.min(5, samples.length);
const timeTicks = [];
for (let i = 0; i < tickCount; i++) {
const ms = t0 + (span * i) / Math.max(1, tickCount - 1);
const label = new Date(ms).toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
timeZone: appTimeZone(),
});
timeTicks.push(`<text x="${x(ms).toFixed(1)}" y="${h - 6}" text-anchor="middle" class="chart-label">${escapeHtml(label)}</text>`);
}
const midY = (padT + h - padB) / 2;
return `<svg class="temp-chart" viewBox="0 0 ${w} ${h}" role="img" aria-label="Temperatur links in °C, Einschaltdauer rechts in Prozent">
${poly("nozzle_target", "#fdba74", true, false)}
${poly("bed_target", "#93c5fd", true, false)}
${poly("nozzle", "#ea580c", false, false)}
${poly("bed", "#2563eb", false, false)}
${poly("chamber", "#16a34a", false, false)}
${hasEd ? poly("nozzle_power", "#c084fc", false, true) : ""}
${hasEd ? poly("bed_power", "#22d3ee", false, true) : ""}
<text x="10" y="${midY}" class="chart-axis" transform="rotate(-90 10 ${midY})">Temp °C</text>
<text x="${padL}" y="14" class="chart-label">${Math.round(maxT)}</text>
<text x="${padL}" y="${h - padB + 2}" class="chart-label">${Math.round(minT)}</text>
${hasEd ? `<text x="${w - 10}" y="${midY}" class="chart-axis" text-anchor="middle" transform="rotate(90 ${w - 10} ${midY})">ED %</text>` : ""}
${hasEd ? `<text x="${w - padR}" y="14" text-anchor="end" class="chart-label">100</text>` : ""}
${hasEd ? `<text x="${w - padR}" y="${h - padB + 2}" text-anchor="end" class="chart-label">0</text>` : ""}
${timeTicks.join("")}
</svg>
<p class="tiny chart-legend">
<span class="leg nozzle">Düse</span>
<span class="leg bed">Bett</span>
<span class="leg chamber">Kammer</span>
${hasEd ? '<span class="leg ed-n">ED Düse</span><span class="leg ed-b">ED Bett</span>' : ""}
· links °C · rechts % · gestrichelt ≈ Soll
</p>`;
}
function showNote(id, error, ok) {
+131 -10
View File
@@ -374,6 +374,47 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
}
.queue-item.running { border-color: rgba(61, 206, 124, 0.28); background: linear-gradient(180deg, rgba(61, 206, 124, 0.07), rgba(255, 255, 255, 0.02)); }
.queue-item.error { border-color: rgba(248, 113, 113, 0.3); background: var(--st-error-soft); }
.queue-item.run-line {
display: flex;
align-items: center;
gap: 0.2rem;
padding: 0.2rem 0.3rem 0.2rem 0.2rem;
background: rgba(255, 255, 255, 0.03);
}
.queue-item.run-line.running {
background: rgba(61, 206, 124, 0.04);
}
.run-line-link {
position: relative;
flex: 1;
min-width: 0;
display: flex;
align-items: center;
padding: 0.28rem 0.45rem;
border-radius: 7px;
overflow: hidden;
}
.run-line-fill {
position: absolute;
inset: 0 auto 0 0;
width: calc(var(--pct, 0) * 1%);
max-width: 100%;
background: rgba(61, 206, 124, 0.22);
pointer-events: none;
transition: width 0.35s ease;
}
.run-line-text {
position: relative;
z-index: 1;
color: var(--ink);
font-size: var(--t-xs);
font-weight: 650;
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-link.run-line-link:hover .run-line-text { color: var(--accent); }
.queue-row { display: flex; align-items: center; gap: 0.4rem; min-width: 0; }
.queue-row .grow { flex: 1; min-width: 0; }
.queue-link { color: inherit; text-decoration: none; }
@@ -1214,14 +1255,16 @@ button.swatch.on { border-color: var(--accent); background: var(--accent-soft);
}
.mat-color-cap { color: var(--faint); font-size: var(--t-xs); }
.mat-form { position: sticky; top: 0.75rem; }
.mat-color {
.mat-color,
.mat-more {
margin: 0.75rem 0 0;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
overflow: hidden;
}
.mat-color > summary {
.mat-color > summary,
.mat-more > summary {
list-style: none;
display: flex;
align-items: center;
@@ -1233,11 +1276,16 @@ button.swatch.on { border-color: var(--accent); background: var(--accent-soft);
font-size: var(--t-sm);
font-weight: 650;
}
.mat-color > summary::-webkit-details-marker { display: none; }
.mat-color > summary .caret { transition: transform 0.2s var(--ease); opacity: 0.6; }
.mat-color:not([open]) > summary .caret { transform: rotate(-90deg); }
.mat-color > summary .grow { flex: 1; color: var(--ink); }
.mat-color-body { padding: 0 0.7rem 0.75rem; }
.mat-color > summary::-webkit-details-marker,
.mat-more > summary::-webkit-details-marker { display: none; }
.mat-color > summary .caret,
.mat-more > summary .caret { transition: transform 0.2s var(--ease); opacity: 0.6; }
.mat-color:not([open]) > summary .caret,
.mat-more:not([open]) > summary .caret { transform: rotate(-90deg); }
.mat-color > summary .grow,
.mat-more > summary .grow { flex: 1; color: var(--ink); }
.mat-color-body,
.mat-more-body { padding: 0 0.7rem 0.75rem; }
.mat-color-body .color-grid { margin-bottom: 0.35rem; }
.mat-color-body .picked { min-height: 1.4rem; margin-bottom: 0.45rem; }
.viewer-card { margin-top: 1rem; }
@@ -1627,6 +1675,13 @@ body.desk-on main {
}
.desk-tool-mesh .mesh { margin-top: 0; }
.desk-tool-mesh .gridmesh { width: 100%; max-width: 22rem; }
.desk-verlauf .temp-chart { max-width: none; }
.desk-verlauf-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.45rem;
}
.desk-empty { margin: 0.35rem 0 0; color: var(--faint); }
.ablauf-card { margin-top: 1rem; }
@@ -1981,6 +2036,17 @@ article.machine.flash { animation: flash 2s var(--ease); }
}
.lib-tools input[type="search"] { flex: 1 1 12rem; margin: 0; }
.lib-tools select { flex: 0 1 11rem; width: auto; margin: 0; }
.lib-group {
font-size: var(--t-xs);
font-weight: 650;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.55rem 0.35rem 0.15rem;
margin-top: 0.25rem;
border-bottom: 1px solid var(--line);
}
.lib-group:first-child { margin-top: 0; padding-top: 0.15rem; }
.lib-list { display: grid; gap: 0.35rem; max-height: 70vh; overflow-y: auto; margin: 0 -0.3rem; padding: 0 0.3rem; }
.lib-item {
display: grid;
@@ -2004,6 +2070,13 @@ article.machine.flash { animation: flash 2s var(--ease); }
.lib-item .t { display: block; font-size: var(--t-sm); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
.lib-item .m { display: flex; flex-wrap: wrap; gap: 0.15rem 0.6rem; font-size: var(--t-xs); color: var(--muted); margin-top: 0.12rem; font-variant-numeric: tabular-nums; }
.lib-item .side { display: flex; gap: 0.25rem; align-items: center; }
.lib-move {
max-width: 7.5rem;
margin: 0;
padding: 0.2rem 0.35rem;
font-size: var(--t-xs);
border-radius: 6px;
}
.lib-info { position: relative; }
.lib-info > summary { list-style: none; }
.lib-info > summary::-webkit-details-marker { display: none; }
@@ -2370,6 +2443,7 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
border: 1px solid var(--line);
}
.chart-label { fill: var(--muted); font-size: 11px; }
.chart-axis { fill: var(--faint); font-size: 10px; font-weight: 650; letter-spacing: 0.04em; }
.chart-legend { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.35rem; }
.chart-legend .leg::before {
content: "";
@@ -2383,6 +2457,8 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
.chart-legend .nozzle::before { background: #ea580c; }
.chart-legend .bed::before { background: #2563eb; }
.chart-legend .chamber::before { background: #16a34a; }
.chart-legend .ed-n::before { background: #c084fc; }
.chart-legend .ed-b::before { background: #22d3ee; }
.tag {
display: inline-block;
font-size: 0.72rem;
@@ -2411,11 +2487,56 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
}
.kpi-label { color: var(--muted); font-size: 0.78rem; font-weight: 650; }
.spool-akte {
margin-top: 0.85rem;
padding-top: 0.7rem;
margin-top: 1rem;
padding-top: 0.85rem;
border-top: 1px solid var(--line);
}
.spool-akte h3 { margin: 0 0 0.35rem; font-size: 0.95rem; }
.spool-akte h3 { margin: 0 0 0.55rem; font-size: 0.95rem; }
.akte-kpis {
margin-bottom: 0.65rem;
grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr));
gap: 0.45rem;
}
.akte-kpis .kpi {
padding: 0.45rem 0.55rem;
border-radius: 10px;
}
.akte-kpis .kpi-val { font-size: 0.98rem; }
.akte-runs { display: flex; flex-direction: column; gap: 0; }
.akte-run {
display: grid;
grid-template-columns: 5.6rem minmax(5.5rem, auto) minmax(0, 1fr) auto auto;
gap: 0.35rem 0.55rem;
align-items: center;
padding: 0.38rem 0;
border-bottom: 1px solid var(--line);
font-size: 0.86rem;
}
.akte-run:last-child { border-bottom: 0; }
.akte-when { color: var(--muted); white-space: nowrap; }
.akte-charge { font-size: 0.72rem; text-decoration: none; }
.akte-file {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.akte-g { font-variant-numeric: tabular-nums; white-space: nowrap; font-weight: 650; }
.akte-state { color: var(--muted); white-space: nowrap; text-align: right; }
@media (max-width: 520px) {
.akte-run {
grid-template-columns: 1fr auto;
grid-template-areas:
"when g"
"charge state"
"file file";
}
.akte-when { grid-area: when; }
.akte-charge { grid-area: charge; }
.akte-file { grid-area: file; }
.akte-g { grid-area: g; }
.akte-state { grid-area: state; }
}
ul.plain { list-style: none; margin: 0; padding: 0; }
ul.plain li { padding: 0.2rem 0; border-bottom: 1px solid var(--line); }
ul.plain li:last-child { border-bottom: 0; }
+12 -32
View File
@@ -29,33 +29,6 @@ function sourceLabel(source, estimate) {
return source || "";
}
function chart(samples) {
if (!samples || samples.length < 2) {
return '<p class="tiny">Kein Temperaturverlauf (nur für neue Drucke).</p>';
}
const w = 640, h = 180, pad = 28;
const temps = samples.flatMap((s) => [s.nozzle, s.bed, s.chamber, s.nozzle_target, s.bed_target].filter((v) => v != null));
const minT = Math.min(...temps, 0);
const maxT = Math.max(...temps, 100);
const x = (i) => pad + (i / (samples.length - 1)) * (w - pad * 2);
const y = (v) => h - pad - ((Number(v) - minT) / Math.max(1, maxT - minT)) * (h - pad * 2);
function path(key, color) {
const pts = samples.map((s, i) => s[key] == null ? null : `${x(i)},${y(s[key])}`).filter(Boolean);
if (pts.length < 2) return "";
return `<polyline fill="none" stroke="${color}" stroke-width="1.6" points="${pts.join(" ")}" />`;
}
return `<svg class="temp-chart" viewBox="0 0 ${w} ${h}" role="img" aria-label="Temperaturverlauf">
${path("nozzle_target", "#fdba74")}
${path("bed_target", "#93c5fd")}
${path("nozzle", "#ea580c")}
${path("bed", "#2563eb")}
${path("chamber", "#16a34a")}
<text x="${pad}" y="14" class="chart-label">${Math.round(maxT)} °C</text>
<text x="${pad}" y="${h - 8}" class="chart-label">${Math.round(minT)} °C</text>
</svg>
<p class="tiny chart-legend"><span class="leg nozzle">Düse</span> <span class="leg bed">Bett</span> <span class="leg chamber">Kammer</span> · gestrichelt ≈ Soll</p>`;
}
function render(data) {
const run = data.run;
const name = (run.filename || "").split("/").pop() || "ohne Datei";
@@ -66,7 +39,14 @@ function render(data) {
const planned = Number(run.weight_planned_g) || 0;
const used = Number(run.weight_used_g) || 0;
const diff = Math.round(used) - Math.round(planned);
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
const energy = data.energy || {};
const energyEuro = energy.cost != null ? Number(energy.cost) : Number(run.energy_cost || 0);
const cost = Number(run.filament_cost || 0) + energyEuro;
const energyHint = energy.source === "ed"
? (Number(energy.kwh) || 0).toLocaleString("de-DE", { maximumFractionDigits: 2 }) + " kWh aus ED"
: (energy.kwh != null
? (Number(energy.kwh) || 0).toLocaleString("de-DE", { maximumFractionDigits: 2 }) + " kWh pauschal"
: "");
const spoolRows = (data.spools || []).map((s) => `
<tr>
@@ -79,7 +59,7 @@ function render(data) {
</tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Spule zugeordnet.</td></tr>';
const events = (data.events || []).map((e) =>
`<li><span class="tiny">${escapeHtml((e.created_at || "").replace("T", " ").slice(0, 19))}</span> ${escapeHtml(e.message || e.kind)}</li>`
`<li><span class="tiny">${escapeHtml(formatDateTime(e.created_at))}</span> ${escapeHtml(e.message || e.kind)}</li>`
).join("") || "<li class=\"tiny\">Keine Ereignisse.</li>";
const labels = (data.labels || []).filter((l) => !l.excluded);
@@ -96,11 +76,11 @@ function render(data) {
<div class="card bericht-head">
<div class="bericht-meta">
<p><b>${escapeHtml(states[run.state] || run.state)}</b>${run.verdict ? " · Urteil " + escapeHtml(run.verdict) : ""}</p>
<p class="tiny">${escapeHtml(run.started_at || "—")} → ${escapeHtml(run.ended_at || "—")}</p>
<p class="tiny">${escapeHtml(formatDateRange(run.started_at, run.ended_at))}</p>
<p>Dauer <b>${hours(run.duration_s)}</b>${run.estimated_s ? " · Soll " + hours(run.estimated_s) : ""}</p>
<p>Material <b>${grams(used)}</b> von ${grams(planned)}${planned ? " (" + (diff > 0 ? "+" : "") + diff + " g)" : ""}
${run.weight_source ? ' · <span class="tag">' + sourceLabel(run.weight_source) + "</span>" : ""}</p>
<p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(run.energy_cost)})</p>
<p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})</p>
${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""}
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
</div>
@@ -115,7 +95,7 @@ function render(data) {
</div>
<div class="card">
<h2>Verlauf</h2>
${chart(data.samples)}
${tempChartHtml(data.samples)}
</div>
<div class="card grid-2">
<div>
+267 -18
View File
@@ -11,6 +11,7 @@
</select>
<label class="button ghost" for="upload-file"><svg class="ico"><use href="#i-upload"/></svg>Hochladen</label>
<input id="upload-file" type="file" accept=".gcode,.3mf,.gcode.3mf" multiple hidden>
<button type="button" class="ghost" id="folder-create" hidden><svg class="ico"><use href="#i-folder"/></svg>Ordner anlegen</button>
<button type="button" class="ghost" id="rescan"><svg class="ico"><use href="#i-refresh"/></svg>Ordner neu lesen</button>
</div>
</div>
@@ -22,7 +23,7 @@
<span class="count" id="lib-count">0</span>
</div>
<div class="lib-tools">
<input id="file-search" type="search" placeholder="Datei suchen…" autocomplete="off">
<input id="file-search" type="search" placeholder="Name, Material, Projekt…" autocomplete="off">
<select id="file-filter-family">
<option value="">Alle Maschinen</option>
<option value="kobra_3">Kobra 3</option>
@@ -31,6 +32,20 @@
<select id="file-filter-project">
<option value="">Alle Projekte</option>
</select>
<select id="file-filter-material" title="Material">
<option value="">Alle Materialien</option>
</select>
<select id="file-filter-folder" title="Ordner">
<option value="">Alle Ordner</option>
</select>
<select id="file-sort" title="Sortierung">
<option value="mtime">Neueste</option>
<option value="name">Name A–Z</option>
<option value="material">Material</option>
<option value="folder">Ordner</option>
<option value="time">Druckzeit</option>
<option value="weight">Gewicht</option>
</select>
</div>
<p id="file-note" class="tiny"></p>
<div class="lib-list" id="lib-list">
@@ -152,8 +167,11 @@
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
const ABLAUF_KEY = "grotti-ablauf-v2";
const SEND_KEY = "grotti-send-v1";
const LIB_KEY = "grotti-lib-v1";
const STEP_TITLES = { print: "Drucken", cool: "Abkühlen", sweep: "Schieben", check: "Tasten" };
let files = [];
let knownFolders = [];
let hasWatchDir = false;
let printers = [];
let spools = [];
let projects = [];
@@ -470,17 +488,101 @@ function collectAceMaps() {
/* ---------- Bibliothek ---------- */
function projectNameOf(file) {
const project = projects.find((p) => String(p.id) === String(file.project_id));
return project ? project.name : "";
}
function materialKey(file) {
return materialText(file) || "";
}
function folderKey(file) {
const folder = file.folder;
if (folder === "Upload") return "Upload";
if (folder) return folder;
return "";
}
function folderLabel(folder) {
if (folder === "Upload") return "Upload";
if (folder) return folder;
return "Wurzel";
}
function saveLibMemory() {
try {
localStorage.setItem(LIB_KEY, JSON.stringify({
search: document.getElementById("file-search").value || "",
family: document.getElementById("file-filter-family").value || "",
project: document.getElementById("file-filter-project").value || "",
material: document.getElementById("file-filter-material").value || "",
folder: document.getElementById("file-filter-folder").value || "",
sort: document.getElementById("file-sort").value || "mtime",
}));
} catch (error) { /* ignore */ }
}
function loadLibMemory() {
try {
const data = JSON.parse(localStorage.getItem(LIB_KEY) || "{}");
const set = (id, value) => {
const node = document.getElementById(id);
if (!node || value == null) return;
node.value = value;
};
set("file-search", data.search || "");
set("file-filter-family", data.family || "");
set("file-filter-project", data.project || "");
set("file-filter-material", data.material || "");
set("file-filter-folder", data.folder || "");
set("file-sort", data.sort || "mtime");
} catch (error) { /* ignore */ }
}
function filteredFiles() {
const q = (document.getElementById("file-search").value || "").trim().toLowerCase();
const family = document.getElementById("file-filter-family").value;
const project = document.getElementById("file-filter-project").value;
return files.filter((file) => {
const material = document.getElementById("file-filter-material").value;
const folder = document.getElementById("file-filter-folder").value;
const sort = document.getElementById("file-sort").value || "mtime";
const shown = files.filter((file) => {
if (family && file.printer_family !== family) return false;
if (project === "none" && file.project_id) return false;
if (project && project !== "none" && String(file.project_id) !== project) return false;
if (material === "none" && materialKey(file)) return false;
if (material && material !== "none") {
const types = ((file.filaments) || []).map((f) => f.type).filter(Boolean);
if (!types.includes(material)) return false;
}
if (folder === "root" && folderKey(file)) return false;
if (folder && folder !== "root" && folderKey(file) !== folder) return false;
if (!q) return true;
return String(file.name || "").toLowerCase().includes(q);
const hay = [
file.name || "",
materialKey(file),
projectNameOf(file),
folderKey(file),
].join(" ").toLowerCase();
return hay.includes(q);
});
const cmpStr = (a, b) => String(a || "").localeCompare(String(b || ""), "de", { sensitivity: "base" });
shown.sort((a, b) => {
if (sort === "name") return cmpStr(a.name, b.name) || (Number(b.mtime) || 0) - (Number(a.mtime) || 0);
if (sort === "material") {
return cmpStr(materialKey(a) || "\uffff", materialKey(b) || "\uffff")
|| cmpStr(a.name, b.name);
}
if (sort === "folder") {
return cmpStr(folderLabel(folderKey(a)), folderLabel(folderKey(b)))
|| cmpStr(a.name, b.name);
}
if (sort === "time") return (Number(b.estimated_s) || 0) - (Number(a.estimated_s) || 0) || cmpStr(a.name, b.name);
if (sort === "weight") return (Number(b.weight_g) || 0) - (Number(a.weight_g) || 0) || cmpStr(a.name, b.name);
return (Number(b.mtime) || 0) - (Number(a.mtime) || 0) || cmpStr(a.name, b.name);
});
return shown;
}
function fillProjectFilter() {
@@ -496,6 +598,70 @@ function fillProjectFilter() {
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function fillMaterialFilter() {
const select = document.getElementById("file-filter-material");
const current = select.value;
const types = [...new Set(files.flatMap((f) => ((f.filaments) || []).map((x) => x.type).filter(Boolean)))];
types.sort((a, b) => a.localeCompare(b, "de", { sensitivity: "base" }));
select.innerHTML = '<option value="">Alle Materialien</option><option value="none">Ohne Material</option>';
types.forEach((type) => {
const option = document.createElement("option");
option.value = type;
option.textContent = type;
select.appendChild(option);
});
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function allFolderNames() {
const fromFiles = files.map((f) => folderKey(f)).filter((f) => f && f !== "Upload");
return [...new Set([...knownFolders, ...fromFiles])]
.sort((a, b) => folderLabel(a).localeCompare(folderLabel(b), "de", { sensitivity: "base" }));
}
function fillFolderFilter() {
const select = document.getElementById("file-filter-folder");
const current = select.value;
const folders = allFolderNames();
select.innerHTML = '<option value="">Alle Ordner</option><option value="root">Wurzel</option>';
folders.forEach((folder) => {
const option = document.createElement("option");
option.value = folder;
option.textContent = folderLabel(folder);
select.appendChild(option);
});
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function moveSelectHtml(file) {
if (!hasWatchDir || file.source !== "watch") return "";
const current = folderKey(file);
const opts = [
`<option value="" selected disabled>Verschieben…</option>`,
`<option value="__root__">Wurzel</option>`,
...allFolderNames().map((folder) =>
`<option value="${escapeHtml(folder)}"${folder === current ? " disabled" : ""}>${escapeHtml(folderLabel(folder))}</option>`
),
`<option value="__new__">Neuer Ordner…</option>`,
];
return `<select class="lib-move" data-move="${file.id}" title="In Ordner verschieben" onclick="event.stopPropagation()">${opts.join("")}</select>`;
}
async function promptCreateFolder() {
const name = window.prompt("Neuer Ordner unter dem Slice-Pfad (z. B. PETG oder PLA/tests):", "");
if (name == null) return null;
const trimmed = String(name).trim();
if (!trimmed) {
toast("Ordnername fehlt.", "bad");
return null;
}
const result = await api("/dateien/ordner", { method: "POST", json: { folder: trimmed } });
knownFolders = result.folders || [];
fillFolderFilter();
toast("Ordner „" + (result.folder || trimmed) + "“ angelegt.", "good");
return result.folder || trimmed;
}
function fillProjects() {
const opts = projects.map((row) =>
`<option value="${escapeHtml(row.id)}">${escapeHtml(row.name)}</option>`
@@ -585,9 +751,21 @@ function thumbHtml(file) {
: `<span class="ph">${icon("cube")}</span>`;
}
function libItemHtml(file) {
const project = projects.find((p) => String(p.id) === String(file.project_id));
const del = file.source === "upload"
? `<button type="button" class="icon-btn" data-delete="${file.id}" title="Hochgeladene Datei löschen">${icon("x")}</button>` : "";
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
${thumbHtml(file)}
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
<span class="side">${moveSelectHtml(file)}${infoPopupHtml(file)}${del}</span>
</div>`;
}
function renderLibrary() {
const list = document.getElementById("lib-list");
const shown = filteredFiles();
const sort = document.getElementById("file-sort").value || "mtime";
document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length;
if (!files.length) {
list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>';
@@ -597,16 +775,19 @@ function renderLibrary() {
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
return;
}
list.innerHTML = shown.map((file) => {
const project = projects.find((p) => String(p.id) === String(file.project_id));
const del = file.source === "upload"
? `<button type="button" class="icon-btn" data-delete="${file.id}" title="Hochgeladene Datei löschen">${icon("x")}</button>` : "";
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
${thumbHtml(file)}
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
<span class="side">${infoPopupHtml(file)}${del}</span>
</div>`;
}).join("");
const parts = [];
let lastGroup = null;
shown.forEach((file) => {
let group = null;
if (sort === "material") group = materialKey(file) || "Ohne Material";
else if (sort === "folder") group = folderLabel(folderKey(file));
if (group != null && group !== lastGroup) {
parts.push(`<div class="lib-group">${escapeHtml(group)}</div>`);
lastGroup = group;
}
parts.push(libItemHtml(file));
});
list.innerHTML = parts.join("");
}
/* ---------- Flow rechts ---------- */
@@ -795,18 +976,23 @@ function selectFile(id) {
/* ---------- Laden ---------- */
async function load() {
const [settings, fileRows, printerRows, spoolRows, projectRows, defaults] = await Promise.all([
const [settings, fileRows, printerRows, spoolRows, projectRows, defaults, folderPayload] = await Promise.all([
api("/einstellungen"),
api("/dateien"),
api("/drucker?live=1"),
api("/spulen"),
api("/projekte").catch(() => []),
api("/abraeumen-defaults").catch(() => null),
api("/dateien/ordner").catch(() => ({ folders: [] })),
]);
document.getElementById("watch").textContent = settings.watch_dir
hasWatchDir = !!(settings.watch_dir);
document.getElementById("watch").textContent = hasWatchDir
? "Slice-Ordner: " + settings.watch_dir
: "Kein Slice-Ordner — Dateien hier hochladen.";
const createBtn = document.getElementById("folder-create");
if (createBtn) createBtn.hidden = !hasWatchDir;
files = fileRows;
knownFolders = (folderPayload && folderPayload.folders) || [];
printers = printerRows;
spools = spoolRows;
projects = projectRows || [];
@@ -816,11 +1002,26 @@ async function load() {
if (sel.fileId && !selectedFile()) sel.fileId = null;
fillProjects();
fillProjectFilter();
fillMaterialFilter();
fillFolderFilter();
loadLibMemory();
fillProjectFilter();
fillMaterialFilter();
fillFolderFilter();
renderLibrary();
renderTabs();
refreshFlow();
const file = selectedFile();
if (file) showToolpath(file, 0);
// Deep-Link von Orca /orca_senden: Senden in den Blick, Optionen offen für ACE
if (new URLSearchParams(location.search).get("file") && file) {
sel.tab = "send";
renderTabs();
const opts = document.getElementById("options");
if (opts) opts.open = true;
const panel = document.querySelector(".send-panel");
if (panel) panel.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
async function refreshPrinters() {
@@ -1161,6 +1362,10 @@ async function doAblauf() {
/* ---------- Ereignisse ---------- */
document.getElementById("lib-list").addEventListener("click", (event) => {
if (event.target.closest(".lib-move")) {
event.stopPropagation();
return;
}
const del = event.target.closest("[data-delete]");
if (del) {
event.stopPropagation();
@@ -1179,6 +1384,29 @@ document.getElementById("lib-list").addEventListener("click", (event) => {
const item = event.target.closest("[data-file]");
if (item) selectFile(item.dataset.file);
});
document.getElementById("lib-list").addEventListener("change", async (event) => {
const select = event.target.closest("select.lib-move");
if (!select) return;
event.stopPropagation();
const fileId = select.dataset.move;
let folder = select.value;
select.value = "";
if (!folder || !fileId) return;
try {
if (folder === "__new__") {
folder = await promptCreateFolder();
if (!folder) return;
} else if (folder === "__root__") {
folder = "";
}
await api("/dateien/" + fileId + "/verschieben", { method: "POST", json: { folder } });
toast(folder ? ("Nach „" + folder + "“ verschoben.") : "In die Wurzel verschoben.", "good");
await load();
} catch (error) {
toast(error.message || String(error), "bad");
}
});
document.getElementById("lib-list").addEventListener("keydown", (event) => {
const item = event.target.closest("[data-file]");
if (item && (event.key === "Enter" || event.key === " ")) {
@@ -1186,9 +1414,22 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => {
selectFile(item.dataset.file);
}
});
["file-search", "file-filter-family", "file-filter-project"].forEach((id) => {
document.getElementById(id).addEventListener("input", renderLibrary);
document.getElementById(id).addEventListener("change", renderLibrary);
[
"file-search",
"file-filter-family",
"file-filter-project",
"file-filter-material",
"file-filter-folder",
"file-sort",
].forEach((id) => {
const node = document.getElementById(id);
if (!node) return;
const onChange = () => {
saveLibMemory();
renderLibrary();
};
node.addEventListener("input", onChange);
node.addEventListener("change", onChange);
});
document.getElementById("targets").addEventListener("click", (event) => {
@@ -1323,6 +1564,14 @@ const libCard = document.getElementById("lib-card");
}));
libCard.addEventListener("drop", (event) => uploadFiles(event.dataTransfer.files));
document.getElementById("folder-create")?.addEventListener("click", async () => {
try {
await promptCreateFolder();
} catch (error) {
toast(error.message || String(error), "bad");
}
});
document.getElementById("rescan").addEventListener("click", async () => {
try {
const result = await api("/dateien/neu-lesen", { method: "POST" });
+117 -3
View File
@@ -18,6 +18,17 @@
</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>
<div class="field" style="max-width:16rem; margin-top:0.85rem">
<label for="tz-mode">Zeitzone</label>
<select id="tz-mode">
<option value="auto">Browser (automatisch)</option>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/Vienna">Europe/Vienna</option>
<option value="Europe/Zurich">Europe/Zurich</option>
<option value="UTC">UTC</option>
</select>
</div>
<p class="hint">Für Protokoll, Bericht und Aktivitätszeiten. Nur dieser Browser.</p>
<p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p>
</section>
@@ -29,6 +40,20 @@
<p id="auto-note"></p>
</section>
<section class="card admin-only">
<h2>Slice-Ordner</h2>
<p class="hint">Ordner, den Grotti für die Bibliothek beobachtet (Orca-Export, „Ordner neu lesen“). Leer = nur manueller Upload. Standard: Ordner <code>slice</code> im Grotti-Repo.</p>
<div class="field" style="max-width:36rem">
<label for="watch-dir">Pfad</label>
<input id="watch-dir" type="text" autocomplete="off" placeholder="D:\Projekte\Grotti\slice" spellcheck="false">
</div>
<div class="actions">
<button type="button" id="watch-save">Speichern</button>
<button type="button" class="ghost" id="watch-default">Standardordner</button>
</div>
<p id="watch-note"></p>
</section>
<section class="card admin-only">
<h2>Öffentliche Adresse</h2>
<p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z.&nbsp;B. localhost).</p>
@@ -103,15 +128,19 @@
<section class="card admin-only">
<h2>Energie</h2>
<p class="hint">Für Kosten im Protokoll. Filament zählt erst mit zugeordneter Spule.</p>
<p class="hint">Kosten im Protokoll. Mit Düse-/Bett-Watt und ED-Proben schätzt Grotti den Verbrauch; sonst Dauer × Grundlast. Filament braucht eine 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-watts">Grundlast W</label><input id="en-watts" type="number" step="1" title="Elektronik/Motion — nicht die Heizer"></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-nozzle">Düse W</label><input id="en-nozzle" type="number" step="1" placeholder="z. B. 60"></div>
<div class="field"><label for="en-bed">Bett W</label><input id="en-bed" type="number" step="1" placeholder="z. B. 220"></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>
<p class="hint">Grundlast = Elektronik/Motion (nicht ganzer Drucker). Düse/Bett 0 = nur pauschal. Typisch Kobra: Düse ~60 W, Bett ~200–300 W.</p>
<p id="en-ed-hint" class="hint" hidden></p>
<div class="actions"><button type="button" id="en-save">Speichern</button></div>
<p id="en-note"></p>
</section>
@@ -330,6 +359,11 @@ async function load() {
]);
if (isAdmin) renderPrinters(printers);
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
const watchDir = document.getElementById("watch-dir");
if (watchDir) {
watchDir.value = settings.watch_dir || "";
watchDir.dataset.default = settings.watch_dir_default || "";
}
const fqdn = document.getElementById("fqdn");
if (fqdn) fqdn.value = settings.fqdn || "";
const tgEnabled = document.getElementById("tg-enabled");
@@ -348,12 +382,15 @@ async function load() {
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-watts").value = en.watts ?? 40;
document.getElementById("en-nozzle").value = en.nozzle_watts ?? 60;
document.getElementById("en-bed").value = en.bed_watts ?? 220;
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;
updateEnergyEdHint();
}
const legacy = document.getElementById("legacy-token-hint");
if (legacy) legacy.hidden = !settings.legacy_token_set;
@@ -417,6 +454,30 @@ document.getElementById("auto-save").addEventListener("click", async () => {
}
});
document.getElementById("watch-default")?.addEventListener("click", () => {
const input = document.getElementById("watch-dir");
if (input) input.value = input.dataset.default || "";
});
document.getElementById("watch-save")?.addEventListener("click", async () => {
try {
const out = await api("/einstellungen", {
method: "PATCH",
json: { watch_dir: document.getElementById("watch-dir").value.trim() },
});
document.getElementById("watch-dir").value = out.watch_dir || "";
showNote(
"watch-note",
null,
out.watch_dir
? "Slice-Ordner aktiv: " + out.watch_dir
: "Kein Slice-Ordner — nur manueller Upload."
);
} catch (error) {
showNote("watch-note", error);
}
});
document.getElementById("fqdn-save").addEventListener("click", async () => {
try {
const out = await api("/einstellungen", {
@@ -490,13 +551,36 @@ document.getElementById("tg-test").addEventListener("click", async () => {
}
});
function updateEnergyEdHint() {
const hint = document.getElementById("en-ed-hint");
if (!hint) return;
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
const bed = Number(document.getElementById("en-bed").value) || 0;
if (nozzle > 0 && bed > 0) {
hint.hidden = true;
hint.textContent = "";
return;
}
hint.hidden = false;
hint.textContent = "Düse oder Bett noch 0 — Bericht bleibt „pauschal“. Für ED z. B. Düse 60 W und Bett 220 W setzen.";
}
["en-nozzle", "en-bed"].forEach((id) => {
const node = document.getElementById(id);
if (node) node.addEventListener("input", updateEnergyEdHint);
});
document.getElementById("en-save").addEventListener("click", async () => {
try {
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
const bed = Number(document.getElementById("en-bed").value) || 0;
await api("/einstellungen", {
method: "PATCH",
json: {
energy: {
watts: Number(document.getElementById("en-watts").value),
nozzle_watts: nozzle,
bed_watts: bed,
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),
@@ -505,7 +589,17 @@ document.getElementById("en-save").addEventListener("click", async () => {
},
},
});
updateEnergyEdHint();
if (nozzle <= 0 || bed <= 0) {
const note = document.getElementById("en-note");
if (note) {
note.textContent = "Gespeichert. Düse/Bett 0 → nur pauschal. Für ED z. B. 60 / 220 W setzen.";
note.className = "msg";
}
toast("Energie gespeichert — ED noch aus (Düse/Bett 0).", "bad");
} else {
showNote("en-note", null, "Energie gespeichert.");
}
} catch (error) {
showNote("en-note", error);
}
@@ -534,6 +628,26 @@ document.getElementById("en-save").addEventListener("click", async () => {
});
})();
(function initTimeZone() {
const sel = document.getElementById("tz-mode");
if (!sel) return;
const known = ["auto", "Europe/Berlin", "Europe/Vienna", "Europe/Zurich", "UTC"];
try {
const value = localStorage.getItem(TZ_KEY) || "auto";
sel.value = known.includes(value) ? value : "auto";
} catch (error) {
sel.value = "auto";
}
sel.addEventListener("change", () => {
try { localStorage.setItem(TZ_KEY, sel.value || "auto"); } catch (error) {}
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 %}
+1 -1
View File
@@ -95,7 +95,7 @@
<p class="code">{{ sheet.chargen_nr }}</p>
{% set soll = sheet.target_g if sheet.target_g is defined else target_g %}
{% if soll %}
<p>Ziel {{ '%.0f'|format(soll) }} g</p>
<p>Soll {{ '%.0f'|format(soll) }} g</p>
{% endif %}
<p>{{ run.printer_name }} · {{ (run.ended_at or run.started_at or '')[:10] }}</p>
{% for spool in spools %}
+55 -26
View File
@@ -9,7 +9,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=45">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=52">
<script>
(function () {
try {
@@ -157,7 +157,7 @@
<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=24"></script>
<script src="{{ url_for('static', filename='api.js') }}?v=26"></script>
<script>
document.getElementById("logout-btn")?.addEventListener("click", async () => {
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
@@ -241,21 +241,44 @@ async function cancelQueueItem(job, button) {
}
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>
function ablaufStepFrac(job) {
if (!job || !job.is_ablauf || !job.step_labels || !job.step_labels.length) return "";
const idx = Number(job.display_step_index != null ? job.display_step_index : job.step_index) || 0;
return (idx + 1) + "/" + job.step_labels.length;
}
function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId }) {
const pct = Math.max(0, Math.min(100, Math.round((Number(progress) || 0) * 100)));
const finish = remain > 0 ? finishClock(remain) : "";
const bits = [name];
if (pct > 0 || remain > 0) bits.push(pct + " %");
if (finish) bits.push(finish);
if (tipExtra) bits.push(tipExtra);
const tip = [
filename ? jobLabel(filename) : "",
remain > 0 ? "noch " + hours(remain) : "",
tipExtra ? "Schritt " + tipExtra : "",
].filter(Boolean).join(" · ");
return `<li class="queue-item running run-line" style="--pct:${pct}">
<a class="queue-link run-line-link" href="/warteschlange" title="${escapeHtml(tip || "Zur Warteschlange")}">
<span class="run-line-fill" aria-hidden="true"></span>
<span class="run-line-text">${escapeHtml(bits.join(" · "))}</span>
</a>
${cancelId != null ? `<span class="queue-tools"><button type="button" class="icon-btn danger" data-cancel-q="${cancelId}" title="Abbrechen">${icon("x")}</button></span>` : ""}
</li>`;
}
function runItemHtml(job) {
return runLineHtml({
name: queueTarget(job),
progress: job.progress,
remain: job.remain,
filename: job.filename,
tipExtra: ablaufStepFrac(job),
cancelId: job.id,
});
}
function waitItemHtml(job, index, total) {
const isError = job.status === "error";
const label = job.is_ablauf ? "Ablauf · " + jobLabel(job.filename) : jobLabel(job.filename);
@@ -276,17 +299,12 @@ function waitItemHtml(job, index, total) {
}
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>`;
return runLineHtml({
name: p.name || p.host,
progress: p.progress,
remain: p.remain,
filename: p.filename,
});
}
let queueCache = [];
@@ -322,7 +340,18 @@ async function refreshPrintQueue() {
[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 liveByHost = Object.create(null);
(active || []).forEach((p) => { if (p.host) liveByHost[p.host] = p; });
const running = queueCache.filter((job) => job.status === "running").map((job) => {
const live = liveByHost[job.host];
if (!live) return job;
return {
...job,
progress: live.progress,
remain: live.remain,
filename: stickyLiveFilename(job.host, live.filename || job.filename, live.state) || job.filename,
};
});
const queuedHosts = new Set(running.map((job) => job.host));
const direct = (active || []).filter((p) => !queuedHosts.has(p.host)).map((p) => ({
...p,
+98 -55
View File
@@ -61,35 +61,6 @@
<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>
@@ -108,11 +79,47 @@
<option value="8">ACE 2 · Platz 4</option>
</select>
</div>
<div class="field"><label for="spool-left">Rest gramm</label><input id="spool-left" type="number" step="0.1" value="1000"></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>
<details class="mat-more" id="spool-more">
<summary>
<svg class="ico caret"><use href="#i-down"/></svg>
<span class="grow">Weitere Angaben</span>
</summary>
<div class="mat-more-body">
<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 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 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>
</details>
<div class="actions">
<button type="submit">Merken</button>
<button type="button" class="ghost" id="spool-shelf" hidden>Ins Regal</button>
@@ -122,8 +129,8 @@
</form>
<div id="spool-akte" class="spool-akte" hidden>
<h3>Akte</h3>
<p class="tiny" id="akte-summary"></p>
<ul class="plain" id="akte-runs"></ul>
<div class="kpi-row akte-kpis" id="akte-kpis"></div>
<div class="akte-runs" id="akte-runs"></div>
</div>
<p id="spool-note"></p>
</section>
@@ -429,6 +436,35 @@ function payload() {
};
}
const akteStates = {
completed: "fertig", cancelled: "abgebrochen", error: "Fehler",
in_progress: "druckt", printing: "druckt", paused: "pausiert",
interrupted: "unterbrochen",
};
function akteWhen(iso) {
if (!iso) return "—";
const text = formatDateTime(iso, { year: undefined });
return text === "—" ? "—" : text.replace(/,\s*/, " ");
}
function akteFile(name) {
const base = (name || "").split("/").pop() || "";
if (!base) return "Druck";
if (base.length <= 42) return base;
return base.slice(0, 39) + "…";
}
function akteStatus(run) {
if (run.verdict === "ausschuss") return "Ausschuss";
if (run.verdict === "gut") return "gut";
return akteStates[run.state] || run.state || "";
}
function akteKpi(label, value) {
return '<div class="kpi"><div class="kpi-val">' + escapeHtml(value) + '</div><div class="kpi-label">' + escapeHtml(label) + "</div></div>";
}
async function loadAkte(spoolId) {
const box = document.getElementById("spool-akte");
if (!spoolId) {
@@ -442,29 +478,34 @@ async function loadAkte(spoolId) {
let leer = "—";
if (stats.empty_in_days == null) leer = "kein Verbrauch";
else if (stats.empty_in_days <= 0) leer = "leer";
else leer = "noch ~" + Math.round(stats.empty_in_days) + " Tage";
document.getElementById("akte-summary").textContent =
"Verdruckt " + grams(stats.total_g)
+ " · Ausschuss " + grams(stats.scrap_g)
+ " · " + euro(stats.total_cost)
+ " · Füllstand " + fillPct + " % · Leer-in: " + leer;
else leer = "~" + Math.round(stats.empty_in_days) + " Tage";
document.getElementById("akte-kpis").innerHTML =
akteKpi("Verdruckt", grams(stats.total_g))
+ akteKpi("Ausschuss", grams(stats.scrap_g))
+ akteKpi("Kosten", euro(stats.total_cost))
+ akteKpi("Füllstand", fillPct + " %")
+ akteKpi("Leer-in", leer);
const list = document.getElementById("akte-runs");
list.innerHTML = "";
(data.runs || []).slice(0, 12).forEach((run) => {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "/protokoll/" + run.id;
a.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
li.appendChild(a);
li.appendChild(document.createTextNode(
" · " + grams(run.spool_weight_g)
+ (run.spool_is_estimate ? " ≈" : "")
+ " · " + (run.verdict || run.state || "")
));
list.appendChild(li);
});
if (!(data.runs || []).length) {
list.innerHTML = '<li class="tiny">Noch kein Druck auf dieser Spule.</li>';
const runs = data.runs || [];
if (!runs.length) {
list.innerHTML = '<p class="tiny">Noch kein Druck auf dieser Spule.</p>';
} else {
list.innerHTML = runs.slice(0, 12).map((run) => {
const href = "/protokoll/" + run.id;
const charge = run.chargen_nr
? '<a class="tag akte-charge" href="' + href + '">' + escapeHtml(run.chargen_nr) + "</a>"
: '<span class="akte-charge tiny">—</span>';
const file = '<a class="akte-file" href="' + href + '" title="' + escapeHtml((run.filename || "").split("/").pop() || "") + '">'
+ escapeHtml(akteFile(run.filename)) + "</a>";
const gramsTxt = grams(run.spool_weight_g) + (run.spool_is_estimate ? " ≈" : "");
return '<div class="akte-run">'
+ '<span class="akte-when tiny">' + escapeHtml(akteWhen(run.started_at)) + "</span>"
+ charge
+ file
+ '<span class="akte-g">' + escapeHtml(gramsTxt) + "</span>"
+ '<span class="akte-state tiny">' + escapeHtml(akteStatus(run)) + "</span>"
+ "</div>";
}).join("");
}
box.hidden = false;
} catch (error) {
@@ -483,7 +524,9 @@ function fill(spool) {
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-left").value = spool
? Math.round((Number(spool.remaining_g) || 0) * 10) / 10
: 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) : "";
+72 -1
View File
@@ -61,6 +61,12 @@
</aside>
</div>
<div class="desk-tools">
<details class="desk-fold desk-tool" data-fold="verlauf" open>
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Verlauf</span><span class="tiny" data-role="verlauf-caption"></span></summary>
<div class="desk-fold-body desk-verlauf" data-role="verlauf">
<p class="tiny">Lade Verlauf …</p>
</div>
</details>
<details class="desk-fold desk-tool" data-fold="path">
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">3D-Pfad</span><span class="tiny" data-role="path-caption"></span></summary>
<div class="desk-fold-body">
@@ -100,6 +106,9 @@ let deskCamFails = 0;
let deskViewerFileId = null;
let deskSyncGen = 0;
let deskExcludeSelected = "";
let deskVerlaufAt = 0;
let deskVerlaufHtml = "";
let deskVerlaufHost = "";
const wallPullTried = new Set();
const toolpathCache = new Map();
@@ -909,6 +918,61 @@ function patchDesk(printer) {
// Kamera nur über Timer aktualisieren — sonst doppelter Reload + Layout-Ruckler
syncDeskViewer(printer).catch(() => {});
refreshDeskVerlauf(false);
}
async function refreshDeskVerlauf(force) {
if (!deskHost || deskEl.hidden) return;
const now = Date.now();
if (!force && deskVerlaufHost === deskHost && now - deskVerlaufAt < 20000) return;
const host = deskHost;
deskVerlaufAt = now;
deskVerlaufHost = host;
const box = deskEl.querySelector('[data-role="verlauf"]');
const caption = deskEl.querySelector('[data-role="verlauf-caption"]');
if (!box) return;
try {
const data = await api("/laeufe/aktuell?host=" + encodeURIComponent(host));
if (deskHost !== host) return;
const run = data.run;
if (!run) {
const empty = '<p class="desk-empty"><svg class="ico"><use href="#i-activity"/></svg><span>Noch kein Verlauf</span></p>';
if (deskVerlaufHtml !== empty) {
deskVerlaufHtml = empty;
box.innerHTML = empty;
}
if (caption) caption.textContent = "";
return;
}
const active = ["in_progress", "printing", "paused"].includes(run.state);
const energy = data.energy || {};
const kwh = energy.kwh != null
? (Number(energy.kwh) || 0).toLocaleString("de-DE", { maximumFractionDigits: 2 }) + " kWh"
+ (energy.source === "ed" ? " ED" : "")
: "";
const bits = [
active ? "läuft" : "letzter Druck",
formatDateRange(run.started_at, run.ended_at),
kwh,
].filter(Boolean);
if (caption) caption.textContent = bits.join(" · ");
const head = `<div class="desk-verlauf-head">
<span class="tiny grow">${escapeHtml(jobLabel(run.filename) || "Druck")}${run.chargen_nr ? " · " + escapeHtml(run.chargen_nr) : ""}</span>
<a class="ghost tiny-btn" href="/protokoll/${run.id}">Bericht →</a>
</div>`;
const html = head + tempChartHtml(data.samples || []);
if (deskVerlaufHtml !== html) {
deskVerlaufHtml = html;
box.innerHTML = html;
}
} catch (error) {
if (deskHost !== host) return;
const fail = '<p class="tiny msg">' + escapeHtml(error.message || "Verlauf nicht geladen") + "</p>";
if (deskVerlaufHtml !== fail) {
deskVerlaufHtml = fail;
box.innerHTML = fail;
}
}
}
async function syncDeskViewer(printer) {
@@ -1068,10 +1132,15 @@ function openDesk(host, opts) {
const prev = deskHost;
deskHost = host;
setDeskVisible(true);
if (prev !== host) destroyDeskViewer();
if (prev !== host) {
destroyDeskViewer();
deskVerlaufHtml = "";
deskVerlaufAt = 0;
}
const steer = deskEl.querySelector("[data-role=steer]");
if (!same || !steer.querySelector(".steer")) setHtml(steer, steerPanelHtml(printer));
patchDesk(printer);
refreshDeskVerlauf(true);
startCameraTimer();
if (!same) {
const back = document.getElementById("desk-back");
@@ -1087,6 +1156,8 @@ function openDesk(host, opts) {
function closeDesk(opts) {
if (!deskHost && deskEl.hidden) return;
deskHost = "";
deskVerlaufHtml = "";
deskVerlaufHost = "";
stopCameraTimer();
destroyDeskViewer();
setDeskVisible(false);