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:
1 parent
efd0801cd1
commit
9e22cebb81
48 files changed
+2253
-416
No files matched your search
@@ -2,6 +2,10 @@
|
||||
config.json
|
||||
data/
|
||||
|
||||
# Slice-/Watch-Ordner: nur der Platzhalter im Git, Inhalt lokal
|
||||
slice/*
|
||||
!slice/.gitkeep
|
||||
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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**.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest==9.1.1
|
||||
+7
-5
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Hilfsskripte (Orca-Nachbearbeitung u. a.)."""
|
||||
@@ -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())
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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" });
|
||||
|
||||
@@ -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. 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 %}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user