From 9e22cebb81a36adc73e9ee00ae00bf77ffb46075 Mon Sep 17 00:00:00 2001 From: Dominic Gundel Date: Sun, 18 Oct 2026 00:41:15 +0200 Subject: [PATCH] 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. --- .gitignore | 4 + CHANGELOG.md | 32 +++ README.md | 30 +- config.example.json | 7 +- docs/Anleitung-Dateien.md | 17 +- docs/Anleitung-Einrichtung.md | 2 +- docs/Anleitung-Einstellungen.md | 9 +- docs/Anleitung-Material.md | 2 +- docs/Anleitung-Oberflaeche.md | 3 +- docs/Anleitung-Orca.md | 55 ++++ docs/Anleitung-Wand.md | 1 + docs/Anleitung-Warteschlange.md | 4 +- docs/internal/Abraeumen.md | 2 +- docs/internal/Chargen-Etiketten-Viewer.md | 4 +- docs/internal/Dateien-Metadaten.md | 4 +- docs/internal/Druckbericht-Statistik.md | 4 +- docs/internal/Features-UI.md | 21 +- docs/internal/Senden-Queue.md | 2 +- grotti/config.py | 61 +++- grotti/cost.py | 94 +++++- grotti/eject.py | 103 +------ grotti/jobs.py | 3 +- grotti/library.py | 138 ++++++++- grotti/moonraker.py | 42 +-- grotti/printers.py | 23 -- grotti/schema.sql | 7 +- grotti/store.py | 276 +++++++++++++++--- grotti/version.py | 2 +- grotti/watcher.py | 76 ++++- requirements-dev.txt | 2 + requirements.txt | 12 +- scripts/__init__.py | 1 + scripts/orca_senden.py | 332 ++++++++++++++++++++++ slice/.gitkeep | 0 tests/test_config_watch.py | 49 ++++ tests/test_cost_ed.py | 51 ++++ tests/test_eject.py | 5 +- tests/test_orca_senden.py | 103 +++++++ web/app.py | 70 ++++- web/static/api.js | 121 +++++++- web/static/grotti.css | 141 ++++++++- web/templates/bericht.html | 44 +-- web/templates/dateien.html | 285 +++++++++++++++++-- web/templates/einstellungen.html | 122 +++++++- web/templates/etikett_lauf.html | 2 +- web/templates/layout.html | 81 ++++-- web/templates/material.html | 159 +++++++---- web/templates/wand.html | 73 ++++- 48 files changed, 2259 insertions(+), 422 deletions(-) create mode 100644 docs/Anleitung-Orca.md create mode 100644 requirements-dev.txt create mode 100644 scripts/__init__.py create mode 100644 scripts/orca_senden.py create mode 100644 slice/.gitkeep create mode 100644 tests/test_config_watch.py create mode 100644 tests/test_cost_ed.py create mode 100644 tests/test_orca_senden.py diff --git a/.gitignore b/.gitignore index a2c3282..b6b20c4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ config.json data/ +# Slice-/Watch-Ordner: nur der Platzhalter im Git, Inhalt lokal +slice/* +!slice/.gitkeep + .venv/ __pycache__/ *.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md index b890b08..1bc3cc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/` 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 diff --git a/README.md b/README.md index dea7030..d483cdf 100644 --- a/README.md +++ b/README.md @@ -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 `. | | `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/` | Drucker anlegen/ändern (Admin) | | `POST /drucker//` | Pause, Fortsetzen, Abbrechen, Fehler löschen | -| `POST /drucker//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/` | Warteschlange (`confirm` beim Einreihen) | -| `POST /spulen//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//steuern` | Temperatur, Speed, Flow, Lüfter, ACE | +| `POST /drucker//abraeumen` | Platte geräumt (`confirm`) | +| `GET /kamera/`, `/vorschau/` | Snapshot / Job-Thumbnail | +| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/` | Bibliothek, Upload, Metadaten | +| `POST /dateien/neu-lesen`, `/dateien/ordner`, `/dateien//verschieben` | Watch-Sync, Ordner, Verschieben | +| `POST /senden`, `GET /sende-auftraege` | Sofort senden (`confirm`), Fortschritt | +| `GET`/`POST /druck-queue`, `PATCH /druck-queue/` | 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//einlegen` | Spulen und Einlegen | +| `GET /spulen//akte`, `/spulen/by-code` | Spulenakte, Scan `S-…` | +| `GET`/`POST /materialien`, `/farben` | Sorten und Farbpalette | +| `GET /laeufe`, `/laeufe/aktuell`, `/laeufe/` | 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`). diff --git a/config.example.json b/config.example.json index 8bb5c8b..551232d 100644 --- a/config.example.json +++ b/config.example.json @@ -2,7 +2,7 @@ "host": "127.0.0.1", "port": 8770, "data_dir": "data", - "watch_dir": "", + "watch_dir": "slice", "database_url": "postgresql://postgres@127.0.0.1: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 } diff --git a/docs/Anleitung-Dateien.md b/docs/Anleitung-Dateien.md index 56df1f8..8fef208 100644 --- a/docs/Anleitung-Dateien.md +++ b/docs/Anleitung-Dateien.md @@ -1,18 +1,22 @@ # Dateien senden -Stand **15.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts. +Stand **17.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts. ![Dateien: Bibliothek und Senden](../web/static/doku/dateien-flow.png) ## Bibliothek -- Suche, Filter nach Maschine und Projekt +- Suche nach Name, Material und Projekt +- Filter: Maschine, Projekt, Material, Ordner (Unterordner unter dem Slice-Ordner; Uploads als „Upload“) +- Sortierung: Neueste, Name, Material, Ordner, Druckzeit, Gewicht — bei Material/Ordner mit Gruppenköpfen +- Auswahl der Filter bleibt im Browser merken - Vorschaubild, Material, Zeit, Gewicht, Schichthöhe - Info-Popup mit Slice-Metadaten (Kopf und Footer) - **Hochladen** per Knopf oder Drag & Drop -- **Ordner neu lesen**, wenn `watch_dir` in der Config auf den Slice-Ordner zeigt +- Mit gesetztem Slice-Ordner ([Einstellungen](Anleitung-Einstellungen.md)): **Ordner anlegen**, Watch-Dateien per Auswahl **verschieben** (Datei bleibt in der Bibliothek dieselbe, inkl. Projekt) +- **Ordner neu lesen**, wenn unter Einstellungen ein Slice-Ordner gesetzt ist -Ohne `watch_dir` geht nur manueller Upload — das reicht für den Alltag. +Ohne Slice-Ordner geht nur manueller Upload — das reicht für den Alltag. Uploads liegen flach unter „Upload“ und lassen sich in der Bibliothek nicht in Slice-Ordner schieben. ## Senden: drei Schritte @@ -43,7 +47,12 @@ Große Dateien brauchen oft **eine Minute**. In der Sidebar unter **Senden** sie - Bei „Wenn frei“: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md) - Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map) +## Aus Orca + +Nach dem G-code-Export kann Orca die Datei direkt nach Grotti laden und diese Seite mit der Datei öffnen — siehe [Orca an Grotti](Anleitung-Orca.md). + ## Weiter +- [Orca an Grotti](Anleitung-Orca.md) - [ACE und Spulen](Anleitung-ACE.md) - [Warteschlange](Anleitung-Warteschlange.md) diff --git a/docs/Anleitung-Einrichtung.md b/docs/Anleitung-Einrichtung.md index 6a2e9ea..dadffd4 100644 --- a/docs/Anleitung-Einrichtung.md +++ b/docs/Anleitung-Einrichtung.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. | diff --git a/docs/Anleitung-Einstellungen.md b/docs/Anleitung-Einstellungen.md index fcbdc13..e18fe8d 100644 --- a/docs/Anleitung-Einstellungen.md +++ b/docs/Anleitung-Einstellungen.md @@ -1,17 +1,22 @@ # Einstellungen -Stand **15.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie. +Stand **17.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie. ![Einstellungen: Drucker](../web/static/doku/einstellungen-drucker.png) ## Dieser Browser - **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche.md). +- **Zeitzone** — Browser automatisch oder fest (z. B. Europe/Berlin) für Protokoll, Bericht und Aktivität. ## Automatik Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen „wenn frei“-Starts. Laufende Abläufe und manuelle „Jetzt“-Starts bleiben möglich. +## Slice-Ordner + +Pfad zum Ordner, den die Bibliothek beobachtet (Export aus Orca/Anycubic, **Ordner neu lesen**, Unterordner). **Standardordner** setzt `slice` im Grotti-Repo. Leer = nur Hochladen. Nur Admin; der Ordner wird beim Speichern angelegt, falls er fehlt. + ## Drucker Tabelle: Name, Host, Familie, **gesperrt**. @@ -36,7 +41,7 @@ MCP (optional): Token erzeugen, dann `python -m grotti.mcp_server` mit `GROTTI_U ## Energie -Watt und Strompreis für die Kosten im **Protokoll**. Optional Trockner/Ausschuss/Auslastung für die Kalkulation. +**Grundlast W** (Elektronik/Motion, nicht die Heizer) und Strompreis für die Kosten im **Protokoll**. **Düse W** / **Bett W** zusammen mit ED-Proben im Verlauf → Schätzung „aus ED“; stehen sie auf 0, bleibt es bei Dauer × Grundlast („pauschal“). Typisch Kobra: Grundlast ~40 W, Düse ~60 W, Bett ~200–300 W. Beim Speichern warnt die Seite, wenn Düse oder Bett noch 0 sind. Optional Trockner/Ausschuss/Auslastung für die Kalkulation. ## Weiter diff --git a/docs/Anleitung-Material.md b/docs/Anleitung-Material.md index 378474c..acb4cb2 100644 --- a/docs/Anleitung-Material.md +++ b/docs/Anleitung-Material.md @@ -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. diff --git a/docs/Anleitung-Oberflaeche.md b/docs/Anleitung-Oberflaeche.md index 81a33f7..b82c650 100644 --- a/docs/Anleitung-Oberflaeche.md +++ b/docs/Anleitung-Oberflaeche.md @@ -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`). diff --git a/docs/Anleitung-Orca.md b/docs/Anleitung-Orca.md new file mode 100644 index 0000000..6f8ea86 --- /dev/null +++ b/docs/Anleitung-Orca.md @@ -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) diff --git a/docs/Anleitung-Wand.md b/docs/Anleitung-Wand.md index fdeabe3..15eadcc 100644 --- a/docs/Anleitung-Wand.md +++ b/docs/Anleitung-Wand.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 diff --git a/docs/Anleitung-Warteschlange.md b/docs/Anleitung-Warteschlange.md index c9153ba..5ff4872 100644 --- a/docs/Anleitung-Warteschlange.md +++ b/docs/Anleitung-Warteschlange.md @@ -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**. diff --git a/docs/internal/Abraeumen.md b/docs/internal/Abraeumen.md index 01caf41..2242d8e 100644 --- a/docs/internal/Abraeumen.md +++ b/docs/internal/Abraeumen.md @@ -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. diff --git a/docs/internal/Chargen-Etiketten-Viewer.md b/docs/internal/Chargen-Etiketten-Viewer.md index 1f997df..75824ca 100644 --- a/docs/internal/Chargen-Etiketten-Viewer.md +++ b/docs/internal/Chargen-Etiketten-Viewer.md @@ -12,9 +12,9 @@ Etikett: `/etikett/`, 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/?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/?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 diff --git a/docs/internal/Dateien-Metadaten.md b/docs/internal/Dateien-Metadaten.md index c10ad3d..9e43a98 100644 --- a/docs/internal/Dateien-Metadaten.md +++ b/docs/internal/Dateien-Metadaten.md @@ -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'`. diff --git a/docs/internal/Druckbericht-Statistik.md b/docs/internal/Druckbericht-Statistik.md index 6fc896c..967e350 100644 --- a/docs/internal/Druckbericht-Statistik.md +++ b/docs/internal/Druckbericht-Statistik.md @@ -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 diff --git a/docs/internal/Features-UI.md b/docs/internal/Features-UI.md index a32cd83..3ee179b 100644 --- a/docs/internal/Features-UI.md +++ b/docs/internal/Features-UI.md @@ -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 `/#`): 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 `/#`): 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//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/` (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/` (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 diff --git a/docs/internal/Senden-Queue.md b/docs/internal/Senden-Queue.md index 08179ed..f4cf98a 100644 --- a/docs/internal/Senden-Queue.md +++ b/docs/internal/Senden-Queue.md @@ -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**. diff --git a/grotti/config.py b/grotti/config.py index 33cfd1b..b0f847e 100644 --- a/grotti/config.py +++ b/grotti/config.py @@ -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)), diff --git a/grotti/cost.py b/grotti/cost.py index 7ad2d32..d869f00 100644 --- a/grotti/cost.py +++ b/grotti/cost.py @@ -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): - hours = float(duration_s or 0) / 3600 - return (float(energy["watts"]) / 1000) * hours * float(energy["euro_per_kwh"]) +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 + 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"] diff --git a/grotti/eject.py b/grotti/eject.py index 1f3ff37..9ded7d4 100644 --- a/grotti/eject.py +++ b/grotti/eject.py @@ -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 "" diff --git a/grotti/jobs.py b/grotti/jobs.py index 4bfee3d..4f6dda8 100644 --- a/grotti/jobs.py +++ b/grotti/jobs.py @@ -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( diff --git a/grotti/library.py b/grotti/library.py index 2958eec..d1c7414 100644 --- a/grotti/library.py +++ b/grotti/library.py @@ -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) diff --git a/grotti/moonraker.py b/grotti/moonraker.py index 2a08326..938b218 100644 --- a/grotti/moonraker.py +++ b/grotti/moonraker.py @@ -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, diff --git a/grotti/printers.py b/grotti/printers.py index 1507dbc..8ef1197 100644 --- a/grotti/printers.py +++ b/grotti/printers.py @@ -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 diff --git a/grotti/schema.sql b/grotti/schema.sql index 49635ad..09d143f 100644 --- a/grotti/schema.sql +++ b/grotti/schema.sql @@ -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, diff --git a/grotti/store.py b/grotti/store.py index 43a9dbe..2eebce9 100644 --- a/grotti/store.py +++ b/grotti/store.py @@ -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), - ) - known_names.add(name) + 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 == "": diff --git a/grotti/version.py b/grotti/version.py index ab791d0..ab2b6aa 100644 --- a/grotti/version.py +++ b/grotti/version.py @@ -1,3 +1,3 @@ """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" -__version__ = "2.5.1" +__version__ = "2.10.0" diff --git a/grotti/watcher.py b/grotti/watcher.py index 38dc966..085dd44 100644 --- a/grotti/watcher.py +++ b/grotti/watcher.py @@ -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): diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..1441c92 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest==9.1.1 diff --git a/requirements.txt b/requirements.txt index 82fc757..e941eb7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..be67738 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Hilfsskripte (Orca-Nachbearbeitung u. a.).""" diff --git a/scripts/orca_senden.py b/scripts/orca_senden.py new file mode 100644 index 0000000..17ff28f --- /dev/null +++ b/scripts/orca_senden.py @@ -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()) diff --git a/slice/.gitkeep b/slice/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config_watch.py b/tests/test_config_watch.py new file mode 100644 index 0000000..a23f2fb --- /dev/null +++ b/tests/test_config_watch.py @@ -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() diff --git a/tests/test_cost_ed.py b/tests/test_cost_ed.py new file mode 100644 index 0000000..21747e2 --- /dev/null +++ b/tests/test_cost_ed.py @@ -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() diff --git a/tests/test_eject.py b/tests/test_eject.py index bea5a48..f102843 100644 --- a/tests/test_eject.py +++ b/tests/test_eject.py @@ -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") diff --git a/tests/test_orca_senden.py b/tests/test_orca_senden.py new file mode 100644 index 0000000..365b238 --- /dev/null +++ b/tests/test_orca_senden.py @@ -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() diff --git a/web/app.py b/web/app.py index 892f932..a2e5c1a 100644 --- a/web/app.py +++ b/web/app.py @@ -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//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/") 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") diff --git a/web/static/api.js b/web/static/api.js index 2cf70c2..d7a7471 100644 --- a/web/static/api.js +++ b/web/static/api.js @@ -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 '

Noch zu wenig Proben für den Verlauf.

'; + } + 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 ``; + } + + 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(`${escapeHtml(label)}`); + } + const midY = (padT + h - padB) / 2; + + return ` + ${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) : ""} + Temp °C + ${Math.round(maxT)} + ${Math.round(minT)} + ${hasEd ? `ED %` : ""} + ${hasEd ? `100` : ""} + ${hasEd ? `0` : ""} + ${timeTicks.join("")} + +

+ Düse + Bett + Kammer + ${hasEd ? 'ED DüseED Bett' : ""} + · links °C · rechts % · gestrichelt ≈ Soll +

`; } function showNote(id, error, ok) { diff --git a/web/static/grotti.css b/web/static/grotti.css index 64a779c..b7e315d 100644 --- a/web/static/grotti.css +++ b/web/static/grotti.css @@ -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; } diff --git a/web/templates/bericht.html b/web/templates/bericht.html index 0ad87a1..e85d63f 100644 --- a/web/templates/bericht.html +++ b/web/templates/bericht.html @@ -29,33 +29,6 @@ function sourceLabel(source, estimate) { return source || ""; } -function chart(samples) { - if (!samples || samples.length < 2) { - return '

Kein Temperaturverlauf (nur für neue Drucke).

'; - } - 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 ``; - } - return ` - ${path("nozzle_target", "#fdba74")} - ${path("bed_target", "#93c5fd")} - ${path("nozzle", "#ea580c")} - ${path("bed", "#2563eb")} - ${path("chamber", "#16a34a")} - ${Math.round(maxT)} °C - ${Math.round(minT)} °C - -

Düse Bett Kammer · gestrichelt ≈ Soll

`; -} - 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) => ` @@ -79,7 +59,7 @@ function render(data) { `).join("") || 'Keine Spule zugeordnet.'; const events = (data.events || []).map((e) => - `
  • ${escapeHtml((e.created_at || "").replace("T", " ").slice(0, 19))} ${escapeHtml(e.message || e.kind)}
  • ` + `
  • ${escapeHtml(formatDateTime(e.created_at))} ${escapeHtml(e.message || e.kind)}
  • ` ).join("") || "
  • Keine Ereignisse.
  • "; const labels = (data.labels || []).filter((l) => !l.excluded); @@ -96,11 +76,11 @@ function render(data) {

    ${escapeHtml(states[run.state] || run.state)}${run.verdict ? " · Urteil " + escapeHtml(run.verdict) : ""}

    -

    ${escapeHtml(run.started_at || "—")} → ${escapeHtml(run.ended_at || "—")}

    +

    ${escapeHtml(formatDateRange(run.started_at, run.ended_at))}

    Dauer ${hours(run.duration_s)}${run.estimated_s ? " · Soll " + hours(run.estimated_s) : ""}

    Material ${grams(used)} von ${grams(planned)}${planned ? " (" + (diff > 0 ? "+" : "") + diff + " g)" : ""} ${run.weight_source ? ' · ' + sourceLabel(run.weight_source) + "" : ""}

    -

    Kosten ${euro(cost)} (Filament ${euro(run.filament_cost)} · Strom ${euro(run.energy_cost)})

    +

    Kosten ${euro(cost)} (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})

    ${run.message ? "

    " + escapeHtml(run.message) + "

    " : ""} ${data.project ? "

    Projekt: " + escapeHtml(data.project.name) + "

    " : ""}
    @@ -115,7 +95,7 @@ function render(data) {

    Verlauf

    - ${chart(data.samples)} + ${tempChartHtml(data.samples)}
    diff --git a/web/templates/dateien.html b/web/templates/dateien.html index 26c313c..19cd8d3 100644 --- a/web/templates/dateien.html +++ b/web/templates/dateien.html @@ -11,6 +11,7 @@ +
    @@ -22,7 +23,7 @@ 0
    - + + + +

    @@ -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 = ''; + 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 = ''; + 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 = [ + ``, + ``, + ...allFolderNames().map((folder) => + `` + ), + ``, + ]; + return ``; +} + +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) => `` @@ -585,9 +751,21 @@ function thumbHtml(file) { : `${icon("cube")}`; } +function libItemHtml(file) { + const project = projects.find((p) => String(p.id) === String(file.project_id)); + const del = file.source === "upload" + ? `` : ""; + return `
    + ${thumbHtml(file)} + ${escapeHtml(file.name)}${alwaysMeta(file, project)} + ${moveSelectHtml(file)}${infoPopupHtml(file)}${del} +
    `; +} + 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 = '
    ' + icon("file") + 'Noch keine Datei.Hochladen oder Slice-Ordner in den Einstellungen setzen.
    '; @@ -597,16 +775,19 @@ function renderLibrary() { list.innerHTML = '
    ' + icon("search") + 'Keine Datei passt.
    '; return; } - list.innerHTML = shown.map((file) => { - const project = projects.find((p) => String(p.id) === String(file.project_id)); - const del = file.source === "upload" - ? `` : ""; - return `
    - ${thumbHtml(file)} - ${escapeHtml(file.name)}${alwaysMeta(file, project)} - ${infoPopupHtml(file)}${del} -
    `; - }).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(`
    ${escapeHtml(group)}
    `); + 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" }); diff --git a/web/templates/einstellungen.html b/web/templates/einstellungen.html index d108fd9..6af677d 100644 --- a/web/templates/einstellungen.html +++ b/web/templates/einstellungen.html @@ -18,6 +18,17 @@

    Regal: größere Bedienelemente, kompakte Navigation — ideal für ~1024×860 Touch am Regal. Shortcut: ?shelf=1

    +
    + + +
    +

    Für Protokoll, Bericht und Aktivitätszeiten. Nur dieser Browser.

    @@ -29,6 +40,20 @@

    +
    +

    Slice-Ordner

    +

    Ordner, den Grotti für die Bibliothek beobachtet (Orca-Export, „Ordner neu lesen“). Leer = nur manueller Upload. Standard: Ordner slice im Grotti-Repo.

    +
    + + +
    +
    + + +
    +

    +
    +

    Öffentliche Adresse

    FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z. B. localhost).

    @@ -103,15 +128,19 @@

    Energie

    -

    Für Kosten im Protokoll. Filament zählt erst mit zugeordneter Spule.

    +

    Kosten im Protokoll. Mit Düse-/Bett-Watt und ED-Proben schätzt Grotti den Verbrauch; sonst Dauer × Grundlast. Filament braucht eine Spule.

    -
    +
    +
    +
    +

    Grundlast = Elektronik/Motion (nicht ganzer Drucker). Düse/Bett 0 = nur pauschal. Typisch Kobra: Düse ~60 W, Bett ~200–300 W.

    +

    @@ -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 () => { }, }, }); - showNote("en-note", null, "Energie gespeichert."); + 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)); {% endblock %} diff --git a/web/templates/etikett_lauf.html b/web/templates/etikett_lauf.html index 70a1a60..3a820a4 100644 --- a/web/templates/etikett_lauf.html +++ b/web/templates/etikett_lauf.html @@ -95,7 +95,7 @@

    {{ sheet.chargen_nr }}

    {% set soll = sheet.target_g if sheet.target_g is defined else target_g %} {% if soll %} -

    Ziel {{ '%.0f'|format(soll) }} g

    +

    Soll {{ '%.0f'|format(soll) }} g

    {% endif %}

    {{ run.printer_name }} · {{ (run.ended_at or run.started_at or '')[:10] }}

    {% for spool in spools %} diff --git a/web/templates/layout.html b/web/templates/layout.html index 43e1c8c..a456bdf 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -9,7 +9,7 @@ - + +