Grotti 3.3.0: Projekte mit Ordner, Datei-Löschen/Verschieben, Material-Archiv, UI-Polish.

This commit is contained in:
Dominic Gundel committed 2026-10-18 20:10:32 +02:00
1 parent 4ddffbbeb1
commit 269e20a01d
45 files changed
+2856 -320

No files matched your search

+33
View File
@@ -4,6 +4,39 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
## [Unreleased] ## [Unreleased]
## [3.3.0] – 2026-10-18
### Neu
- **Projekte** `/projekte`: Arbeitsmappe mit Ordner unter dem Slice-Pfad (`projects.folder`); G-code-Zuordnung per Sync; Upload weiterer Dateien (STEP/STL/PDF/…); Nav unter Verwalten.
- **Dateien:** Uploads in Slice-Ordner verschieben (werden Watch); Löschen für Upload und Watch (nur unter Slice/`eingang`); kompaktere Bibliothek-Filter.
- **Material:** Filter Sorte/Farbe; leere/archivierte Spulen ausgeblendet (Filter **Archiv**); Buttons **Leer**, **Archivieren**, **Wiederherstellen** (`archived_at`).
- **Wand:** kleines Kamera-Snapshot-Thumb auf jeder Karte (nie Stream); Menüpunkt Kameras entfernt, `/kameras` → Wand.
### Geändert
- **Technik-Cockpit:** kompakte Fact-Chips; Steuerung in Gruppen Heizung / Druck / ACE-Plätze.
- **Protokoll:** kurze Dateinamen, festere Spaltenbreiten.
- **Statistik:** kompakte Filter inkl. Farbe, Auto-Laden, KPI Primär/Sekundär.
- **Orca-Login:** Session 30 Tage (überlebt App-Fenster schließen); kein zweites Chrome mit demselben Profil (sonst erneutes Anmelden).
- **Druckbericht:** Kosten-Torte und Tabelle inkl. Filament (nicht nur Strom); Summe = Material + Energie.
- **Dateien:** Starten / Einreihen / Ablauf mit einem Klick (kein Zwei-Klick mehr). API weiter mit `confirm: true`.
- **Senden:** Zwei Start-Modi statt drei — **Dieser Drucker** (frei → sofort, sonst Warteschlange) und **Nächster passender** (Grotti wählt nach Sorte).
- **Material-Routing:** Match auf Katalog-Sorte aus Orca-`filament_settings_id` (PLA Meta ≠ Matte ≠ Silk); falsche Variante wartet mit Grund. Farbe bleibt soft. Bei Auto-Wahl baut Grotti die ACE-Map aus den Spulen am Claim-Drucker. Bestehende Dateien: **Dateien neu lesen**.
- **Wand:** Bei Filament-Pause Hinweis, die neue Rolle unter Material dem Drucker zuzuweisen — sonst zählt der Verbrauch weiter zur alten Spule.
- Anleitung Material/ACE: Rollenwechsel während Pause klargestellt.
### Behoben
- **Orca/Anycubic öffnet zwei Browserfenster:** Nachbearbeitung läuft oft 2× — Debounce (~25 s) öffnet nur noch eines. Deep-Link lädt fehlende `paint_index` nach (ACE zeigt sonst fälschlich T0/T1).
- **Dateien-Tab hängt nach Orca:** 3D-Pfad erst nach ACE/Senden (Idle); Debounce ignoriert Anycubic-Zeitstempel im Dateinamen; erneuter Export aktualisiert den offenen Tab statt ein zweites Fenster.
- **Orca/Anycubic → Desktop-Fenster:** `orca_senden` öffnet Grotti als Chrome/Edge-App (`browser: app`). Anycubic-Dialog nur vermeiden durch **G-code exportieren**, nicht Remote Print / Aufgabe starten.
- Anleitung Orca: Grotti unter **Gerät** per Device-UI-URL (`http://127.0.0.1:8770/`); Upload-zum-Host weiter meiden.
- **ACE-Map mit Lücken-T (Bambu/Anycubic):** UI behält Slicer-`Tn` (z. B. T1+T10). Beim Senden verdichtet Grotti auf T0…n und schreibt passende `.acm` — sonst gklib **10011700 invalid filament id** bzw. Hang bei Map-Mismatch. Senden prüft Map gegen T-Befehle. Bestehende Dateien: **Dateien neu lesen**.
- **10011700 trotz dichter UI-Map:** Verdichtung hängt am G-code (`T1`/`T10`), nicht daran, ob die Map schon 0/1 zeigt — sonst blieb T10 auf dem Drucker. Grotti neu starten, Clip erneut senden.
- **Abräumen ließ Lauf offen:** Urteil und Platte waren quittiert, der Live-Lauf blieb `in_progress` (Moonraker-History oft ohne Endzeit). Abräumen und Watcher (`printing`→`complete`) schließen den Lauf jetzt als `completed` inkl. Materialabzug.
- **Rollenwechsel während Pause:** Einlegen hängt Abschnitte/Ereignisse auch an offene History-Jobs (nicht nur `live:`). Send-Spule und Lauf-Spule werden mitgezogen; Abzug bevorzugt die Rolle am Drucker statt eines veralteten Send-Hints.
## [3.2.0] – 2026-10-18 ## [3.2.0] – 2026-10-18
### Neu ### Neu
+16 -2
View File
@@ -12,27 +12,41 @@ Stand **18.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Date
Es gibt **kein freies Spulen-Dropdown** beim Start: Grotti nimmt die Rolle(n), die am Ziel-Drucker liegen. Material-Mismatch oder zu wenig Rest → Warnung vor dem Start. Es gibt **kein freies Spulen-Dropdown** beim Start: Grotti nimmt die Rolle(n), die am Ziel-Drucker liegen. Material-Mismatch oder zu wenig Rest → Warnung vor dem Start.
### Rollenwechsel während Pause
Während Druck oder Pause die neue Spule unter Material dem Drucker zuweisen und speichern. Grotti schließt den Abschnitt der alten Rolle und öffnet einen neuen — im Druckbericht erscheinen dann zwei Spulenzeilen und Ereignisse. Nur Filament am Gerät laden ohne Zuweisung: Abzug und Protokoll bleiben bei der alten Rolle.
Wenn Moonraker den Druck nach Filament-Pause als neuen History-Job führt, übernimmt Grotti die neu zugewiesene Rolle für den fortgesetzten Teil (Send- und Lauf-Spule werden beim Einlegen aktualisiert).
Etiketten mit QR: [Material und mehr](Anleitung-Material.md). Etiketten mit QR: [Material und mehr](Anleitung-Material.md).
## ACE-Map beim Senden ## ACE-Map beim Senden
Unter **Dateien → Optionen → ACE-Zuordnung**: Unter **Dateien → Optionen → ACE-Zuordnung**:
- Jede Farbe aus der Datei (`paint_index`) einem **ACE-Platz** zuordnen - Jede Farbe aus der Datei (`Tn` / Slicer-`paint_index`) einem **ACE-Platz** zuordnen
- Liegt unter Material eine Spule auf dem Platz, zeigt die Liste Name, Farbe und Restgramm mit - Bei Bambu-/Anycubic-Dateien sind die Nummern oft **nicht 0–3** (z. B. T1 und T10 bei 16 Slots) — Grotti zeigt `T…`, verdichtet beim Senden aber auf T0…n (sonst Fehler **invalid filament id**)
- Liegt unter Material eine Spule auf dem Platz, zeigt die Liste Name, Farbe und Restgramm mit — Vorauswahl bevorzugt die **Katalog-Sorte** aus dem Orca-Profil (Meta/Matte …)
- Ohne passende Map schreibt der Drucker oft **„cannot parse the file“** und startet nicht - Ohne passende Map schreibt der Drucker oft **„cannot parse the file“** und startet nicht
- Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit - Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit
Bei **Nächster passender** (kein Drucker gewählt) baut Grotti die Map erst beim Claim aus den Spulen am gewählten Drucker. Passt die Sorte nicht → Wartegrund in der Queue.
Externe Spule / keine ACE-Slots: keine `.acm` nötig. Externe Spule / keine ACE-Slots: keine `.acm` nötig.
Nach einem Grotti-Update mit ACE-Farben: unter Dateien **neu lesen**, damit alte Einträge die echten `Tn` bekommen.
## Typische Fehler ## Typische Fehler
| Symptom | Check | | Symptom | Check |
| --- | --- | | --- | --- |
| „cannot parse the file“ | ACE-Zuordnung vollständig? Farben richtig? | | „cannot parse the file“ | ACE-Zuordnung vollständig? Farben richtig? |
| „T… fehlt in der Map“ | Datei neu lesen, Zuordnung erneut setzen (Slicer-T ≠ ACE-Platz) |
| **invalid filament id** (10011700) | Alte Grotti-Version ohne T-Verdichtung, oder Map nicht neu gesetzt. Grotti neu starten, Datei neu lesen, nochmal senden. |
| Material-Warnung | Eingelegte Sorte ≠ Datei. Der Start geht trotzdem. PA12-CF steht in Grotti so, an den ACE geht PA6-CF. | | Material-Warnung | Eingelegte Sorte ≠ Datei. Der Start geht trotzdem. PA12-CF steht in Grotti so, an den ACE geht PA6-CF. |
| Rest zu gering | Andere Spule oder leichteres Teil | | Rest zu gering | Andere Spule oder leichteres Teil |
| ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten | | ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten |
| Drucker hängt kurz nach Multi-Color-Start | Oft falsche Map (T10 gemappt als Platz 0). Neu lesen + Zuordnung. |
## Weiter ## Weiter
+1
View File
@@ -14,6 +14,7 @@ Im Auftrag **Lieferschein** öffnen: Positionen, Chargen `D-…`, druckbar. Dana
## Weiter ## Weiter
- [Projekte](Anleitung-Projekte.md) (Arbeitsmappe / Konstruktion — nicht dasselbe wie Auftrag)
- [Warteschlange](Anleitung-Warteschlange.md) - [Warteschlange](Anleitung-Warteschlange.md)
- [Material](Anleitung-Material.md) - [Material](Anleitung-Material.md)
- [Protokoll / Bericht](Anleitung-Oberflaeche.md) - [Protokoll / Bericht](Anleitung-Oberflaeche.md)
+12 -9
View File
@@ -1,31 +1,33 @@
# Dateien senden # Dateien senden
Stand **17.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts. Stand **18.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
![Dateien: Bibliothek und Senden](../web/static/doku/dateien-flow.png) ![Dateien: Bibliothek und Senden](../web/static/doku/dateien-flow.png)
## Bibliothek ## Bibliothek
- Suche nach Name, Material und Projekt - Kompakte Filterleiste: Suche, Maschine, Projekt, Material, Ordner, Sortierung
- Filter: Maschine, Projekt, Material, Ordner (Unterordner unter dem Slice-Ordner; Uploads als „Upload“) - 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 - Sortierung: Neueste, Name, Material, Ordner, Druckzeit, Gewicht — bei Material/Ordner mit Gruppenköpfen
- Auswahl der Filter bleibt im Browser merken - Auswahl der Filter bleibt im Browser merken
- Vorschaubild, Material, Zeit, Gewicht, Schichthöhe - Vorschaubild, Material, Zeit, Gewicht, Schichthöhe
- Info-Popup mit Slice-Metadaten (Kopf und Footer) - Info-Popup mit Slice-Metadaten (Kopf und Footer)
- **Hochladen** per Knopf oder Drag & Drop - **Hochladen** per Knopf oder Drag & Drop
- Mit gesetztem Slice-Ordner ([Einstellungen](Anleitung-Einstellungen.md)): **Ordner anlegen**, Watch-Dateien per Auswahl **verschieben** (Datei bleibt in der Bibliothek dieselbe, inkl. Projekt) - Mit gesetztem Slice-Ordner ([Einstellungen](Anleitung-Einstellungen.md)): **Ordner anlegen**; Watch- **und** Upload-Dateien per Auswahl **verschieben** (Upload wird zur Watch-Datei im Slice-Ordner)
- **Löschen** (Papierkorb, Zwei-Klick) für jede Bibliotheksdatei — entfernt Eintrag und Datei auf der Platte (nur unter Slice-Ordner oder `eingang`)
- **Ordner neu lesen**, wenn unter Einstellungen ein Slice-Ordner gesetzt ist - **Ordner neu lesen**, wenn unter Einstellungen ein Slice-Ordner gesetzt ist
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. Ohne Slice-Ordner geht nur manueller Upload; Verschieben in Ordner braucht den Slice-Pfad.
Projekte mit eigenem Ordner: [Projekte](Anleitung-Projekte.md).
## Senden: drei Schritte ## Senden: drei Schritte
1. **Datei** wählen (Klick in der Liste). 1. **Datei** wählen (Klick in der Liste).
2. **Drucker** wählen — Kacheln zeigen Live-Zustand und eingelegte Rolle. Passt die Maschine nicht, ist die Kachel blass. 2. **Drucker** wählen — Kacheln zeigen Live-Zustand und eingelegte Rolle. Passt die Maschine nicht, ist die Kachel blass.
3. **Wann:** 3. **Start:**
- **Jetzt** — sofort senden und starten (Zwei-Klick) - **Dieser Drucker** — frei und Platte klar → sofort starten; sonst Warteschlange für diesen Drucker
- **Wenn frei** — in die Warteschlange für diesen Drucker - **Nächster passender** — Grotti wählt den nächsten freien Drucker mit passender Sorte (z. B. Sunlu PLA Meta, nicht Matte)
- **Nächster freier** — erster passender freier Drucker
Auswahl merkt sich der Browser (`localStorage`). Auswahl merkt sich der Browser (`localStorage`).
@@ -44,7 +46,7 @@ Große Dateien brauchen oft **eine Minute**. In der Sidebar unter **Senden** sie
## Nach dem Start ## Nach dem Start
- Fortschritt auf der [Wand](Anleitung-Wand.md) und in der Sidebar **Läuft jetzt** - Fortschritt auf der [Wand](Anleitung-Wand.md) und in der Sidebar **Läuft jetzt**
- Bei „Wenn frei“: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md) - Bei Warteschlange / Nächster passender: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md)
- Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map) - Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map)
## Aus Orca ## Aus Orca
@@ -53,6 +55,7 @@ Nach dem G-code-Export kann Orca die Datei direkt nach Grotti laden und diese Se
## Weiter ## Weiter
- [Projekte](Anleitung-Projekte.md)
- [Orca an Grotti](Anleitung-Orca.md) - [Orca an Grotti](Anleitung-Orca.md)
- [ACE und Spulen](Anleitung-ACE.md) - [ACE und Spulen](Anleitung-ACE.md)
- [Warteschlange](Anleitung-Warteschlange.md) - [Warteschlange](Anleitung-Warteschlange.md)
+1 -1
View File
@@ -54,7 +54,7 @@ Dann <http://127.0.0.1:8770/> öffnen. Beim ersten Start legst du den Admin-Benu
## Sicherheit ## Sicherheit
- Druckstart nur mit Bestätigung (Zwei-Klick in der UI bzw. `confirm: true` in der API). - Druckstart über die API nur mit `confirm: true` (Dateien-UI: ein Klick).
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt. - Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
- Benutzer und API-Tokens unter Einstellungen. Für Zugriff aus dem Internet: Reverse Proxy mit TLS, nicht den Entwickler-Server öffentlich freigeben. - Benutzer und API-Tokens unter Einstellungen. Für Zugriff aus dem Internet: Reverse Proxy mit TLS, nicht den Entwickler-Server öffentlich freigeben.
- `config.json` und `data/` bleiben lokal, nicht im Git. - `config.json` und `data/` bleiben lokal, nicht im Git.
+1 -1
View File
@@ -12,7 +12,7 @@ Stand **18.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Te
## Automatik ## Automatik
Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen „wenn frei“-Starts. Laufende Abläufe und manuelle „Jetzt“-Starts bleiben möglich. Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen Queue-Starts. Laufende Abläufe und manuelle Sofort-Starts bleiben möglich.
## Slice-Ordner ## Slice-Ordner
+5 -5
View File
@@ -1,6 +1,6 @@
# Material und mehr # Material und mehr
Stand **18.10.2026**. Spulen, Etiketten, Aufträge, Protokoll, Statistik, Kameras, Aktivität. Stand **18.10.2026**. Spulen, Etiketten, Aufträge, Protokoll, Statistik, Aktivität.
![Material: Spulen](../web/static/doku/material-spule.png) ![Material: Spulen](../web/static/doku/material-spule.png)
@@ -10,7 +10,7 @@ Zwei Reiter: **Spulen** und **Sorten**.
Oben die Karte **Bedarf aus Warteschlange**, wenn wartende Jobs Filament brauchen: Queue-Gewicht vs. Spulenrest je Sorte/Farbe. Fehlmengen erscheinen auch in der Sidebar und als Chip in der Warteschlange. Oben die Karte **Bedarf aus Warteschlange**, wenn wartende Jobs Filament brauchen: Queue-Gewicht vs. Spulenrest je Sorte/Farbe. Fehlmengen erscheinen auch in der Sidebar und als Chip in der Warteschlange.
**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest. Formular bei Auswahl oder „Neue Spule“. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in. **Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest / Archiv; zusätzlich Sorte und Farbe. Leere und archivierte Spulen sind standardmäßig ausgeblendet — **Archiv** zeigt sie. Formular bei Auswahl oder „Neue Spule“. **Leer** setzt Rest auf 0, legt die Rolle ins Regal und archiviert; **Archivieren** / **Wiederherstellen** ohne Restzwang. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in.
**Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter. **Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
@@ -26,9 +26,9 @@ Unter **Aufträge** Kunden, Positionen und Fortschritt. Dateien lassen sich eine
## Protokoll und Statistik ## Protokoll und Statistik
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Strom-Aufschlüsselung mit Tortendiagramm, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option. Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Kosten-Aufschlüsselung Filament + Strom mit Tortendiagramm, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
Nach Filament-Ende: neue Spule vom Regal dem Drucker/ACE-Platz zuweisen, am Gerät laden, an der Wand **Weiter**. Nach Filament-Ende: neue Spule unter **Material** dem Drucker/ACE-Platz zuweisen (Ort setzen und speichern — das legt den Rollenwechsel im Lauf an), am Gerät laden, an der Wand **Weiter**. Nur physisch nachlegen reicht nicht. Die Zuweisung gilt auch, wenn Moonraker den Druck nach der Pause als neuen Job zählt.
Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis. Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis.
@@ -38,7 +38,7 @@ Abzug nach dem Druck nimmt das Ist aus `filament_used` (Abbruch nur anteilig). M
## Kameras ## Kameras
`/kameras` — alle Kameras nebeneinander mit Live-Zustand. Oben **Snapshot** oder **Stream** wählbar (gilt für diesen Browser, auch im Cockpit). Stream ist flüssiger, hält aber je sichtbarer Kamera eine Verbindung. Auf der **Druckerwand** erscheint je Drucker ein kleines Snapshot-Bild (ca. alle 3 s), wenn eine Webcam erreichbar ist. Klick öffnet das Cockpit — dort gilt weiter die Einstellung Snapshot/Stream unter Einstellungen → Dieser Browser. Die frühere Seite `/kameras` leitet auf die Wand um.
## Aktivität ## Aktivität
+3 -2
View File
@@ -4,7 +4,7 @@ Stand **18.10.2026**. Wie die App aufgebaut ist — Menü, Jetzt-Liste, Desktop
## Navigation ## Navigation
Links (Desktop), gruppiert: **Live** (Drucker, Dateien, Kameras, Warteschlange, Aufträge), **Rückschau** (Protokoll, Statistik, Aktivität), **Verwalten** (Material, Wartung). Darüber im Inhaltsbereich die **Topbar**: Suche, Live-Chips, Einstellungen, Doku und Benutzer-Menü (Abmelden, Version). Links (Desktop), gruppiert: **Live** (Drucker, Dateien, Warteschlange, Aufträge), **Rückschau** (Protokoll, Statistik, Aktivität), **Verwalten** (Projekte, Material, Wartung). Darüber im Inhaltsbereich die **Topbar**: Suche, Live-Chips, Einstellungen, Doku und Benutzer-Menü (Abmelden, Version).
Unter ca. 760 px: untere Leiste (Drucker, Dateien, Warteschlange, **Jetzt**, Mehr) plus **Mehr**-Sheet (inkl. Aktivität und Abmelden). Das Seitenmenü lässt sich zur Icon-Rail einklappen. Unter ca. 760 px: untere Leiste (Drucker, Dateien, Warteschlange, **Jetzt**, Mehr) plus **Mehr**-Sheet (inkl. Aktivität und Abmelden). Das Seitenmenü lässt sich zur Icon-Rail einklappen.
@@ -36,7 +36,7 @@ Unter **Einstellungen → Dieser Browser**:
- **Regal (Touch)** — größere Bedienelemente, Sidebar oft eingeklappt; auf schmalem Viewport die untere Leiste. Alle Menüpunkte bleiben in der Sidebar sichtbar. - **Regal (Touch)** — größere Bedienelemente, Sidebar oft eingeklappt; auf schmalem Viewport die untere Leiste. Alle Menüpunkte bleiben in der Sidebar sichtbar.
- **Darstellung** — Dunkel, Hell oder System - **Darstellung** — Dunkel, Hell oder System
- **Zeitzone** — Browser oder fest (z. B. Europe/Berlin) für Uhrzeiten in Protokoll und Bericht - **Zeitzone** — Browser oder fest (z. B. Europe/Berlin) für Uhrzeiten in Protokoll und Bericht
- **Kamera** — Snapshot (schonend, alle paar Sekunden) oder Stream (flüssig, hält eine Verbindung je Kamera). Auch umschaltbar unter Kameras. - **Kamera** — Snapshot (schonend, alle paar Sekunden) oder Stream (flüssig, hält eine Verbindung). Gilt für das Cockpit; Wand-Thumbs sind immer Snapshot.
Shortcut in der URL: `?shelf=1` bzw. `?shelf=0`. Nur dieser Browser (`localStorage`). Shortcut in der URL: `?shelf=1` bzw. `?shelf=0`. Nur dieser Browser (`localStorage`).
@@ -46,3 +46,4 @@ Drucker-Cockpit: Alltag (Job, ETA, Rolle, Steuerung der Aktionen) oben; **Techni
- [Druckerwand](Anleitung-Wand.md) - [Druckerwand](Anleitung-Wand.md)
- [Dateien senden](Anleitung-Dateien.md) - [Dateien senden](Anleitung-Dateien.md)
- [Projekte](Anleitung-Projekte.md)
+39 -10
View File
@@ -1,6 +1,6 @@
# Orca an Grotti # 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. Stand **18.10.2026**. Nach dem Export landet der G-code in der Bibliothek und Grotti öffnet sich als **eigenes Fenster** (Chrome/Edge-App auf dem Desktop). Drucker, ACE-Platz und Einreihen wählst du dort.
## Einmal einrichten ## Einmal einrichten
@@ -11,11 +11,13 @@ Stand **17.10.2026**. Nach dem Export in OrcaSlicer landet der G-code in der Bib
```json ```json
{ {
"url": "http://127.0.0.1:8770", "url": "http://127.0.0.1:8770",
"token": "grt_…" "token": "grt_…",
"browser": "app"
} }
``` ```
`url` ist die Adresse, unter der du Grotti im Browser öffnest. `url` ist die Adresse, unter der du Grotti im Browser öffnest.
`browser`: `app` (Standard) = eigenes Desktop-Fenster; `tab` = klassischer Browser-Tab.
4. Im **Prozessprofil** (Druckqualität, z. B. „0.20mm Standard“): **Sonstiges → Nachbearbeitungsskripte**, eine Zeile: 4. Im **Prozessprofil** (Druckqualität, z. B. „0.20mm Standard“): **Sonstiges → Nachbearbeitungsskripte**, eine Zeile:
@@ -25,13 +27,31 @@ D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_send
Gilt für **OrcaSlicer** und **AnycubicSlicerNext**. Pfade anpassen. Der Slicer hängt den Export-Pfad selbst an. 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. ### Gerät-Reiter = Grotti (nur OrcaSlicer)
## Ablauf Damit unter **Gerät** die Grotti-Oberfläche erscheint:
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). 1. Orca → Drucker-Dropdown → **Physikalischer Drucker** (Verbindung/Stecker-Symbol).
2. Der Browser öffnet `/dateien?file=…` mit der neuen Datei. 2. **Host-Typ:** z. B. `Octo/Klipper` (egal, solange du nicht „Drucken“ an den Host schickst).
3. Drucker wählen, unter Optionen Farbe → ACE-Platz, **Jetzt** / **Wenn frei** / **Nächster freier**, dann **Starten**. 3. **Hostname / API:** leer lassen — oder nur eine Dummy-Adresse. **Nicht** die Moonraker-IP eines Kobras eintragen.
4. **Device UI / Web-UI-URL:** `http://127.0.0.1:8770/` (Grotti muss laufen).
5. Speichern → Reiter **Gerät** öffnen → einmal bei Grotti anmelden.
Wichtig: Den Orca-Button **Drucken / Upload zum Host** nicht nutzen — der spricht Moonraker/OctoPrint und umgeht ACE-Zuordnung in Grotti. Weiterhin **G-code exportieren** + Nachbearbeitungsskript.
In **AnycubicSlicerNext** steckt unter Gerät/Arbeitsfläche die Anycubic-Cloud — dort lässt sich Grotti so nicht einbinden.
## Ablauf (wichtig bei Anycubic)
**Nicht** „Remote Print“ / „Aufgabe starten“ / Cloud-Dialog — der Anycubic-Dialog lässt sich von Grotti nicht abschalten.
Stattdessen:
1. Slicen, dann **G-code exportieren** / **Platte schreiben** (Ziel: Slice-Ordner; Unterordner ok).
2. Das Nachbearbeitungsskript lädt die Datei nach Grotti und öffnet das Grotti-Fenster.
3. Drucker wählen, unter Optionen Farbe → ACE-Platz, **Dieser Drucker** oder **Nächster passender**, dann **Starten**.
Erstes App-Fenster: einmal bei Grotti anmelden (eigenes Profil unter `data/grotti-app-profile`). Die Session hält **30 Tage** und überlebt das Schließen des Fensters. Läuft das Fenster noch, startet Orca kein zweites Chrome (sonst leeres Profil → erneutes Login) — die neue Datei wird im offenen Fenster ausgewählt.
Test ohne Orca: Test ohne Orca:
@@ -43,11 +63,20 @@ D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_send
| Symptom | Check | | Symptom | Check |
| --- | --- | | --- | --- |
| Anycubic-Dialog trotz Grotti | Du hast Remote Print / Aufgabe starten genutzt → nur **G-code exportieren** |
| Meldungsfenster „Config fehlt“ | `data/orca-senden.json` anlegen | | Meldungsfenster „Config fehlt“ | `data/orca-senden.json` anlegen |
| „Grotti nicht erreichbar“ | Server läuft? `url` stimmt? | | „Grotti nicht erreichbar“ | Server läuft? `url` stimmt? |
| „Token fehlt oder stimmt nicht“ | Token neu anlegen, in der JSON eintragen | | „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 | | App-Fenster will Login | Einmal anmelden (Session 30 Tage). Profil: `data/grotti-app-profile`. Zweites Chrome-Fenster schließen, nur eines offen lassen. Grotti neu starten nach Update. |
| Skript läuft nicht | Export wirklich speichern; Zeile im **Druckerprofil**; Python-Pfad prüfen | | Browser-Tab statt Fenster | In `orca-senden.json` `"browser": "app"` setzen (oder Chrome/Edge fehlt) |
| Zwei Fenster / Tab hängt | Debounce (~25 s); erneuter Export aktualisiert den offenen Tab. 3D-Pfad lädt verzögert |
| ACE zeigt T0/T1 statt T1/T10 | Seite neu laden oder **Ordner neu lesen**; Deep-Link zieht `paint_index` nach |
| Skript läuft nicht | Export wirklich speichern; Zeile im **Prozessprofil**; Python-Pfad prüfen |
## Was Grotti nicht kann
- Den **Anycubic-Cloud-/Aufgabe-Dialog** unterdrücken (liegt im Slicer, nicht im Skript).
- Sich in Anycubics Reiter **Arbeitsfläche** einbetten. In **Orca** dagegen: Device-UI-URL → Grotti unter **Gerät** (siehe oben).
## Weiter ## Weiter
+33
View File
@@ -0,0 +1,33 @@
# Projekte
Stand **18.10.2026**. Arbeitsmappen unter `/projekte` — getrennt von Kunden-[Aufträgen](Anleitung-Auftraege.md).
## Wozu
Ein **Projekt** bündelt Konstruktion und Druckdateien in einem Ordner unter dem [Slice-Pfad](Anleitung-Einstellungen.md), z. B. `Projekte/2609_002`.
| | Projekt | Auftrag |
| --- | --- | --- |
| Zweck | Konstruktion + Slice-Dateien | Kunde, Menge, Lieferschein |
| Ort | Ordner auf der Platte | nur in der Datenbank |
| Seite | `/projekte` | `/auftraege` |
## Anlegen
**Neues Projekt** — Name, Status, optional Ordner (Standard: `Projekte/<Kurzname>`). Mit gesetztem Slice-Ordner legt Grotti den Ordner an.
G-code und 3MF in diesem Ordner (oder dorthin [verschoben](Anleitung-Dateien.md)) werden dem Projekt zugeordnet und erscheinen in der Druck-Bibliothek.
## Weitere Dateien
Im Projekt-Detail: Upload von STEP, STL, PDF, SolidWorks usw. — liegen im selben Ordner, erscheinen **nicht** in der Druck-Bibliothek. Download und Löschen pro Datei.
## Löschen
Projekt löschen (Zwei-Klick) entfernt den Datensatz und den Ordner **inkl. Inhalt** (`force`). Druckdateien in der Bibliothek verlieren nur die Projekt-Zuordnung, wenn sie woanders liegen; Dateien im Projektordner sind mit dem Ordner weg.
## Weiter
- [Dateien](Anleitung-Dateien.md)
- [Aufträge](Anleitung-Auftraege.md)
- [Einstellungen](Anleitung-Einstellungen.md)
+3 -2
View File
@@ -9,6 +9,7 @@ Stand **18.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet da
Pro Drucker siehst du: Pro Drucker siehst du:
- Zustand (Chip), Fertig-Uhrzeit, Fortschrittsbalken - Zustand (Chip), Fertig-Uhrzeit, Fortschrittsbalken
- Kleines Kamerabild (Snapshot ca. alle 3 s), wenn eine Webcam erreichbar ist — Klick öffnet das Cockpit
- Restzeit, Schicht, Laufzeit - Restzeit, Schicht, Laufzeit
- Temperaturen Düse/Bett (mit Ziel) - Temperaturen Düse/Bett (mit Ziel)
- Dateiname, bei ACE oft Farb-Swatches - Dateiname, bei ACE oft Farb-Swatches
@@ -34,11 +35,11 @@ URL mit Hash: `/#<host>` (z. B. nach Klick auf die Karte).
Inhalt: Inhalt:
- **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg. Modus Snapshot/Stream unter Einstellungen → Dieser Browser (oder Kameras-Seite) - **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg. Modus Snapshot/Stream unter Einstellungen → Dieser Browser (gilt nur im Cockpit; die Wand-Thumbs sind immer Snapshot)
- Status, Temps inkl. Bauraum wo verfügbar, Lüfter - 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) - **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 - **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 (dort Strom nach Grundlast/Düse/Bett) - **Verlauf** Temperatur und ED des aktuellen oder letzten Drucks; Link zum Druckbericht (dort Kosten: Filament + Grundlast/Düse/Bett)
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung) - **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
- **Bettnetz**-Heatmap, wenn vorhanden - **Bettnetz**-Heatmap, wenn vorhanden
+1 -1
View File
@@ -1,6 +1,6 @@
# Warteschlange und Ablauf # Warteschlange und Ablauf
Stand **18.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten). Stand **18.10.2026**. Aufträge in der Warteschlange (Dieser Drucker / Nächster passender), Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
![Warteschlange](../web/static/doku/warteschlange.png) ![Warteschlange](../web/static/doku/warteschlange.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

+1 -1
View File
@@ -6,7 +6,7 @@ Nach dem Druck kann Grotti auf der **Kobra 3** Bett und Düse abkühlen und die
## Quittierung vs. Schieben ## Quittierung vs. Schieben
- **Wand „Platte geräumt“:** Nur Quittierung. Tabelle `bed_status`, API `POST /api/v1/drucker/<host>/abraeumen` mit `confirm`. Fährt keine Bahn. `needs_clear` setzt der Watcher beim Live-Übergang Druckende/Abbruch/Fehler (nicht schon beim Start; nicht blind beim ersten Blick nach Neustart). Solange gesetzt, startet „wenn frei“ den nächsten Job nicht. - **Wand „Platte geräumt“:** Quittierung + optional Urteil. Tabelle `bed_status`, API `POST /api/v1/drucker/<host>/abraeumen` mit `confirm`. Fährt keine Bahn. Offener Live-Lauf wird als `completed` geschlossen (History von Moonraker oft ohne Endzeit). `needs_clear` setzt der Watcher beim Live-Übergang Druckende/Abbruch/Fehler (nicht schon beim Start; nicht blind beim ersten Blick nach Neustart). Solange gesetzt, startet „wenn frei“ den nächsten Job nicht.
- **Ablauf (Dateien):** Drucken → Abkühlen → Schieben → Tasten, dann der nächste Druck mit LeviQ. Eine Bestätigung beim Einreihen; Schieben läuft ohne zweite Rückfrage. Nach erfolgreichem Schieben ist die Platte automatisch quittiert. - **Ablauf (Dateien):** Drucken → Abkühlen → Schieben → Tasten, dann der nächste Druck mit LeviQ. Eine Bestätigung beim Einreihen; Schieben läuft ohne zweite Rückfrage. Nach erfolgreichem Schieben ist die Platte automatisch quittiert.
- **Normale Drucke** und „Nochmal“ schieben nicht — nach dem Druck muss man auf der Wand quittieren. - **Normale Drucke** und „Nochmal“ schieben nicht — nach dem Druck muss man auf der Wand quittieren.
+1 -1
View File
@@ -14,7 +14,7 @@ In der Bibliothek gespeichert:
| Feld | Bedeutung | | Feld | Bedeutung |
|------|-----------| |------|-----------|
| `filaments` | Typ/Farbe (`paint_info` oder `filament_type`/`filament_colour`) | | `filaments` | Typ/Farbe (`paint_info` oder `filament_type`/`filament_colour`) plus `profile` aus `filament_settings_id` (Sorte, z. B. Sunlu PLA Meta) |
| `weight_g` / `estimated_s` | Filamentgewicht und Druckzeit | | `weight_g` / `estimated_s` | Filamentgewicht und Druckzeit |
| `printer_family` | aus `printer_model` | | `printer_family` | aus `printer_model` |
| `slice_meta` | jsonb: `layer_height`, `layers`, `max_z_mm`, `nozzle_mm`, `temp_nozzle`, `temp_bed`, `slicer` | | `slice_meta` | jsonb: `layer_height`, `layers`, `max_z_mm`, `nozzle_mm`, `temp_nozzle`, `temp_bed`, `slicer` |
+1 -1
View File
@@ -1,6 +1,6 @@
# Grotti: Alltag und Oberfläche # Grotti: Alltag und Oberfläche
Stand **18.10.2026**, v3.2.0. Stand **18.10.2026**, v3.3.0.
## Design ## Design
+1 -1
View File
@@ -12,7 +12,7 @@ Seite `/einstellungen`: Druckernamen/Host/Familie/gesperrt (PostgreSQL `printers
## Druckwarteschlange „wenn frei“ ## Druckwarteschlange „wenn frei“
Dateien → „Wenn frei“ oder „Nächster freier“ → `when_free` in `POST /api/v1/senden`. Einträge in `print_queue`; Dispatcher startet, sobald der Host (oder ein freier Drucker) `standby`/`complete`/`cancelled` ist, Platte frei und nicht gesperrt. **Alle freien Drucker im selben Tick** (Upload/Start je Host im Worker parallel) — nicht einer alle ~8 s. Sidebar und Seite `/warteschlange`. Sofort-Senden bleibt über die Send-Queue (mehrere Worker). Hängender Fehler wird nur geräumt, wenn für den Host ein Auftrag wartet. Ablauf: Phase `starting` vor Upload; Timeout 10 min ohne `print_active`. Dateien → „Dieser Drucker“ (belegt) oder „Nächster passender“ → `when_free` in `POST /api/v1/senden`. Einträge in `print_queue`; Dispatcher startet, sobald der Host (oder ein freier Drucker) `standby`/`complete`/`cancelled` ist, Platte frei und nicht gesperrt. Material-Score: Katalog-Sorte aus `filament_settings_id` (streng), Farbe soft; bei ACE und leerer Map baut Grotti die Zuordnung aus Spulen. **Alle freien Drucker im selben Tick** (Upload/Start je Host im Worker parallel) — nicht einer alle ~8 s. Sidebar und Seite `/warteschlange`. Sofort-Senden bleibt über die Send-Queue (mehrere Worker). Hängender Fehler wird nur geräumt, wenn für den Host ein Auftrag wartet. Ablauf: Phase `starting` vor Upload; Timeout 10 min ohne `print_active`.
## Spulen ## Spulen
+171 -2
View File
@@ -73,13 +73,182 @@ def normalize_mapping(raw):
ams = int(item.get("ams_index")) ams = int(item.get("ams_index"))
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
if paint < 0 or ams < 0 or ams > 7 or paint in seen: # paint_index = Slicer-T (oft 0–15 bei Bambu), ams_index = ACE-Platz 0–7
if paint < 0 or paint > 63 or ams < 0 or ams > 7 or paint in seen:
continue continue
seen.add(paint) seen.add(paint)
out.append({"paint_index": paint, "ams_index": ams}) out.append({"paint_index": paint, "ams_index": ams})
return out return out
def filament_for_paint(filaments, paint):
"""Filament-Eintrag zur Slicer-T-Nummer; Fallback Array-Index für Alt-Daten."""
filaments = filaments or []
try:
paint = int(paint)
except (TypeError, ValueError):
return {}
for index, row in enumerate(filaments):
if not isinstance(row, dict):
continue
try:
key = int(row.get("paint_index"))
except (TypeError, ValueError):
key = index
if key == paint:
return row
if 0 <= paint < len(filaments) and isinstance(filaments[paint], dict):
return filaments[paint]
return {}
def densify_mapping(mapping):
"""Slicer-T mit Lücken (T1/T10) → dichte 0…n−1 für gklib/ACE.
gklib meldet bei zu hoher Filament-ID ``invalid filament id`` (10011700).
Rückgabe: (remap alt→neu, dichte Map). Identity → ({}, unverändert).
"""
mapping = normalize_mapping(mapping)
paints = sorted(item["paint_index"] for item in mapping)
if not paints or paints == list(range(len(paints))):
return {}, mapping
remap = {old: new for new, old in enumerate(paints)}
dense = [
{"paint_index": remap[item["paint_index"]], "ams_index": item["ams_index"]}
for item in mapping
]
return remap, dense
def remap_filaments(filaments, remap):
"""paint_index der Filament-Metadaten mit densify-Remap mitziehen."""
if not remap:
return list(filaments or [])
out = []
for row in filaments or []:
if not isinstance(row, dict):
continue
copy = dict(row)
try:
old = int(row.get("paint_index"))
except (TypeError, ValueError):
out.append(copy)
continue
if old in remap:
copy["paint_index"] = remap[old]
out.append(copy)
return out
def align_tools_for_ace(content, mapping, filaments=None):
"""G-code-T und ACE-Map auf dichte 0…n−1 bringen.
Maßgeblich sind die ``Tn`` im G-code. Die UI-Map darf echte Slicer-Indizes
(T1/T10) oder fälschlich kompakte 0/1 haben — ACE-Plätze werden in
Sortierreihenfolge der Map-Einträge den sortierten T-Nummern zugeordnet.
Sonst: ACM 0/1 + G-code T10 → 10011700 invalid filament id.
"""
from grotti.parse import tools_used_in_gcode
mapping = normalize_mapping(mapping)
used = sorted(tools_used_in_gcode(content))
if not mapping:
return content, mapping, list(filaments or []), {}
if not used:
remap, dense = densify_mapping(mapping)
new_content = rewrite_gcode_tools(content, remap) if remap else content
return new_content, dense, remap_filaments(filaments, remap), remap
if len(mapping) != len(used):
from grotti.errors import GrottiError
raise GrottiError(
"ACE-Zuordnung (%d Farben) passt nicht zum G-code (T%s). "
"Datei neu lesen und Zuordnung erneut setzen."
% (len(mapping), ", T".join(str(t) for t in used)),
"ace",
)
mapped_paints = {item["paint_index"] for item in mapping}
paints_match_gcode = mapped_paints == set(used)
if paints_match_gcode:
ams_by_tool = {item["paint_index"]: item["ams_index"] for item in mapping}
ams_order = [ams_by_tool[tool] for tool in used]
else:
# UI kompakt 0…n−1, G-code sparse — Reihenfolge der Map = Reihenfolge der Farben
ordered = sorted(mapping, key=lambda item: item["paint_index"])
ams_order = [item["ams_index"] for item in ordered]
remap = {old: new for new, old in enumerate(used)}
dense = [
{"paint_index": index, "ams_index": ams_order[index]}
for index in range(len(used))
]
source = list(filaments or [])
if paints_match_gcode:
new_fils = remap_filaments(source, remap)
else:
# Metadaten folgen der Map-Reihenfolge (0,1,…), nicht den G-code-T
ordered_paints = sorted(item["paint_index"] for item in mapping)
new_fils = []
for index, tool in enumerate(used):
row = filament_for_paint(source, ordered_paints[index]) or (
source[index] if index < len(source) and isinstance(source[index], dict) else {}
)
if not row:
row = filament_for_paint(source, tool) or {}
copy = dict(row) if isinstance(row, dict) else {}
copy["paint_index"] = index
new_fils.append(copy)
# Identity-Remap weglassen (schon T0…n−1)
if used == list(range(len(used))):
return content, dense, new_fils, {}
new_content = rewrite_gcode_tools(content, remap)
return new_content, dense, new_fils, remap
_TOOL_NUM_RE = re.compile(
rb"^(?P<prefix>(?:MMU_CHANGE_TOOL(?:_STANDALONE)?[^\n]*?\bTOOL=)|T)(?P<num>\d{1,2})\b",
re.I,
)
_REF_TOOLS_RE = re.compile(rb"^(;\s*referenced_tools\s*=\s*)(.+?)\s*$", re.I)
def rewrite_gcode_tools(content, remap):
"""Tn / TOOL=n / referenced_tools laut Remap umschreiben (T10 vor T1)."""
if not content or not remap:
return content
text = content if isinstance(content, (bytes, bytearray)) else str(content).encode("utf-8", "replace")
lines = text.splitlines(keepends=True)
out = []
for line in lines:
stripped = line[:-2] if line.endswith(b"\r\n") else (line[:-1] if line.endswith(b"\n") else line)
ending = line[len(stripped) :]
ref = _REF_TOOLS_RE.match(stripped)
if ref:
parts = []
for bit in ref.group(2).split(b","):
raw = bit.strip()
try:
number = int(raw)
except ValueError:
parts.append(raw)
continue
parts.append(str(remap.get(number, number)).encode("ascii"))
out.append(ref.group(1) + b",".join(parts) + ending)
continue
match = _TOOL_NUM_RE.match(stripped)
if match:
old = int(match.group("num"))
if old in remap:
new = str(remap[old]).encode("ascii")
stripped = stripped[: match.start("num")] + new + stripped[match.end("num") :]
out.append(stripped + ending)
return b"".join(out)
def build_acm(filaments, mapping, slots): def build_acm(filaments, mapping, slots):
filaments = filaments or [] filaments = filaments or []
slots = slots or [] slots = slots or []
@@ -88,7 +257,7 @@ def build_acm(filaments, mapping, slots):
for item in normalize_mapping(mapping): for item in normalize_mapping(mapping):
paint = item["paint_index"] paint = item["paint_index"]
ams = item["ams_index"] ams = item["ams_index"]
filament = filaments[paint] if 0 <= paint < len(filaments) else {} filament = filament_for_paint(filaments, paint)
slot = slot_by_index.get(ams) or {} slot = slot_by_index.get(ams) or {}
paint_color = color_channels(filament.get("color")) or [0, 0, 0] paint_color = color_channels(filament.get("color")) or [0, 0, 0]
ams_color = color_channels(slot.get("color")) or paint_color ams_color = color_channels(slot.get("color")) or paint_color
+53 -13
View File
@@ -57,25 +57,47 @@ def unix_to_dt(value):
def choose_spool(conn, host, material, hinted_id, ended_at=None): def choose_spool(conn, host, material, hinted_id, ended_at=None):
"""Spule für History-Abzug. Ohne Hint nur Rollen, die vor Druckende existierten.""" """Spule für History-Abzug. Ohne Hint nur Rollen, die vor Druckende existierten."""
at_host = [
row for row in store.spools_for_printer(conn, host)
if _spool_valid_for_end(row, ended_at)
]
if hinted_id:
row = store.get_spool_row(conn, hinted_id)
if row and not _spool_valid_for_end(row, ended_at):
return None
# Hint nur nutzen, wenn die Rolle noch am Drucker liegt — sonst Rollenwechsel
if row and (row.get("printer_host") or "") == (host or ""):
return row
if row and not at_host:
# Rolle schon im Regal, kein Nachfolger am Host → Hint behalten (alte Jobs)
return row
if len(at_host) == 1:
return at_host[0]
if material:
same = [row for row in at_host if (row["material_type"] or "").lower() == material.lower()]
if len(same) == 1:
return same[0]
if hinted_id: if hinted_id:
row = store.get_spool_row(conn, hinted_id) row = store.get_spool_row(conn, hinted_id)
if row and _spool_valid_for_end(row, ended_at): if row and _spool_valid_for_end(row, ended_at):
return row return row
if row and not _spool_valid_for_end(row, ended_at):
return None
rows = [
row for row in store.spools_for_printer(conn, host)
if _spool_valid_for_end(row, ended_at)
]
if len(rows) == 1:
return rows[0]
if material:
same = [row for row in rows if (row["material_type"] or "").lower() == material.lower()]
if len(same) == 1:
return same[0]
return None return None
def resolve_tracking_spool(conn, host, hinted_id, ace_map=None):
"""Spule für Live-Tracking: am Host liegende Rolle schlägt veralteten Send-Hint."""
current = resolve_spools_at_host(conn, host, ace_map=ace_map)
if len(current) == 1:
return current[0]["id"]
if hinted_id:
row = store.get_spool_row(conn, hinted_id)
if row and (row.get("printer_host") or "") == (host or ""):
return hinted_id
if current:
return current[0]["id"]
return hinted_id
def _spool_valid_for_end(spool, ended_at): def _spool_valid_for_end(spool, ended_at):
"""Verhindert Abzug alter Moonraker-Jobs auf neu eingelegte Spulen.""" """Verhindert Abzug alter Moonraker-Jobs auf neu eingelegte Spulen."""
if ended_at is None or not spool: if ended_at is None or not spool:
@@ -544,12 +566,30 @@ def send_file(
) )
# CJK/Unicode im Namen → Rinkhals 10115 not found (Upload ≠ Start-Name) # CJK/Unicode im Namen → Rinkhals 10115 not found (Upload ≠ Start-Name)
filename = moonraker.safe_upload_name(filename) filename = moonraker.safe_upload_name(filename)
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"): if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"):
if slots and mapping:
# Immer an G-code-Tn ausrichten (auch wenn UI fälschlich 0/1 schickt)
content, mapping, filaments, remap = acm.align_tools_for_ace(
content, mapping, filaments
)
if remap:
progress(
"ace",
32,
"Slicer-T verdichtet (%s)…"
% ", ".join("T%s→T%s" % pair for pair in sorted(remap.items())),
)
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
content = prepare_rinkhals_gcode(content, tools=ace_tools) content = prepare_rinkhals_gcode(content, tools=ace_tools)
if keep_motors: if keep_motors:
# Slice-Ende `M84` → sofort unhomed; Abräumen braucht die Stepper. # Slice-Ende `M84` → sofort unhomed; Abräumen braucht die Stepper.
content = eject.strip_end_motor_disable(content) content = eject.strip_end_motor_disable(content)
else:
if slots and mapping:
_, mapping, filaments, _ = acm.align_tools_for_ace(
b"", mapping, filaments
)
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
size_mb = len(content) / (1024 * 1024) size_mb = len(content) / (1024 * 1024)
def upload_progress(sent, total): def upload_progress(sent, total):
+259 -22
View File
@@ -59,10 +59,55 @@ def sync_watch(conn, cfg, force=False):
except (OSError, zipfile.BadZipFile): except (OSError, zipfile.BadZipFile):
continue continue
store.forget_missing_watch(conn, seen) store.forget_missing_watch(conn, seen)
link_watch_files_to_projects(conn, cfg)
_LAST_WATCH_SYNC = now _LAST_WATCH_SYNC = now
return {"gelesen": len(seen), "hinweis": ""} return {"gelesen": len(seen), "hinweis": ""}
def _apply_meta(conn, file_id, row, meta):
store.update_file(
conn,
file_id,
{
"printer_family": meta.get("printer_family") or row.get("printer_family"),
"filaments": meta.get("filaments") or [],
"weight_g": meta.get("weight_g") or 0,
"estimated_s": meta.get("estimated_s") or 0,
"thumb_entry": meta.get("thumb_entry") or "",
"kind": meta.get("kind") or row.get("kind"),
"plate": meta.get("plate") or "",
"slice_meta": meta.get("slice_meta") or {},
},
)
def refresh_one_file(conn, file_id, watch_dir=None):
"""Eine Datei neu parsen (paint_index / ACE). None wenn nicht gefunden."""
row = store.get_file_row(conn, file_id)
if not row:
return None
stored = row.get("stored_path") or ""
path = Path(stored) if stored else None
if path is not None and path.is_file():
try:
meta = meta_for(path)
_apply_meta(conn, file_id, row, meta)
row = store.get_file_row(conn, file_id) or row
except (OSError, zipfile.BadZipFile):
pass
return store.file_out(row, watch_dir=watch_dir)
def filaments_need_paint_index(filaments):
rows = filaments if isinstance(filaments, list) else []
if len(rows) < 2:
return False
for item in rows:
if not isinstance(item, dict) or "paint_index" not in item:
return True
return False
def refresh_file_meta(conn, force=False): def refresh_file_meta(conn, force=False):
"""Metadaten bestehender Dateien neu lesen (z. B. paint_info → filaments).""" """Metadaten bestehender Dateien neu lesen (z. B. paint_info → filaments)."""
if not force: if not force:
@@ -80,20 +125,7 @@ def refresh_file_meta(conn, force=False):
meta = meta_for(path) meta = meta_for(path)
except (OSError, zipfile.BadZipFile): except (OSError, zipfile.BadZipFile):
continue continue
store.update_file( _apply_meta(conn, row["id"], row, meta)
conn,
row["id"],
{
"printer_family": meta.get("printer_family") or row.get("printer_family"),
"filaments": meta.get("filaments") or [],
"weight_g": meta.get("weight_g") or 0,
"estimated_s": meta.get("estimated_s") or 0,
"thumb_entry": meta.get("thumb_entry") or "",
"kind": meta.get("kind") or row.get("kind"),
"plate": meta.get("plate") or "",
"slice_meta": meta.get("slice_meta") or {},
},
)
updated += 1 updated += 1
return updated return updated
@@ -213,27 +245,39 @@ def delete_folder(cfg, rel):
return folder return folder
def _link_file_to_project_folder(conn, file_id, stored_path, watch_dir):
"""Wenn Pfad unter einem Projekt-Ordner liegt → project_id setzen."""
folder = store.file_folder(stored_path, "watch", watch_dir)
if not folder:
return
for row in store.list_project_folders(conn):
base = (row.get("folder") or "").strip().replace("\\", "/").strip("/")
if not base:
continue
if folder == base or folder.startswith(base + "/"):
store.set_file_project(conn, file_id, row["id"])
return
def move_file(conn, cfg, file_id, folder): def move_file(conn, cfg, file_id, folder):
"""Watch-Datei in Unterordner (oder Wurzel) verschieben; ID bleibt.""" """Watch- oder Upload-Datei in Slice-Unterordner (oder Wurzel); Upload wird Watch."""
root = require_watch_dir(cfg) root = require_watch_dir(cfg)
folder = normalize_folder(folder) folder = normalize_folder(folder)
row = store.get_file_row(conn, file_id) row = store.get_file_row(conn, file_id)
if not row: if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404) raise GrottiError("Die Datei fehlt.", "datei", 404)
if row.get("source") != "watch": source = row.get("source") or ""
raise GrottiError( if source not in ("watch", "upload"):
"Nur Dateien aus dem Slice-Ordner lassen sich verschieben.", raise GrottiError("Diese Datei lässt sich nicht verschieben.", "datei")
"watch",
)
src = Path(row.get("stored_path") or "") src = Path(row.get("stored_path") or "")
if not src.is_file(): if not src.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404) raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
dest_dir = path_under_watch(root, folder) dest_dir = path_under_watch(root, folder)
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / src.name dest = dest_dir / src.name
if dest.resolve() == src.resolve(): if dest.resolve() == src.resolve() and source == "watch":
return store.file_out(row, watch_dir=root) return store.file_out(row, watch_dir=root)
if dest.exists(): if dest.exists() and dest.resolve() != src.resolve():
dest = unique_path(dest_dir, src.name) dest = unique_path(dest_dir, src.name)
try: try:
shutil.move(str(src), str(dest)) shutil.move(str(src), str(dest))
@@ -247,11 +291,204 @@ def move_file(conn, cfg, file_id, folder):
name=dest.name, name=dest.name,
size_bytes=stat.st_size, size_bytes=stat.st_size,
mtime=stat.st_mtime, mtime=stat.st_mtime,
source="watch",
) )
_link_file_to_project_folder(conn, file_id, str(dest.resolve()), root)
row = store.get_file_row(conn, file_id) row = store.get_file_row(conn, file_id)
return store.file_out(row, watch_dir=root) return store.file_out(row, watch_dir=root)
def path_allowed_for_delete(stored_path, cfg):
"""Nur Dateien unter Slice-Ordner oder data/eingang."""
try:
resolved = Path(stored_path).resolve()
except (OSError, ValueError):
return False
roots = []
watch = cfg.get("watch_dir")
if watch:
roots.append(Path(watch).resolve())
data_dir = cfg.get("data_dir")
if data_dir:
roots.append((Path(data_dir) / "eingang").resolve())
for root in roots:
try:
if resolved == root or root in resolved.parents:
return True
except OSError:
continue
return False
def delete_library_file(conn, cfg, file_id):
"""Bibliotheksdatei löschen (Upload oder Watch) — DB + Datei auf der Platte."""
row = store.get_file_row(conn, file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
stored = row.get("stored_path") or ""
if stored and not path_allowed_for_delete(stored, cfg):
raise GrottiError("Löschen außerhalb von Slice-Ordner oder Eingang ist nicht erlaubt.", "datei")
store.delete_file(conn, file_id)
if stored:
Path(stored).unlink(missing_ok=True)
return row
_PRINT_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
def is_print_filename(name):
low = (name or "").lower()
return any(low.endswith(suf) for suf in _PRINT_SUFFIXES)
def project_slug(name):
"""Ordner-sichere Kurzform aus Projektnamen."""
text = re.sub(r"[^\w\- äöüÄÖÜß.]+", "-", (name or "").strip(), flags=re.UNICODE)
text = re.sub(r"\s+", "-", text)
text = re.sub(r"-{2,}", "-", text).strip("-.")
text = text[:60] or "projekt"
return normalize_folder(text)
def ensure_project_folder(cfg, folder_rel):
"""Projektordner unter watch_dir anlegen; gibt normalisierten Relativpfad zurück."""
root = require_watch_dir(cfg)
folder = normalize_folder(folder_rel)
if not folder:
raise GrottiError("Projektordner fehlt.", "ordner")
path_under_watch(root, folder).mkdir(parents=True, exist_ok=True)
return folder
def default_project_folder(name):
return "Projekte/" + project_slug(name)
def link_watch_files_to_projects(conn, cfg):
"""Watch-Dateien in Projektordnern dem passenden Projekt zuordnen."""
watch = cfg.get("watch_dir")
if not watch:
return 0
projects = store.list_project_folders(conn)
if not projects:
return 0
linked = 0
for row in store.list_file_rows(conn):
if (row.get("source") or "") != "watch":
continue
folder = store.file_folder(row.get("stored_path"), "watch", watch)
if not folder:
continue
for proj in projects:
base = (proj.get("folder") or "").strip().replace("\\", "/").strip("/")
if not base:
continue
if folder == base or folder.startswith(base + "/"):
if row.get("project_id") != proj["id"]:
store.set_file_project(conn, row["id"], proj["id"])
linked += 1
break
return linked
def list_project_assets(cfg, folder_rel):
"""Nicht-Druckdateien im Projektordner (flach + Unterordner)."""
root = require_watch_dir(cfg)
folder = normalize_folder(folder_rel)
if not folder:
return []
base = path_under_watch(root, folder)
if not base.is_dir():
return []
items = []
for path in sorted(base.rglob("*")):
if not path.is_file() or skip_path(path, cfg["data_dir"]):
continue
if is_print_filename(path.name):
continue
try:
rel = path.resolve().relative_to(base).as_posix()
except ValueError:
continue
stat = path.stat()
items.append({
"name": path.name,
"rel": rel,
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
})
return items
def resolve_project_asset(cfg, folder_rel, rel_name):
"""Sicheren Pfad zu einer Asset-Datei im Projektordner."""
root = require_watch_dir(cfg)
folder = normalize_folder(folder_rel)
if not folder:
raise GrottiError("Projektordner fehlt.", "ordner")
base = path_under_watch(root, folder)
rel = str(rel_name or "").replace("\\", "/").strip("/")
if not rel or ".." in rel.split("/"):
raise GrottiError("Ungültiger Dateiname.", "datei")
target = (base / rel).resolve()
if base not in target.parents and target != base:
raise GrottiError("Pfad außerhalb des Projektordners.", "ordner")
if not target.is_file():
raise GrottiError("Die Datei fehlt.", "datei", 404)
return target
def save_project_asset(cfg, folder_rel, filename, content):
root = require_watch_dir(cfg)
folder = normalize_folder(folder_rel)
if not folder:
raise GrottiError("Projektordner fehlt.", "ordner")
dest_dir = path_under_watch(root, folder)
dest_dir.mkdir(parents=True, exist_ok=True)
path = unique_path(dest_dir, filename)
path.write_bytes(content)
return {
"name": path.name,
"rel": path.name,
"size_bytes": path.stat().st_size,
"mtime": path.stat().st_mtime,
}
def delete_project_asset(cfg, folder_rel, rel_name):
path = resolve_project_asset(cfg, folder_rel, rel_name)
path.unlink(missing_ok=True)
return True
def delete_project_disk_folder(cfg, folder_rel, force=False):
"""Projektordner auf der Platte entfernen (leer oder force inkl. Inhalt)."""
if not folder_rel:
return
try:
root = require_watch_dir(cfg)
except GrottiError:
return
folder = normalize_folder(folder_rel)
if not folder:
return
target = path_under_watch(root, folder)
if not target.is_dir():
return
if force:
shutil.rmtree(target, ignore_errors=True)
return
try:
next(target.iterdir())
raise GrottiError(
"Projektordner ist nicht leer — mit Bestätigung inkl. Inhalt löschen.",
"ordner",
)
except StopIteration:
target.rmdir()
def save_upload(conn, filename, content, cfg, project_id=None): def save_upload(conn, filename, content, cfg, project_id=None):
directory = cfg["data_dir"] / "eingang" directory = cfg["data_dir"] / "eingang"
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
+2
View File
@@ -0,0 +1,2 @@
-- Leere/alte Spulen aus der Alltagsliste nehmen
ALTER TABLE spools ADD COLUMN IF NOT EXISTS archived_at timestamptz;
+2
View File
@@ -0,0 +1,2 @@
-- Projekt-Arbeitsmappe: Relativordner unter dem Slice-Pfad
ALTER TABLE projects ADD COLUMN IF NOT EXISTS folder text NOT NULL DEFAULT '';
+80 -9
View File
@@ -91,40 +91,111 @@ def color_hex(channels):
return "#%02x%02x%02x" % tuple(rgb) return "#%02x%02x%02x" % tuple(rgb)
def profile_name(raw):
"""Orca-Profil → Katalogname: `Sunlu PLA Meta @Kobra 3` → `Sunlu PLA Meta`."""
text = (raw or "").strip().strip('"').strip("'").strip()
if " @" in text:
text = text.rsplit(" @", 1)[0].strip()
return text
def filament_profiles(comments):
"""Profilnamen aus `; filament_settings_id`, Index = Slicer-Slot / paint_index."""
raw = (comments or {}).get("filament_settings_id") or ""
if not raw.strip():
fallback = profile_name((comments or {}).get("default_filament_profile") or "")
return [fallback] if fallback else []
return [profile_name(part) for part in raw.split(";") if profile_name(part)]
def attach_profiles(filaments, comments):
"""Hängt `profile` (Sorte) an Filament-Einträge anhand des paint_index."""
profiles = filament_profiles(comments)
if not filaments:
return filaments
if not profiles:
return filaments
for index, row in enumerate(filaments):
if not isinstance(row, dict):
continue
try:
paint = int(row.get("paint_index"))
except (TypeError, ValueError):
paint = index
name = ""
if 0 <= paint < len(profiles):
name = profiles[paint]
elif len(filaments) == 1 and len(profiles) == 1:
name = profiles[0]
if name:
row["profile"] = name
return filaments
def filaments_from_paint_info(raw): def filaments_from_paint_info(raw):
"""Farben aus `; paint_info` — inkl. originalem `paint_index` (T-Nummer).
Bambu/Anycubic nutzen oft Lücken (T1 + T10 bei 16 Slots). Die Liste bleibt
kompakt (nur belegte Farben), aber jeder Eintrag trägt den Slicer-Index,
damit `.acm` und ACE-Map denselben Wert wie `Tn` im G-code schreiben.
"""
try: try:
items = json.loads(raw or "") items = json.loads(raw or "")
except (TypeError, json.JSONDecodeError): except (TypeError, json.JSONDecodeError):
return [] return []
if not isinstance(items, list): if not isinstance(items, list):
return [] return []
found = [] found = {}
next_fallback = 0
for item in items: for item in items:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
try: try:
index = int(item.get("paint_index")) index = int(item.get("paint_index"))
except (TypeError, ValueError): except (TypeError, ValueError):
index = len(found) index = next_fallback
while len(found) <= index: if index < 0:
found.append({"type": "", "color": ""}) continue
found[index] = { next_fallback = max(next_fallback, index + 1)
row = {
"type": str(item.get("material_type") or "").strip(), "type": str(item.get("material_type") or "").strip(),
"color": color_hex(item.get("paint_color")), "color": color_hex(item.get("paint_color")),
"paint_index": index,
} }
return [row for row in found if row["type"] or row["color"]] if row["type"] or row["color"]:
found[index] = row
return [found[key] for key in sorted(found)]
def filaments_of(comments): def filaments_of(comments):
painted = filaments_from_paint_info(comments.get("paint_info")) painted = filaments_from_paint_info(comments.get("paint_info"))
if painted: if painted:
return painted return attach_profiles(painted, comments)
types = [part.strip() for part in (comments.get("filament_type") or "").split(";") if part.strip()] types = [part.strip() for part in (comments.get("filament_type") or "").split(";") if part.strip()]
colors = [part.strip() for part in (comments.get("filament_colour") or comments.get("filament_color") or "").split(";")] colors = [part.strip() for part in (comments.get("filament_colour") or comments.get("filament_color") or "").split(";")]
return [ rows = [
{"type": kind, "color": colors[index] if index < len(colors) else ""} {
"type": kind,
"color": colors[index] if index < len(colors) else "",
"paint_index": index,
}
for index, kind in enumerate(types) for index, kind in enumerate(types)
] ]
return attach_profiles(rows, comments)
def tools_used_in_gcode(content):
"""T-/MMU-Werkzeuge aus dem G-code (wie Rinkhals mmu_ace_metadata)."""
if not content:
return set()
if isinstance(content, str):
content = content.encode("utf-8", "replace")
found = set()
for line in _gcode_lines(content):
match = _TOOL_RE.match(line)
if match:
found.add(int(match.group(1)))
return found
def _gcode_lines(content): def _gcode_lines(content):
+49 -1
View File
@@ -10,7 +10,7 @@ import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from grotti import eject, jobs, moonraker, notify, store from grotti import acm, eject, jobs, moonraker, notify, store
from grotti.db import connect from grotti.db import connect
from grotti.errors import GrottiError from grotti.errors import GrottiError
from grotti import time_window from grotti import time_window
@@ -306,6 +306,11 @@ class PrintQueueDispatcher:
if (waiting.get("message") or "") != WINDOW_WAIT_MSG: if (waiting.get("message") or "") != WINDOW_WAIT_MSG:
store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG}) store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG})
continue continue
# Nächster passender / leere Map: ACE aus Spulen bauen oder warten
if not _has_ablauf(waiting):
waiting = self._ensure_ace_map(conn, waiting, host, live)
if not waiting:
continue
claimed = store.claim_print_queue(conn, waiting["id"], host) claimed = store.claim_print_queue(conn, waiting["id"], host)
if not claimed: if not claimed:
continue continue
@@ -317,6 +322,49 @@ class PrintQueueDispatcher:
self._start_simple(conn, claimed, printer) self._start_simple(conn, claimed, printer)
conn.commit() conn.commit()
def _ensure_ace_map(self, conn, waiting, host, live):
"""Bei ACE-Drucker und leerer Map: aus Spulen zuordnen oder Wartegrund setzen."""
slots = (live or {}).get("slots") or []
if not slots:
return waiting
ace = waiting.get("ace_map") or []
if isinstance(ace, str):
import json
try:
ace = json.loads(ace)
except json.JSONDecodeError:
ace = []
if acm.normalize_mapping(ace if isinstance(ace, list) else []):
return waiting
file_id = waiting.get("file_id")
if not file_id:
return waiting
mapping, reason = store.auto_ace_map(conn, host, file_id)
if reason:
if (waiting.get("wait_reason") or "") != reason or (waiting.get("message") or "") != reason:
store.update_print_queue(
conn,
waiting["id"],
{"wait_reason": reason, "message": reason},
)
return None
if not mapping:
# ACE am Drucker, aber keine ACE-Spulen in Grotti → nicht raten
reason = "ACE-Zuordnung fehlt (keine passende Spule)"
if (waiting.get("wait_reason") or "") != reason:
store.update_print_queue(
conn,
waiting["id"],
{"wait_reason": reason, "message": reason},
)
return None
store.update_print_queue(conn, waiting["id"], {"ace_map": mapping, "wait_reason": "", "message": ""})
waiting = dict(waiting)
waiting["ace_map"] = mapping
waiting["wait_reason"] = ""
return waiting
def _start_simple(self, conn, item, printer): def _start_simple(self, conn, item, printer):
"""Upload/Start im Worker — mehrere freie Drucker parallel.""" """Upload/Start im Worker — mehrere freie Drucker parallel."""
host = printer["host"] host = printer["host"]
+4
View File
@@ -7,9 +7,12 @@ CREATE TABLE IF NOT EXISTS projects (
notes text NOT NULL DEFAULT '', notes text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'idee', status text NOT NULL DEFAULT 'idee',
quote jsonb NOT NULL DEFAULT '{}'::jsonb, quote jsonb NOT NULL DEFAULT '{}'::jsonb,
folder text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now() created_at timestamptz NOT NULL DEFAULT now()
); );
ALTER TABLE projects ADD COLUMN IF NOT EXISTS folder text NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS spools ( CREATE TABLE IF NOT EXISTS spools (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL, name text NOT NULL,
@@ -123,6 +126,7 @@ ALTER TABLE spools ADD COLUMN IF NOT EXISTS material_id bigint REFERENCES materi
ALTER TABLE spools ADD COLUMN IF NOT EXISTS chargen_nr text; ALTER TABLE spools ADD COLUMN IF NOT EXISTS chargen_nr text;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS hersteller_charge text NOT NULL DEFAULT ''; ALTER TABLE spools ADD COLUMN IF NOT EXISTS hersteller_charge text NOT NULL DEFAULT '';
ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text; ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS archived_at timestamptz;
CREATE UNIQUE INDEX IF NOT EXISTS spools_chargen_nr ON spools (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> ''; CREATE UNIQUE INDEX IF NOT EXISTS spools_chargen_nr ON spools (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
CREATE UNIQUE INDEX IF NOT EXISTS spools_code ON spools (code) WHERE code IS NOT NULL AND code <> ''; CREATE UNIQUE INDEX IF NOT EXISTS spools_code ON spools (code) WHERE code IS NOT NULL AND code <> '';
+283 -37
View File
@@ -20,6 +20,7 @@ def project_out(row, files=None, file_count=None):
"notes": row["notes"], "notes": row["notes"],
"status": row["status"], "status": row["status"],
"quote": row["quote"] or {}, "quote": row["quote"] or {},
"folder": (row.get("folder") or "").strip(),
"created_at": iso(row["created_at"]), "created_at": iso(row["created_at"]),
} }
if files is not None: if files is not None:
@@ -76,6 +77,7 @@ def spool_out(row):
colors = row.get("colors") or [] colors = row.get("colors") or []
if isinstance(colors, str): if isinstance(colors, str):
colors = json.loads(colors) colors = json.loads(colors)
archived = row.get("archived_at")
return { return {
"id": row["id"], "id": row["id"],
"name": row["name"], "name": row["name"],
@@ -93,6 +95,8 @@ def spool_out(row):
"chargen_nr": row.get("chargen_nr") or "", "chargen_nr": row.get("chargen_nr") or "",
"hersteller_charge": row.get("hersteller_charge") or "", "hersteller_charge": row.get("hersteller_charge") or "",
"code": row.get("code") or "", "code": row.get("code") or "",
"archived_at": archived.isoformat() if archived and hasattr(archived, "isoformat") else (archived or None),
"archived": bool(archived),
} }
@@ -209,14 +213,14 @@ def get_project(conn, project_id):
return project_out(row, files=[file_out(item) for item in files]) return project_out(row, files=[file_out(item) for item in files])
def create_project(conn, name, notes, status): def create_project(conn, name, notes, status, folder=""):
row = conn.execute( row = conn.execute(
""" """
INSERT INTO projects (name, notes, status) INSERT INTO projects (name, notes, status, folder)
VALUES (%s, %s, %s) VALUES (%s, %s, %s, %s)
RETURNING * RETURNING *
""", """,
(name, notes, status), (name, notes, status, (folder or "").strip()),
).fetchone() ).fetchone()
return project_out(row, files=[], file_count=0) return project_out(row, files=[], file_count=0)
@@ -224,10 +228,10 @@ def create_project(conn, name, notes, status):
def update_project(conn, project_id, fields): def update_project(conn, project_id, fields):
sets = [] sets = []
values = [] values = []
for key in ("name", "notes", "status"): for key in ("name", "notes", "status", "folder"):
if key in fields: if key in fields:
sets.append("%s = %%s" % key) sets.append("%s = %%s" % key)
values.append(fields[key]) values.append(fields[key] if key != "folder" else (fields[key] or "").strip())
if "quote" in fields: if "quote" in fields:
sets.append("quote = %s") sets.append("quote = %s")
values.append(Json(fields["quote"])) values.append(Json(fields["quote"]))
@@ -243,6 +247,24 @@ def update_project(conn, project_id, fields):
return get_project(conn, project_id) return get_project(conn, project_id)
def list_project_folders(conn):
"""id + folder für Sync (nur Projekte mit Ordner)."""
return conn.execute(
"""
SELECT id, folder FROM projects
WHERE coalesce(folder, '') <> ''
ORDER BY length(folder) DESC, folder
"""
).fetchall()
def set_file_project(conn, file_id, project_id):
conn.execute(
"UPDATE files SET project_id = %s WHERE id = %s",
(project_id, file_id),
)
def delete_project(conn, project_id): def delete_project(conn, project_id):
row = conn.execute("DELETE FROM projects WHERE id = %s RETURNING id", (project_id,)).fetchone() row = conn.execute("DELETE FROM projects WHERE id = %s RETURNING id", (project_id,)).fetchone()
return bool(row) return bool(row)
@@ -341,7 +363,7 @@ def update_file(conn, file_id, fields, watch_dir=None):
return file_out(row, watch_dir=watch_dir) 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): def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None, mtime=None, source=None):
"""Pfad nach Verschieben auf der Platte — ID und Zuordnungen bleiben.""" """Pfad nach Verschieben auf der Platte — ID und Zuordnungen bleiben."""
sets = ["stored_path = %s"] sets = ["stored_path = %s"]
values = [stored_path] values = [stored_path]
@@ -354,6 +376,9 @@ def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None,
if mtime is not None: if mtime is not None:
sets.append("mtime = %s") sets.append("mtime = %s")
values.append(mtime) values.append(mtime)
if source is not None:
sets.append("source = %s")
values.append(source)
values.append(file_id) values.append(file_id)
return conn.execute( return conn.execute(
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets), "UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
@@ -440,6 +465,14 @@ def update_spool(conn, spool_id, fields):
if key in fields: if key in fields:
sets.append("%s = %%s" % key) sets.append("%s = %%s" % key)
values.append(Json(fields[key]) if key == "colors" else fields[key]) values.append(Json(fields[key]) if key == "colors" else fields[key])
if "archived_at" in fields:
if fields["archived_at"] is None or fields["archived_at"] is False:
sets.append("archived_at = NULL")
elif fields["archived_at"] is True or fields["archived_at"] == "now":
sets.append("archived_at = now()")
else:
sets.append("archived_at = %s")
values.append(fields["archived_at"])
if not sets: if not sets:
row = get_spool_row(conn, spool_id) row = get_spool_row(conn, spool_id)
return spool_out(row) if row else None return spool_out(row) if row else None
@@ -451,6 +484,23 @@ def update_spool(conn, spool_id, fields):
return spool_out(row) if row else None return spool_out(row) if row else None
def mark_spool_empty(conn, spool_id):
"""Rest auf 0, vom Drucker ins Regal, archivieren."""
row = conn.execute(
"""
UPDATE spools
SET remaining_g = 0,
printer_host = '',
ace_slot = NULL,
archived_at = COALESCE(archived_at, now())
WHERE id = %s
RETURNING *
""",
(spool_id,),
).fetchone()
return spool_out(row) if row else None
def material_out(row): def material_out(row):
s1 = row["volumetric_s1"] s1 = row["volumetric_s1"]
return { return {
@@ -1278,6 +1328,27 @@ def find_live_run(conn, host, filename=""):
).fetchone() ).fetchone()
def find_active_run(conn, host, filename=""):
"""Offener Lauf am Host: Live bevorzugt, sonst offener History-Job (Pause/Resume)."""
return find_live_run(conn, host, filename) or find_open_history_run(conn, host, filename)
def update_send_spool(conn, host, filename, spool_id):
"""Spule am letzten Send dieser Datei korrigieren (Rollenwechsel während Druck)."""
host = (host or "").strip()
if not host or not spool_id:
return None
sent = latest_send(conn, host, filename or "")
if not sent:
return None
if sent.get("spool_id") == spool_id:
return sent
return conn.execute(
"UPDATE sends SET spool_id = %s WHERE id = %s RETURNING *",
(spool_id, sent["id"]),
).fetchone()
def ensure_live_run(conn, host, printer_name, filename, spool_id=None): def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
"""Aktiven Lauf: History-Job bevorzugen, sonst Live — nie parallel beide offen.""" """Aktiven Lauf: History-Job bevorzugen, sonst Live — nie parallel beide offen."""
hist = find_open_history_run(conn, host, filename) hist = find_open_history_run(conn, host, filename)
@@ -1286,7 +1357,7 @@ def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
if live: if live:
adopt_live_run(conn, host, filename or hist.get("filename") or "", hist["id"]) adopt_live_run(conn, host, filename or hist.get("filename") or "", hist["id"])
hist = get_run_row(conn, hist["id"]) or hist hist = get_run_row(conn, hist["id"]) or hist
if spool_id and not hist.get("spool_id"): if spool_id and hist.get("spool_id") != spool_id:
conn.execute("UPDATE runs SET spool_id = %s WHERE id = %s", (spool_id, hist["id"])) conn.execute("UPDATE runs SET spool_id = %s WHERE id = %s", (spool_id, hist["id"]))
hist = get_run_row(conn, hist["id"]) or hist hist = get_run_row(conn, hist["id"]) or hist
return hist return hist
@@ -1298,6 +1369,9 @@ def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
(filename, row["id"]), (filename, row["id"]),
) )
row = get_run_row(conn, row["id"]) row = get_run_row(conn, row["id"])
if spool_id and row and row.get("spool_id") != spool_id:
conn.execute("UPDATE runs SET spool_id = %s WHERE id = %s", (spool_id, row["id"]))
row = get_run_row(conn, row["id"])
return row return row
job_id = "live:%s:%s" % (host, datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")) job_id = "live:%s:%s" % (host, datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S"))
return upsert_run(conn, { return upsert_run(conn, {
@@ -1349,6 +1423,30 @@ def abandon_live_runs(conn, host, keep_id=None):
return len(rows) return len(rows)
def complete_open_run(conn, run_id, state="completed"):
"""Offenen Lauf als beendet markieren (Abräumen / Druckende ohne History)."""
state = (state or "completed").strip() or "completed"
if state not in ("completed", "cancelled", "error", "interrupted"):
state = "completed"
return conn.execute(
"""
UPDATE runs
SET state = %s,
ended_at = COALESCE(ended_at, now()),
duration_s = CASE
WHEN COALESCE(duration_s, 0) > 0 THEN duration_s
WHEN started_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (now() - started_at))
ELSE COALESCE(duration_s, 0)
END
WHERE id = %s
AND state IN ('in_progress', 'printing', 'paused')
RETURNING *
""",
(state, run_id),
).fetchone()
def adopt_live_run(conn, host, filename, real_run_id): def adopt_live_run(conn, host, filename, real_run_id):
"""Proben/Segmente vom Live-Lauf auf den History-Lauf umhängen.""" """Proben/Segmente vom Live-Lauf auf den History-Lauf umhängen."""
live = find_live_run(conn, host, filename) live = find_live_run(conn, host, filename)
@@ -1772,7 +1870,7 @@ def exclusive_until(until):
return until return until
def statistics(conn, since=None, until=None, host="", material="", brand="", project_id=None): def statistics(conn, since=None, until=None, host="", material="", brand="", color="", project_id=None):
where = ["r.state IN ('completed', 'cancelled', 'error', 'interrupted')"] where = ["r.state IN ('completed', 'cancelled', 'error', 'interrupted')"]
values = [] values = []
until_bound = exclusive_until(until) until_bound = exclusive_until(until)
@@ -1790,7 +1888,7 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
values.append(project_id) values.append(project_id)
spool_join = "" spool_join = ""
if material or brand: if material or brand or color:
spool_join = ( spool_join = (
"JOIN run_spools rs0 ON rs0.run_id = r.id " "JOIN run_spools rs0 ON rs0.run_id = r.id "
"JOIN spools s0 ON s0.id = rs0.spool_id" "JOIN spools s0 ON s0.id = rs0.spool_id"
@@ -1801,6 +1899,9 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
if brand: if brand:
where.append("s0.brand ILIKE %s") where.append("s0.brand ILIKE %s")
values.append(brand) values.append(brand)
if color:
where.append("s0.color ILIKE %s")
values.append(color)
clause = " AND ".join(where) clause = " AND ".join(where)
overview = conn.execute( overview = conn.execute(
@@ -1874,11 +1975,14 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
if brand: if brand:
mat_where.append("s.brand ILIKE %s") mat_where.append("s.brand ILIKE %s")
mat_values.append(brand) mat_values.append(brand)
if color:
mat_where.append("s.color ILIKE %s")
mat_values.append(color)
mat_clause = " AND ".join(mat_where) mat_clause = " AND ".join(mat_where)
by_material = conn.execute( by_material = conn.execute(
""" """
SELECT s.material_type, s.brand, SELECT s.material_type, s.brand, s.color,
COALESCE(SUM(rs.weight_g), 0) AS weight_g, COALESCE(SUM(rs.weight_g), 0) AS weight_g,
COALESCE(SUM(rs.cost), 0) AS cost, COALESCE(SUM(rs.cost), 0) AS cost,
COUNT(DISTINCT rs.run_id) AS jobs COUNT(DISTINCT rs.run_id) AS jobs
@@ -1886,7 +1990,7 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
JOIN runs r ON r.id = rs.run_id JOIN runs r ON r.id = rs.run_id
JOIN spools s ON s.id = rs.spool_id JOIN spools s ON s.id = rs.spool_id
WHERE %s WHERE %s
GROUP BY s.material_type, s.brand GROUP BY s.material_type, s.brand, s.color
ORDER BY weight_g DESC ORDER BY weight_g DESC
""" % mat_clause, """ % mat_clause,
mat_values, mat_values,
@@ -1920,6 +2024,17 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
ov = overview or {} ov = overview or {}
# Bei Spulen-Join können SUM(DISTINCT …) ungenau sein — Überblick ohne Join neu # Bei Spulen-Join können SUM(DISTINCT …) ungenau sein — Überblick ohne Join neu
if spool_join: if spool_join:
spool_filters = []
spool_vals = list(run_values)
if material:
spool_filters.append("AND s.material_type ILIKE %s")
spool_vals.append(material)
if brand:
spool_filters.append("AND s.brand ILIKE %s")
spool_vals.append(brand)
if color:
spool_filters.append("AND s.color ILIKE %s")
spool_vals.append(color)
overview2 = conn.execute( overview2 = conn.execute(
""" """
SELECT SELECT
@@ -1940,15 +2055,10 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
JOIN spools s ON s.id = rs.spool_id JOIN spools s ON s.id = rs.spool_id
WHERE rs.run_id = r.id WHERE rs.run_id = r.id
%s %s
%s
) )
) )
""" % ( """ % (run_clause, " ".join(spool_filters)),
run_clause, spool_vals,
("AND s.material_type ILIKE %%s" if material else ""),
("AND s.brand ILIKE %%s" if brand else ""),
),
run_values + ([material] if material else []) + ([brand] if brand else []),
).fetchone() ).fetchone()
ov = overview2 or ov ov = overview2 or ov
@@ -1986,6 +2096,7 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
{ {
"material_type": row["material_type"] or "", "material_type": row["material_type"] or "",
"brand": row["brand"] or "", "brand": row["brand"] or "",
"color": row.get("color") or "",
"weight_g": float(row["weight_g"] or 0), "weight_g": float(row["weight_g"] or 0),
"cost": float(row["cost"] or 0), "cost": float(row["cost"] or 0),
"jobs": int(row["jobs"] or 0), "jobs": int(row["jobs"] or 0),
@@ -2579,14 +2690,50 @@ def update_print_queue(conn, item_id, fields):
return print_queue_out(fresh, fresh.get("filename")) if fresh else None return print_queue_out(fresh, fresh.get("filename")) if fresh else None
def _profile_matches(need_profile, spool_name):
"""Sorte: exakter Katalogname (Meta ≠ Matte ≠ PLA). Zusatz nur hinter dem Namen."""
a = (need_profile or "").strip().lower()
b = (spool_name or "").strip().lower()
if not a:
return True
if not b:
return False
if a == b:
return True
# Spulen-Anzeigename: "Sunlu PLA Meta schwarz" — nicht "Sunlu PLA" ⊂ "Sunlu PLA Matte"
if b.startswith(a) and len(b) > len(a) and not b[len(a)].isalnum():
rest = b[len(a):].lstrip(" ·-–_|/")
# Nächstes Wort darf keine bekannte PLA-/PETG-Variante sein
first = (rest.split() or [""])[0]
if first in {"meta", "matte", "matt", "silk", "plus", "+", "hf", "cf"}:
return False
return True
return False
def _need_label(need):
profile = (need.get("profile") or "").strip()
if profile:
return profile
kind = (need.get("material_type") or "").strip()
color = (need.get("color") or "").strip()
return (kind + ((" " + color) if color else "")).strip() or "Material"
def _spool_material_set(conn, host): def _spool_material_set(conn, host):
rows = conn.execute( rows = conn.execute(
""" """
SELECT lower(coalesce(material_type, '')) AS mt, SELECT lower(coalesce(s.material_type, '')) AS mt,
lower(coalesce(color, '')) AS color, lower(coalesce(s.color, '')) AS color,
coalesce(remaining_g, 0) AS remaining_g coalesce(s.remaining_g, 0) AS remaining_g,
FROM spools lower(coalesce(m.name, s.name, '')) AS material_name,
WHERE printer_host = %s s.ace_slot AS ace_slot,
s.id AS spool_id
FROM spools s
LEFT JOIN materials m ON m.id = s.material_id
WHERE s.printer_host = %s
AND s.archived_at IS NULL
AND coalesce(s.remaining_g, 0) > 0.5
""", """,
(host or "",), (host or "",),
).fetchall() ).fetchall()
@@ -2595,6 +2742,9 @@ def _spool_material_set(conn, host):
"material_type": (r["mt"] or "").strip(), "material_type": (r["mt"] or "").strip(),
"color": (r["color"] or "").strip(), "color": (r["color"] or "").strip(),
"remaining_g": float(r["remaining_g"] or 0), "remaining_g": float(r["remaining_g"] or 0),
"material_name": (r["material_name"] or "").strip(),
"ace_slot": r["ace_slot"],
"spool_id": r["spool_id"],
} }
for r in rows for r in rows
] ]
@@ -2617,48 +2767,144 @@ def _file_needs(conn, file_id):
except Exception: except Exception:
filaments = [] filaments = []
types = [] types = []
for item in filaments if isinstance(filaments, list) else []: for index, item in enumerate(filaments if isinstance(filaments, list) else []):
if isinstance(item, dict): if isinstance(item, dict):
t = (item.get("type") or item.get("material_type") or "").strip().lower() t = (item.get("type") or item.get("material_type") or "").strip().lower()
c = (item.get("color") or "").strip().lower() c = (item.get("color") or "").strip().lower()
if t: profile = (item.get("profile") or "").strip()
types.append({"material_type": t, "color": c}) try:
paint = int(item.get("paint_index"))
except (TypeError, ValueError):
paint = index
if t or profile:
types.append({
"material_type": t,
"color": c,
"profile": profile,
"paint_index": paint,
})
elif isinstance(item, str) and item.strip(): elif isinstance(item, str) and item.strip():
types.append({"material_type": item.strip().lower(), "color": ""}) types.append({
"material_type": item.strip().lower(),
"color": "",
"profile": "",
"paint_index": index,
})
return types, float(row.get("weight_g") or 0) return types, float(row.get("weight_g") or 0)
def _match_spools_for_need(need, have, used_ids=None):
used = used_ids or set()
candidates = [h for h in have if h.get("spool_id") not in used]
profile = (need.get("profile") or "").strip()
if profile:
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
if not by_profile:
return []
colored = [
h for h in by_profile
if not need.get("color") or h["color"] == need["color"] or not h["color"]
]
return colored or by_profile
kind = need.get("material_type") or ""
return [
h for h in candidates
if (not kind or h["material_type"] == kind)
and (not need.get("color") or h["color"] == need["color"] or not h["color"])
]
def material_fit_score(conn, host, file_id): def material_fit_score(conn, host, file_id):
"""Höher = besser. -1 nur bei zu wenig Rest. Falsche Sorte startet trotzdem.""" """Höher = besser. -1 = warten (falsche Sorte mit Profil, oder zu wenig Rest)."""
need, weight = _file_needs(conn, file_id) need, weight = _file_needs(conn, file_id)
have = _spool_material_set(conn, host) have = _spool_material_set(conn, host)
if not need: if not need:
return 0, "" return 0, ""
if not have: if not have:
# Mit Profil: ohne Spule warten. Ohne Profil: soft (wie bisher).
if any((n.get("profile") or "").strip() for n in need):
return -1, "braucht %s" % _need_label(need[0])
return 0, "" return 0, ""
has_profiles = any((n.get("profile") or "").strip() for n in need)
if has_profiles:
score = 20
used = set()
for n in need:
matched = _match_spools_for_need(n, have, used)
if not matched:
return -1, "braucht %s" % _need_label(n)
best = max(matched, key=lambda h: h["remaining_g"])
used.add(best.get("spool_id"))
if n.get("color") and any(h["color"] == n["color"] for h in matched):
score += 5
if weight and best["remaining_g"] + 1e-6 >= weight:
score += 5
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
return -1, "braucht %s (%s g fehlen)" % (
_need_label(n),
int(max(0, weight - max(h["remaining_g"] for h in matched))),
)
return score, ""
have_types = {h["material_type"] for h in have if h["material_type"]} have_types = {h["material_type"] for h in have if h["material_type"]}
need_types = {n["material_type"] for n in need if n["material_type"]} need_types = {n["material_type"] for n in need if n["material_type"]}
# Falsche Sorte hält den Job nicht fest. Dateien warnt, der Start geht trotzdem. # Ohne Profil: falscher Typ hält den Job nicht fest (Sofort-Start warnt in der UI).
score = 0 if (need_types and have_types and need_types.isdisjoint(have_types)) else 10 score = 0 if (need_types and have_types and need_types.isdisjoint(have_types)) else 10
# Farbe + Rest
for n in need: for n in need:
matched = [ matched = _match_spools_for_need(n, have)
h for h in have
if h["material_type"] == n["material_type"]
and (not n["color"] or h["color"] == n["color"] or not h["color"])
]
if matched: if matched:
score += 5 score += 5
if weight and any(h["remaining_g"] + 1e-6 >= weight for h in matched): if weight and any(h["remaining_g"] + 1e-6 >= weight for h in matched):
score += 5 score += 5
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched): elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
return -1, "braucht %s (%s g fehlen)" % ( return -1, "braucht %s (%s g fehlen)" % (
n["material_type"] + ((" " + n["color"]) if n["color"] else ""), _need_label(n),
int(max(0, weight - max(h["remaining_g"] for h in matched))), int(max(0, weight - max(h["remaining_g"] for h in matched))),
) )
return score, "" return score, ""
def auto_ace_map(conn, host, file_id):
"""ACE-Map aus eingelegten Spulen: Sorte (Profil) zuerst, Farbe soft.
Rückgabe: (mapping, reason). reason gesetzt → nicht starten, warten.
Ohne ACE-Spulen am Host: leere Map und kein Grund (externe Rolle).
"""
need, _weight = _file_needs(conn, file_id)
have = [h for h in _spool_material_set(conn, host) if h.get("ace_slot")]
if not need:
return [], ""
if not have:
return [], ""
mapping = []
used = set()
for n in need:
matched = _match_spools_for_need(n, have, used)
# Nur ACE-Plätze
matched = [h for h in matched if h.get("ace_slot")]
if not matched:
return [], "braucht %s auf ACE" % _need_label(n)
# Farbe bevorzugen, dann Rest
matched.sort(
key=lambda h: (
0 if (n.get("color") and h["color"] == n["color"]) else 1,
-h["remaining_g"],
)
)
chosen = matched[0]
used.add(chosen.get("spool_id"))
try:
paint = int(n.get("paint_index"))
except (TypeError, ValueError):
paint = len(mapping)
ams = int(chosen["ace_slot"]) - 1
if ams < 0 or ams > 7:
return [], "ACE-Platz von %s ungültig" % _need_label(n)
mapping.append({"paint_index": paint, "ams_index": ams})
return mapping, ""
def next_waiting_print(conn, host=None, family=None): def next_waiting_print(conn, host=None, family=None):
"""Nächster wartender Druck: Termin (due_at) vor Priorität, Material-Match bevorzugt.""" """Nächster wartender Druck: Termin (due_at) vor Priorität, Material-Match bevorzugt."""
if host: if host:
+14 -6
View File
@@ -53,15 +53,23 @@ def planned_shares(metadata, ace_map, target_count):
weights = weight_list(metadata) weights = weight_list(metadata)
mapping = ace_map if isinstance(ace_map, list) else [] mapping = ace_map if isinstance(ace_map, list) else []
if weights and mapping and len(weights) >= len(mapping) and target_count == len(mapping): if weights and mapping and len(weights) >= len(mapping) and target_count == len(mapping):
shares = [] paints = []
for item in mapping: for item in mapping:
paint = item.get("paint_index")
try: try:
index = int(paint) paints.append(int(item.get("paint_index")))
except (TypeError, ValueError): except (TypeError, ValueError):
index = len(shares) paints.append(-1)
if 0 <= index < len(weights): # Nur bei dichter 0…n−1-Map paint_index = Gewichtsindex; sonst Map-Reihenfolge
shares.append(weights[index]) # (Bambu/Anycubic oft T1+T10 bei zwei kompakten Gewichten).
dense = sorted(paints) == list(range(len(paints))) and all(
0 <= paint < len(weights) for paint in paints
)
shares = []
for order, paint in enumerate(paints):
if dense:
shares.append(weights[paint])
elif order < len(weights):
shares.append(weights[order])
else: else:
shares.append(0.0) shares.append(0.0)
if sum(shares) > 0: if sum(shares) > 0:
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "3.2.0" __version__ = "3.3.0"
+14 -1
View File
@@ -6,7 +6,7 @@ import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from grotti import moonraker, notify, store, usage from grotti import jobs, moonraker, notify, store, usage
from grotti.events_bus import publish as publish_live from grotti.events_bus import publish as publish_live
from grotti.db import connect from grotti.db import connect
@@ -170,6 +170,17 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg) jobs.sync_history(conn, self.cfg)
except Exception as exc: except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True) print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
# History oft ohne Endzeit (interrupted) — Live trotzdem schließen.
dangling = store.find_live_run(conn, host)
if dangling:
closed = store.complete_open_run(conn, dangling["id"], state="completed")
if closed:
try:
from grotti import jobs
jobs.apply_deduction(conn, closed, self.cfg["energy"])
except Exception as exc:
print("Watcher Abzug %s: %s" % (host, exc), flush=True)
elif state in ("cancelled", "standby"): elif state in ("cancelled", "standby"):
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart # Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren). # (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
@@ -286,6 +297,8 @@ class StateWatcher:
return None return None
sent = store.latest_send(conn, host, filename) if filename else None sent = store.latest_send(conn, host, filename) if filename else None
spool_id = (sent or {}).get("spool_id") spool_id = (sent or {}).get("spool_id")
# Send kann nach Rollenwechsel veraltet sein — aktuelle Rolle am Host bevorzugt
spool_id = jobs.resolve_tracking_spool(conn, host, spool_id, ace_map=(sent or {}).get("ace_map"))
run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id) run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id)
objects = self._merge_objects(run.get("objects"), live.get("exclude")) objects = self._merge_objects(run.get("objects"), live.get("exclude"))
planned = usage.as_float(live.get("weight")) planned = usage.as_float(live.get("weight"))
+184 -4
View File
@@ -13,8 +13,11 @@ from __future__ import annotations
import json import json
import mimetypes import mimetypes
import os import os
import re
import shutil import shutil
import subprocess
import sys import sys
import time
import uuid import uuid
import webbrowser import webbrowser
from pathlib import Path from pathlib import Path
@@ -25,7 +28,19 @@ from urllib.request import Request, urlopen
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
CONFIG_PATH = REPO_ROOT / "data" / "orca-senden.json" CONFIG_PATH = REPO_ROOT / "data" / "orca-senden.json"
DEBUG_LOG = REPO_ROOT / "data" / "orca-senden-last.log" DEBUG_LOG = REPO_ROOT / "data" / "orca-senden-last.log"
BROWSER_LOCK = REPO_ROOT / "data" / "orca-senden-browser.lock"
FOCUS_PATH = REPO_ROOT / "data" / "orca-senden-focus.json"
APP_PROFILE = REPO_ROOT / "data" / "grotti-app-profile"
ALLOWED_SUFFIXES = (".gcode", ".gcode.3mf", ".3mf", ".gcode.pp", ".pp") ALLOWED_SUFFIXES = (".gcode", ".gcode.3mf", ".3mf", ".gcode.pp", ".pp")
BROWSER_DEBOUNCE_S = 25.0
# Anycubic: MMDD-HHMM-Name.gcode — Zeitstempel raus, sonst jedes Export ein neues Fenster
_TIME_PREFIX_RE = re.compile(r"^\d{4}-\d{4}-")
_CHROMIUM_CANDIDATES = (
Path(r"C:\Program Files\Google\Chrome\Application\chrome.exe"),
Path(r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"),
Path(os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe")),
Path(os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe")),
)
def show_error(message: str) -> None: def show_error(message: str) -> None:
@@ -76,7 +91,10 @@ def load_config(path: Path = CONFIG_PATH) -> dict[str, str]:
raise ValueError("Config: url fehlt (z. B. http://127.0.0.1:8770).") raise ValueError("Config: url fehlt (z. B. http://127.0.0.1:8770).")
if not token: if not token:
raise ValueError("Config: token fehlt (grt_… aus Einstellungen).") raise ValueError("Config: token fehlt (grt_… aus Einstellungen).")
return {"url": url, "token": token} browser = str(data.get("browser") or "app").strip().lower() or "app"
if browser not in ("app", "tab"):
browser = "app"
return {"url": url, "token": token, "browser": browser}
def is_gcode_path(path: Path) -> bool: def is_gcode_path(path: Path) -> bool:
@@ -140,6 +158,83 @@ def send_page_url(base_url: str, file_id: int) -> str:
return "%s/dateien?file=%s" % (base_url.rstrip("/"), int(file_id)) return "%s/dateien?file=%s" % (base_url.rstrip("/"), int(file_id))
def app_page_url(base_url: str) -> str:
"""Stabile App-URL — Datei kommt über orca-fokus, kein neues Fenster pro Slice."""
return "%s/dateien?orca=1" % base_url.rstrip("/")
def find_chromium() -> Path | None:
for path in _CHROMIUM_CANDIDATES:
try:
if path.is_file():
return path
except OSError:
continue
return None
def app_already_running() -> bool:
"""True wenn Chrome/Edge schon mit grotti-app-profile läuft (Profil-Sperre)."""
needle = "grotti-app-profile"
if sys.platform == "win32":
try:
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
out = subprocess.check_output(
[
"powershell",
"-NoProfile",
"-Command",
(
"Get-CimInstance Win32_Process | "
"Where-Object { "
"($_.Name -match '^(chrome|msedge)\\.exe$') -and "
"($_.CommandLine -like '*%s*') "
"} | Select-Object -First 1 -ExpandProperty ProcessId"
)
% needle,
],
timeout=8,
stderr=subprocess.DEVNULL,
creationflags=creationflags,
)
return bool((out or b"").strip())
except (OSError, subprocess.SubprocessError):
pass
for name in ("SingletonLock", "SingletonSocket", "lockfile"):
path = APP_PROFILE / name
try:
if path.exists() or path.is_symlink():
return True
except OSError:
continue
return False
def open_as_app(url: str) -> bool:
"""Chrome/Edge --app=… → eigenes Fenster auf dem Desktop, kein Browser-Tab."""
binary = find_chromium()
if binary is None:
return False
try:
APP_PROFILE.mkdir(parents=True, exist_ok=True)
subprocess.Popen(
[
str(binary),
"--app=%s" % url,
"--user-data-dir=%s" % str(APP_PROFILE.resolve()),
"--no-first-run",
"--no-default-browser-check",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
)
return True
except OSError as exc:
write_debug(sys.argv, "app_open_fail=%s" % exc)
return False
def _api_json( def _api_json(
base_url: str, base_url: str,
token: str, token: str,
@@ -279,7 +374,79 @@ def upload_file(
return payload return payload
def open_browser(url: str) -> None: def browser_key(name: str) -> str:
"""Gleicher Slice trotz Zeitstempel / -2 / Leerzeichen → ein Browserfenster."""
stem = Path(name or "").stem.lower()
stem = _TIME_PREFIX_RE.sub("", stem)
stem = re.sub(r"-\d+$", "", stem)
return re.sub(r"[^a-z0-9]+", "", stem)
def write_focus(file_id: int, url: str, key: str = "") -> None:
"""Offene Dateien-Seite kann auf die neueste Orca-Datei umschalten."""
try:
FOCUS_PATH.parent.mkdir(parents=True, exist_ok=True)
FOCUS_PATH.write_text(
json.dumps(
{
"file_id": int(file_id),
"url": url,
"key": key,
"ts": time.time(),
},
ensure_ascii=False,
),
encoding="utf-8",
)
except (OSError, TypeError, ValueError):
pass
def open_browser(
url: str,
key: str = "",
file_id: int | None = None,
mode: str = "app",
) -> None:
"""Anycubic/Orca starten das Skript oft 2× — nur ein Fenster pro Modell.
mode=app (Standard): Chrome/Edge als App-Fenster auf dem Desktop.
mode=tab: klassischer Browser-Tab.
"""
token = (key or url).strip() or url
now = time.time()
if file_id is not None:
write_focus(int(file_id), url, key=token)
try:
BROWSER_LOCK.parent.mkdir(parents=True, exist_ok=True)
if BROWSER_LOCK.is_file():
prev = json.loads(BROWSER_LOCK.read_text(encoding="utf-8"))
if (
isinstance(prev, dict)
and prev.get("key") == token
and now - float(prev.get("ts") or 0) < BROWSER_DEBOUNCE_S
):
write_debug(
sys.argv,
"browser_skip key=%s age=%.1fs focus=%s"
% (token, now - float(prev.get("ts") or 0), file_id),
)
return
BROWSER_LOCK.write_text(
json.dumps({"key": token, "ts": now, "url": url, "mode": mode}, ensure_ascii=False),
encoding="utf-8",
)
except (OSError, TypeError, ValueError, json.JSONDecodeError):
pass
if (mode or "app").strip().lower() != "tab":
# Zweites Chrome mit gleichem Profil = leeres Profil / erneutes Login.
# Offenes Fenster pollt orca-fokus und wechselt die Datei selbst.
if app_already_running():
write_debug(sys.argv, "browser_reuse focus=%s" % file_id)
return
if open_as_app(url):
write_debug(sys.argv, "browser_app url=%s" % url)
return
webbrowser.open(url) webbrowser.open(url)
@@ -319,8 +486,21 @@ def main(argv: list[str] | None = None) -> int:
% (path, upload_name_for(path), output_target() or ""), % (path, upload_name_for(path), output_target() or ""),
) )
file_id = deliver(config["url"], config["token"], path) file_id = deliver(config["url"], config["token"], path)
write_debug(argv, "file_id=%s" % file_id) name = upload_name_for(path)
open_browser(send_page_url(config["url"], file_id)) write_debug(argv, "file_id=%s\nupload_as=%s" % (file_id, name))
mode = str(config.get("browser") or "app").strip().lower()
# App: stabile URL; Tab: Deep-Link mit file=
open_url = (
send_page_url(config["url"], file_id)
if mode == "tab"
else app_page_url(config["url"])
)
open_browser(
open_url,
key=browser_key(name),
file_id=file_id,
mode=mode,
)
except Exception as exc: except Exception as exc:
write_debug(argv, "error=%s" % exc) write_debug(argv, "error=%s" % exc)
show_error(str(exc)) show_error(str(exc))
+41
View File
@@ -28,6 +28,47 @@ class ChooseSpoolTests(unittest.TestCase):
with patch("grotti.jobs.store.spools_for_printer", return_value=[old]): with patch("grotti.jobs.store.spools_for_printer", return_value=[old]):
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", None, ended_at=end)["id"], 1) self.assertEqual(jobs.choose_spool(conn, "h", "PLA", None, ended_at=end)["id"], 1)
def test_prefers_host_spool_over_stale_hint(self):
"""Rollenwechsel: Hint im Regal, neue Rolle am Drucker → Abzug auf die neue."""
end = datetime(2026, 10, 18, 14, 58, tzinfo=timezone.utc)
hinted = {
"id": 4,
"material_type": "PLA",
"printer_host": "",
"created_at": end - timedelta(days=5),
}
current = {
"id": 14,
"material_type": "PLA",
"printer_host": "h",
"created_at": end - timedelta(minutes=10),
}
conn = MagicMock()
with patch("grotti.jobs.store.spools_for_printer", return_value=[current]):
with patch("grotti.jobs.store.get_spool_row", return_value=hinted):
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", 4, ended_at=end)["id"], 14)
def test_keeps_hint_when_no_host_spool(self):
end = datetime(2026, 10, 18, 14, 58, tzinfo=timezone.utc)
hinted = {
"id": 4,
"material_type": "PLA",
"printer_host": "",
"created_at": end - timedelta(days=5),
}
conn = MagicMock()
with patch("grotti.jobs.store.spools_for_printer", return_value=[]):
with patch("grotti.jobs.store.get_spool_row", return_value=hinted):
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", 4, ended_at=end)["id"], 4)
def test_resolve_tracking_spool_prefers_host(self):
conn = MagicMock()
with patch(
"grotti.jobs.resolve_spools_at_host",
return_value=[{"id": 14, "printer_host": "h"}],
):
self.assertEqual(jobs.resolve_tracking_spool(conn, "h", 4), 14)
def test_spool_valid_for_end_naive(self): def test_spool_valid_for_end_naive(self):
end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc) end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
spool = {"created_at": datetime(2026, 9, 1, 12, 0)} spool = {"created_at": datetime(2026, 9, 1, 12, 0)}
+80
View File
@@ -0,0 +1,80 @@
"""Sorten-Routing: Profil (Meta/Matte) und Auto-ACE-Map."""
import unittest
from grotti import store
class ProfileMatchTests(unittest.TestCase):
def test_meta_not_matte(self):
self.assertTrue(store._profile_matches("Sunlu PLA Meta", "Sunlu PLA Meta"))
self.assertTrue(store._profile_matches("Sunlu PLA Meta", "Sunlu PLA Meta schwarz"))
self.assertFalse(store._profile_matches("Sunlu PLA Meta", "Sunlu PLA Matte"))
self.assertFalse(store._profile_matches("Sunlu PLA Matte", "Sunlu PLA"))
self.assertFalse(store._profile_matches("Sunlu PLA", "Sunlu PLA Matte"))
self.assertFalse(store._profile_matches("Sunlu PLA", "Sunlu PLA Meta"))
def test_empty_profile_always_ok(self):
self.assertTrue(store._profile_matches("", "Sunlu PLA Meta"))
class MatchSpoolsTests(unittest.TestCase):
def setUp(self):
self.have = [
{
"spool_id": 1,
"material_type": "pla",
"color": "#ff0000",
"remaining_g": 800,
"material_name": "sunlu pla meta",
"ace_slot": 1,
},
{
"spool_id": 2,
"material_type": "pla",
"color": "#000000",
"remaining_g": 500,
"material_name": "sunlu pla matte",
"ace_slot": 2,
},
]
def test_picks_meta_not_matte(self):
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
matched = store._match_spools_for_need(need, self.have)
self.assertEqual(len(matched), 1)
self.assertEqual(matched[0]["spool_id"], 1)
def test_color_soft_among_profile(self):
need = {
"profile": "Sunlu PLA Meta",
"material_type": "pla",
"color": "#00ff00",
"paint_index": 0,
}
# Nur Meta vorhanden, Farbe anders → trotzdem Meta (Farbe soft)
matched = store._match_spools_for_need(need, self.have)
self.assertEqual(matched[0]["spool_id"], 1)
def test_auto_ace_map_assigns_slots(self):
# Ohne DB: Logik über _match + manuell wie auto_ace_map
need = [
{"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "#ff0000", "paint_index": 1},
{"profile": "Sunlu PLA Matte", "material_type": "pla", "color": "#000000", "paint_index": 10},
]
used = set()
mapping = []
for n in need:
matched = store._match_spools_for_need(n, self.have, used)
self.assertTrue(matched)
chosen = matched[0]
used.add(chosen["spool_id"])
mapping.append({"paint_index": n["paint_index"], "ams_index": int(chosen["ace_slot"]) - 1})
self.assertEqual(mapping, [
{"paint_index": 1, "ams_index": 0},
{"paint_index": 10, "ams_index": 1},
])
if __name__ == "__main__":
unittest.main()
+84
View File
@@ -23,6 +23,90 @@ class OrcaSendenHelpersTest(unittest.TestCase):
"http://127.0.0.1:8770/dateien?file=42", "http://127.0.0.1:8770/dateien?file=42",
) )
def test_app_page_url(self):
self.assertEqual(
orca.app_page_url("http://127.0.0.1:8770/"),
"http://127.0.0.1:8770/dateien?orca=1",
)
def test_browser_key_collapses_duplicates(self):
self.assertEqual(
orca.browser_key("1018-1648-Round Clip PLA + PETG(01)_PLA_0.2_30m34s.gcode"),
orca.browser_key("1018-1648-Round_Clip_PLA_+_PETG(01)_PLA_0.2_30m34s-2.gcode"),
)
def test_browser_key_strips_time_prefix(self):
self.assertEqual(
orca.browser_key("1018-1706-Round Clip PLA + PETG(01)_PLA_0.2_30m34s.gcode"),
orca.browser_key("1018-1709-Round_Clip_PLA_+_PETG(01)_PLA_0.2_30m34s.gcode"),
)
self.assertEqual(
orca.browser_key("1018-1709-Round Clip PLA + PETG(01)_PLA_0.2_30m34s.gcode"),
"roundclipplapetg01pla0230m34s",
)
def test_open_browser_debounces(self):
with tempfile.TemporaryDirectory() as tmp:
lock = Path(tmp) / "browser.lock"
focus = Path(tmp) / "focus.json"
with patch.object(orca, "BROWSER_LOCK", lock):
with patch.object(orca, "FOCUS_PATH", focus):
with patch.object(orca, "open_as_app", return_value=False):
with patch.object(orca, "webbrowser") as browser:
orca.open_browser(
"http://127.0.0.1:8770/dateien?file=1",
key="roundclip",
file_id=1,
mode="tab",
)
orca.open_browser(
"http://127.0.0.1:8770/dateien?file=2",
key="roundclip",
file_id=2,
mode="tab",
)
self.assertEqual(browser.open.call_count, 1)
data = json.loads(focus.read_text(encoding="utf-8"))
self.assertEqual(data["file_id"], 2)
def test_open_browser_app_mode(self):
with tempfile.TemporaryDirectory() as tmp:
lock = Path(tmp) / "browser.lock"
focus = Path(tmp) / "focus.json"
with patch.object(orca, "BROWSER_LOCK", lock):
with patch.object(orca, "FOCUS_PATH", focus):
with patch.object(orca, "app_already_running", return_value=False):
with patch.object(orca, "open_as_app", return_value=True) as app:
with patch.object(orca, "webbrowser") as browser:
orca.open_browser(
"http://127.0.0.1:8770/dateien?orca=1",
key="roundclip",
file_id=9,
mode="app",
)
app.assert_called_once()
browser.open.assert_not_called()
def test_open_browser_reuses_running_app(self):
with tempfile.TemporaryDirectory() as tmp:
lock = Path(tmp) / "browser.lock"
focus = Path(tmp) / "focus.json"
with patch.object(orca, "BROWSER_LOCK", lock):
with patch.object(orca, "FOCUS_PATH", focus):
with patch.object(orca, "app_already_running", return_value=True):
with patch.object(orca, "open_as_app", return_value=True) as app:
with patch.object(orca, "webbrowser") as browser:
orca.open_browser(
"http://127.0.0.1:8770/dateien?orca=1",
key="andere-datei",
file_id=11,
mode="app",
)
app.assert_not_called()
browser.open.assert_not_called()
data = json.loads(focus.read_text(encoding="utf-8"))
self.assertEqual(data["file_id"], 11)
def test_find_gcode_arg_last_matching(self): def test_find_gcode_arg_last_matching(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp) folder = Path(tmp)
+171
View File
@@ -0,0 +1,171 @@
"""Sparse paint_index / Bambu-T-Nummern für ACE-Map."""
import json
import unittest
from grotti import acm, parse, usage
PAINT_SPARSE = json.dumps(
[
{"material_type": "PLA", "paint_color": [255, 128, 0], "paint_index": 1},
{"material_type": "PLA", "paint_color": [0, 0, 0], "paint_index": 10},
]
)
class PaintIndexTests(unittest.TestCase):
def test_filaments_keep_slicer_paint_index(self):
rows = parse.filaments_from_paint_info(PAINT_SPARSE)
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0]["paint_index"], 1)
self.assertEqual(rows[0]["type"], "PLA")
self.assertEqual(rows[0]["color"], "#ff8000")
self.assertEqual(rows[1]["paint_index"], 10)
self.assertEqual(rows[1]["color"], "#000000")
def test_build_acm_uses_paint_index_not_array_slot(self):
filaments = parse.filaments_from_paint_info(PAINT_SPARSE)
mapping = [
{"paint_index": 1, "ams_index": 3},
{"paint_index": 10, "ams_index": 2},
]
slots = [
{"index": 2, "type": "PLA", "color": "#f4f4f4"},
{"index": 3, "type": "PLA", "color": "#c23b3b"},
]
payload = acm.build_acm(filaments, mapping, slots)
paints = [item["paint_index"] for item in payload["ams_box_mapping"]]
self.assertEqual(paints, [1, 10])
by_paint = {item["paint_index"]: item for item in payload["ams_box_mapping"]}
self.assertEqual(by_paint[1]["ams_index"], 3)
self.assertEqual(by_paint[10]["ams_index"], 2)
self.assertEqual(by_paint[1]["paint_color"], [255, 128, 0])
self.assertEqual(by_paint[10]["paint_color"], [0, 0, 0])
def test_densify_mapping_and_rewrite_avoids_invalid_filament_id(self):
mapping = [
{"paint_index": 1, "ams_index": 3},
{"paint_index": 10, "ams_index": 2},
]
remap, dense = acm.densify_mapping(mapping)
self.assertEqual(remap, {1: 0, 10: 1})
self.assertEqual(
dense,
[
{"paint_index": 0, "ams_index": 3},
{"paint_index": 1, "ams_index": 2},
],
)
gcode = b"; hdr\nT10\nG1 X0\nT1 ; change\n; referenced_tools = 1,10\n"
out = acm.rewrite_gcode_tools(gcode, remap)
self.assertIn(b"T1\n", out)
self.assertIn(b"T0 ; change", out)
self.assertNotIn(b"T10", out)
self.assertIn(b"; referenced_tools = 0,1", out)
filaments = acm.remap_filaments(
parse.filaments_from_paint_info(PAINT_SPARSE), remap
)
slots = [
{"index": 2, "type": "PLA", "color": "#f4f4f4"},
{"index": 3, "type": "PLA", "color": "#c23b3b"},
]
payload = acm.build_acm(filaments, dense, slots)
paints = [item["paint_index"] for item in payload["ams_box_mapping"]]
self.assertEqual(paints, [0, 1])
self.assertEqual(payload["ams_box_mapping"][0]["paint_color"], [255, 128, 0])
def test_densify_identity_when_already_dense(self):
mapping = [{"paint_index": 0, "ams_index": 1}, {"paint_index": 1, "ams_index": 2}]
remap, dense = acm.densify_mapping(mapping)
self.assertEqual(remap, {})
self.assertEqual(dense, mapping)
def test_align_rewrites_when_ui_map_is_already_dense(self):
"""Regression 10011700: UI schickt 0/1, G-code hat noch T1/T10."""
gcode = b"; hdr\nT10\nG1 X0\nT1 ; change\n; referenced_tools = 1,10\n"
mapping = [
{"paint_index": 0, "ams_index": 3},
{"paint_index": 1, "ams_index": 2},
]
filaments = [
{"type": "PLA", "color": "#ff8000", "paint_index": 0},
{"type": "PLA", "color": "#000000", "paint_index": 1},
]
out, dense, fils, remap = acm.align_tools_for_ace(gcode, mapping, filaments)
self.assertEqual(remap, {1: 0, 10: 1})
self.assertEqual(
dense,
[
{"paint_index": 0, "ams_index": 3},
{"paint_index": 1, "ams_index": 2},
],
)
self.assertIn(b"T1\n", out)
self.assertIn(b"T0 ; change", out)
self.assertNotIn(b"T10", out)
self.assertIn(b"; referenced_tools = 0,1", out)
self.assertEqual([row["paint_index"] for row in fils], [0, 1])
self.assertEqual(fils[0]["color"], "#ff8000")
self.assertEqual(fils[1]["color"], "#000000")
def test_align_with_sparse_ui_map(self):
gcode = b"T10\nT1\n"
mapping = [
{"paint_index": 1, "ams_index": 3},
{"paint_index": 10, "ams_index": 2},
]
out, dense, fils, remap = acm.align_tools_for_ace(
gcode, mapping, parse.filaments_from_paint_info(PAINT_SPARSE)
)
self.assertEqual(remap, {1: 0, 10: 1})
self.assertEqual(dense[0]["ams_index"], 3)
self.assertEqual(dense[1]["ams_index"], 2)
self.assertNotIn(b"T10", out)
self.assertEqual([row["paint_index"] for row in fils], [0, 1])
def test_tools_used_finds_t10(self):
gcode = b"; header\nT10\nG1 X0\nT1 ; change\n"
self.assertEqual(parse.tools_used_in_gcode(gcode), {1, 10})
def test_planned_shares_sparse_paint_falls_back_to_order(self):
meta = {"filament_weight_total": [12.0, 8.0]}
mapping = [
{"paint_index": 1, "ams_index": 0},
{"paint_index": 10, "ams_index": 1},
]
self.assertEqual(usage.planned_shares(meta, mapping, 2), [12.0, 8.0])
def test_profile_name_strips_machine(self):
self.assertEqual(parse.profile_name('Sunlu PLA Meta @Kobra 3'), "Sunlu PLA Meta")
self.assertEqual(parse.profile_name('"Sunlu PLA Matte @Kobra S1"'), "Sunlu PLA Matte")
def test_filaments_of_attaches_settings_id_profiles(self):
# Slot 1 = PLA, Slot 10 = Matte — wie sparse paint_info
profiles = ";".join(
['"Sunlu PLA @Kobra 3"' if i not in (1, 10) else (
'"Sunlu PLA @Kobra 3"' if i == 1 else '"Sunlu PLA Matte @Kobra 3"'
) for i in range(15)]
)
comments = {
"paint_info": PAINT_SPARSE,
"filament_settings_id": profiles,
}
rows = parse.filaments_of(comments)
self.assertEqual(rows[0]["profile"], "Sunlu PLA")
self.assertEqual(rows[1]["profile"], "Sunlu PLA Matte")
def test_filaments_of_single_without_paint(self):
comments = {
"filament_type": "PLA",
"filament_colour": "#00FF00",
"filament_settings_id": '"Sunlu PLA Meta @Kobra 3"',
}
rows = parse.filaments_of(comments)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["profile"], "Sunlu PLA Meta")
self.assertEqual(rows[0]["type"], "PLA")
if __name__ == "__main__":
unittest.main()
+152 -13
View File
@@ -5,7 +5,7 @@ import re
import secrets import secrets
import time import time
import uuid import uuid
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@@ -77,6 +77,9 @@ def create_app(cfg):
app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024 app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024
app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax" app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
# Orca-App-Fenster schließt oft — Session-Cookie muss überleben (30 Tage, gleitend).
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30)
app.config["SESSION_REFRESH_EACH_REQUEST"] = True
if cfg.get("fqdn", "").startswith("https://"): if cfg.get("fqdn", "").startswith("https://"):
app.config["SESSION_COOKIE_SECURE"] = True app.config["SESSION_COOKIE_SECURE"] = True
app.json.ensure_ascii = False app.json.ensure_ascii = False
@@ -421,6 +424,7 @@ def create_app(cfg):
grotti_db.invalidate_user_count() grotti_db.invalidate_user_count()
store.touch_user_login(conn(), row["id"]) store.touch_user_login(conn(), row["id"])
session.clear() session.clear()
session.permanent = True
session["user_id"] = row["id"] session["user_id"] = row["id"]
session["csrf"] = grotti_auth.new_csrf() session["csrf"] = grotti_auth.new_csrf()
store.add_event(conn(), "", "", "auth", "Admin %s angelegt" % username) store.add_event(conn(), "", "", "auth", "Admin %s angelegt" % username)
@@ -442,6 +446,7 @@ def create_app(cfg):
raise GrottiError("Benutzer oder Passwort falsch.", "auth", 401) raise GrottiError("Benutzer oder Passwort falsch.", "auth", 401)
store.touch_user_login(conn(), row["id"]) store.touch_user_login(conn(), row["id"])
session.clear() session.clear()
session.permanent = True
session["user_id"] = row["id"] session["user_id"] = row["id"]
session["csrf"] = grotti_auth.new_csrf() session["csrf"] = grotti_auth.new_csrf()
store.add_event(conn(), "", "", "auth", "Login %s" % row["username"]) store.add_event(conn(), "", "", "auth", "Login %s" % row["username"])
@@ -633,7 +638,8 @@ def create_app(cfg):
@app.get("/kameras") @app.get("/kameras")
def kameras(): def kameras():
return page("kameras.html", "Kameras") """Alte Links → Wand (Kameras sitzen als Snapshot-Thumbs auf den Karten)."""
return redirect("/", code=301)
@app.get("/dateien") @app.get("/dateien")
def dateien(): def dateien():
@@ -641,7 +647,7 @@ def create_app(cfg):
@app.get("/projekte") @app.get("/projekte")
def projekte(): def projekte():
return redirect("/auftraege", code=301) return page("projekte.html", "Projekte")
@app.get("/auftraege") @app.get("/auftraege")
def auftraege_page(): def auftraege_page():
@@ -1049,7 +1055,27 @@ def create_app(cfg):
status = data.get("status") or "idee" status = data.get("status") or "idee"
if status not in PROJECT_STATUS: if status not in PROJECT_STATUS:
raise GrottiError("Unbekannter Status.", "status") raise GrottiError("Unbekannter Status.", "status")
return jsonify(store.create_project(conn(), name, (data.get("notes") or "").strip(), status)), 201 folder = (data.get("folder") or "").strip()
if not folder:
folder = library.default_project_folder(name)
try:
if cfg.get("watch_dir"):
folder = library.ensure_project_folder(cfg, folder)
else:
folder = library.normalize_folder(folder)
except GrottiError as exc:
if exc.code in ("ordner", "watch"):
raise GrottiError(
"%s — Ordner relativ unter dem Slice-Pfad, z. B. Projekte/%s (kein D:\\…)."
% (exc.message.rstrip("."), library.project_slug(name)),
exc.code,
getattr(exc, "status", 400) or 400,
) from exc
raise
row = store.create_project(
conn(), name, (data.get("notes") or "").strip(), status, folder=folder
)
return jsonify(row), 201
@app.get("/api/v1/projekte/<int:project_id>") @app.get("/api/v1/projekte/<int:project_id>")
def project_get(project_id): def project_get(project_id):
@@ -1073,6 +1099,13 @@ def create_app(cfg):
if data["status"] not in PROJECT_STATUS: if data["status"] not in PROJECT_STATUS:
raise GrottiError("Unbekannter Status.", "status") raise GrottiError("Unbekannter Status.", "status")
fields["status"] = data["status"] fields["status"] = data["status"]
if "folder" in data:
folder = (data.get("folder") or "").strip()
if folder and cfg.get("watch_dir"):
folder = library.ensure_project_folder(cfg, folder)
elif folder:
folder = library.normalize_folder(folder)
fields["folder"] = folder
row = store.update_project(conn(), project_id, fields) row = store.update_project(conn(), project_id, fields)
if not row: if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404) raise GrottiError("Das Projekt fehlt.", "projekt", 404)
@@ -1080,15 +1113,95 @@ def create_app(cfg):
@app.delete("/api/v1/projekte/<int:project_id>") @app.delete("/api/v1/projekte/<int:project_id>")
def project_delete(project_id): def project_delete(project_id):
row = store.get_project(conn(), project_id)
if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
# ?force=1 löscht den Projektordner inkl. Inhalt
force = (request.args.get("force") or "").strip() in ("1", "true", "yes")
folder = (row.get("folder") or "").strip()
if folder and cfg.get("watch_dir"):
library.delete_project_disk_folder(cfg, folder, force=force)
if not store.delete_project(conn(), project_id): if not store.delete_project(conn(), project_id):
raise GrottiError("Das Projekt fehlt.", "projekt", 404) raise GrottiError("Das Projekt fehlt.", "projekt", 404)
return "", 204 return "", 204
@app.get("/api/v1/projekte/<int:project_id>/assets")
def project_assets_list(project_id):
row = store.get_project(conn(), project_id)
if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
folder = (row.get("folder") or "").strip()
if not folder:
return jsonify([])
if not cfg.get("watch_dir"):
return jsonify([])
return jsonify(library.list_project_assets(cfg, folder))
@app.post("/api/v1/projekte/<int:project_id>/assets")
def project_assets_upload(project_id):
row = store.get_project(conn(), project_id)
if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
folder = (row.get("folder") or "").strip()
if not folder:
raise GrottiError("Das Projekt hat keinen Ordner.", "ordner")
if not cfg.get("watch_dir"):
raise GrottiError("Kein Slice-Ordner gesetzt — unter Einstellungen festlegen.", "watch")
upload = request.files.get("file")
if not upload or not upload.filename:
raise GrottiError("Keine Datei angehängt.", "datei")
saved = library.save_project_asset(cfg, folder, upload.filename, upload.read())
return jsonify(saved), 201
@app.get("/api/v1/projekte/<int:project_id>/assets/<path:rel>")
def project_assets_download(project_id, rel):
row = store.get_project(conn(), project_id)
if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
folder = (row.get("folder") or "").strip()
if not folder:
raise GrottiError("Das Projekt hat keinen Ordner.", "ordner")
path = library.resolve_project_asset(cfg, folder, rel)
return send_file(path, as_attachment=True, download_name=path.name)
@app.delete("/api/v1/projekte/<int:project_id>/assets/<path:rel>")
def project_assets_delete(project_id, rel):
row = store.get_project(conn(), project_id)
if not row:
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
folder = (row.get("folder") or "").strip()
if not folder:
raise GrottiError("Das Projekt hat keinen Ordner.", "ordner")
library.delete_project_asset(cfg, folder, rel)
return "", 204
@app.get("/api/v1/dateien") @app.get("/api/v1/dateien")
def file_list(): def file_list():
library.sync_watch(conn(), cfg, force=False) library.sync_watch(conn(), cfg, force=False)
return jsonify(store.list_files(conn(), watch_dir=cfg.get("watch_dir"))) return jsonify(store.list_files(conn(), watch_dir=cfg.get("watch_dir")))
@app.post("/api/v1/dateien/<int:file_id>/meta")
def file_meta_refresh(file_id):
"""Eine Datei neu parsen — z. B. paint_index für ACE nach Orca-Deep-Link."""
out = library.refresh_one_file(conn(), file_id, watch_dir=cfg.get("watch_dir"))
if not out:
raise GrottiError("Datei nicht gefunden.", "datei", 404)
return jsonify(out)
@app.get("/api/v1/orca-fokus")
def orca_focus():
"""Zuletzt von orca_senden gesetzte Datei — offene Tabs schalten um statt neu zu öffnen."""
path = Path(cfg["data_dir"]) / "orca-senden-focus.json"
if not path.is_file():
return jsonify({})
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return jsonify({})
if not isinstance(data, dict) or data.get("file_id") is None:
return jsonify({})
return jsonify(data)
@app.post("/api/v1/dateien/neu-lesen") @app.post("/api/v1/dateien/neu-lesen")
def file_rescan(): def file_rescan():
result = library.sync_watch(conn(), cfg, force=True) result = library.sync_watch(conn(), cfg, force=True)
@@ -1203,13 +1316,7 @@ def create_app(cfg):
@app.delete("/api/v1/dateien/<int:file_id>") @app.delete("/api/v1/dateien/<int:file_id>")
def file_delete(file_id): def file_delete(file_id):
row = store.get_file_row(conn(), file_id) library.delete_library_file(conn(), cfg, file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
if row["source"] != "upload":
raise GrottiError("Die Datei kommt aus dem Watch-Ordner und bleibt, solange sie dort liegt.", "watch")
store.delete_file(conn(), file_id)
Path(row["stored_path"]).unlink(missing_ok=True)
return "", 204 return "", 204
@app.post("/api/v1/senden") @app.post("/api/v1/senden")
@@ -1575,6 +1682,17 @@ def create_app(cfg):
except ValueError as exc: except ValueError as exc:
raise GrottiError(str(exc), "urteil") from exc raise GrottiError(str(exc), "urteil") from exc
run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n) run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n)
# Live-Lauf schließen: Moonraker-History bleibt oft ohne Endzeit hängen,
# sonst bleibt der Druck im Protokoll ewig „in_progress“ trotz Urteil.
if run_row and (run_row.get("state") or "") in ("in_progress", "printing", "paused"):
closed = store.complete_open_run(c, run_row["id"], state="completed")
if closed:
try:
jobs.apply_deduction(c, closed, cfg["energy"])
except Exception as exc:
print("Abräumen Abzug %s: %s" % (host, exc), flush=True)
row = store.get_run_row(c, closed["id"]) or closed
run_json = store.run_out(row)
reprint = None reprint = None
if bool(data.get("reprint")) and run_row and run_row.get("file_id"): if bool(data.get("reprint")) and run_row and run_row.get("file_id"):
reprint = store.enqueue_print( reprint = store.enqueue_print(
@@ -2098,11 +2216,27 @@ def create_app(cfg):
@app.patch("/api/v1/spulen/<int:spool_id>") @app.patch("/api/v1/spulen/<int:spool_id>")
def spool_patch(spool_id): def spool_patch(spool_id):
row = store.update_spool(conn(), spool_id, spool_fields(body(), partial=True, spool_id=spool_id)) data = body()
fields = spool_fields(data, partial=True, spool_id=spool_id)
if "archived" in data or "archived_at" in data:
if data.get("archived") is False or data.get("archived_at") is None:
fields["archived_at"] = None
elif data.get("archived") is True or data.get("archived_at") in (True, "now"):
fields["archived_at"] = "now"
row = store.update_spool(conn(), spool_id, fields)
if not row: if not row:
raise GrottiError("Die Spule fehlt.", "spule", 404) raise GrottiError("Die Spule fehlt.", "spule", 404)
return jsonify(row) return jsonify(row)
@app.post("/api/v1/spulen/<int:spool_id>/leer")
def spool_empty(spool_id):
"""Rest 0, Regal, archivieren — wenn der Rest falsch geschätzt war."""
row = store.mark_spool_empty(conn(), spool_id)
if not row:
raise GrottiError("Die Spule fehlt.", "spule", 404)
store.add_event(conn(), "", "", "spule", "Spule „%s“ als leer markiert" % row["name"])
return jsonify(row)
@app.post("/api/v1/spulen/<int:spool_id>/einlegen") @app.post("/api/v1/spulen/<int:spool_id>/einlegen")
def spool_load(spool_id): def spool_load(spool_id):
"""Spule an Drucker/Platz legen; vorherige Rolle dort geht ins Regal.""" """Spule an Drucker/Platz legen; vorherige Rolle dort geht ins Regal."""
@@ -2112,7 +2246,8 @@ def create_app(cfg):
spool = store.get_spool_row(conn(), spool_id) spool = store.get_spool_row(conn(), spool_id)
if not spool: if not spool:
raise GrottiError("Die Spule fehlt.", "spule", 404) raise GrottiError("Die Spule fehlt.", "spule", 404)
live_run = store.find_live_run(conn(), host) # Auch offene History-Jobs (Pause/Resume nach Moonraker-Split), nicht nur live:
live_run = store.find_active_run(conn(), host)
mm_now = float((live_run or {}).get("filament_used_mm") or 0) mm_now = float((live_run or {}).get("filament_used_mm") or 0)
printing = bool( printing = bool(
live_run and (live_run.get("state") or "") in ("in_progress", "printing", "paused") live_run and (live_run.get("state") or "") in ("in_progress", "printing", "paused")
@@ -2161,6 +2296,8 @@ def create_app(cfg):
updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot}) updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot})
if printing: if printing:
store.open_segment(conn(), live_run["id"], spool_id, slot, mm_now) store.open_segment(conn(), live_run["id"], spool_id, slot, mm_now)
store.assign_run_spool(conn(), live_run["id"], spool_id)
store.update_send_spool(conn(), host, live_run.get("filename") or "", spool_id)
store.add_event( store.add_event(
conn(), conn(),
host, host,
@@ -2462,6 +2599,7 @@ def create_app(cfg):
host = (request.args.get("host") or "").strip() host = (request.args.get("host") or "").strip()
material = (request.args.get("material") or "").strip() material = (request.args.get("material") or "").strip()
brand = (request.args.get("brand") or "").strip() brand = (request.args.get("brand") or "").strip()
color = (request.args.get("color") or "").strip()
project_id = optional_id(request.args.get("project_id")) project_id = optional_id(request.args.get("project_id"))
return jsonify(store.statistics( return jsonify(store.statistics(
conn(), conn(),
@@ -2470,6 +2608,7 @@ def create_app(cfg):
host=host, host=host,
material=material or "", material=material or "",
brand=brand or "", brand=brand or "",
color=color or "",
project_id=project_id, project_id=project_id,
)) ))
+198 -11
View File
@@ -693,6 +693,14 @@ h2.queue-title { font-size: var(--t-xs); }
display: grid; display: grid;
gap: 0.45rem; gap: 0.45rem;
} }
.steer-group { display: grid; gap: 0.35rem; }
.steer-group-label {
font-size: 0.68rem;
font-weight: 650;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--faint);
}
.steer-row { .steer-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -714,7 +722,8 @@ h2.queue-title { font-size: var(--t-xs); }
.steer-ace-slots { .steer-ace-slots {
display: grid; display: grid;
gap: 0.35rem; gap: 0.35rem;
padding-top: 0.35rem; padding-top: 0.45rem;
margin-top: 0.15rem;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
.steer-ace-head { .steer-ace-head {
@@ -726,9 +735,9 @@ h2.queue-title { font-size: var(--t-xs); }
} }
.steer-ace-head b { font-weight: 650; } .steer-ace-head b { font-weight: 650; }
.steer-slot-row { .steer-slot-row {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: auto 1fr auto;
gap: 0.35rem 0.55rem; gap: 0.45rem 0.55rem;
align-items: center; align-items: center;
font-size: 0.78rem; font-size: 0.78rem;
} }
@@ -739,7 +748,13 @@ h2.queue-title { font-size: var(--t-xs); }
flex: none; flex: none;
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.15);
} }
.steer-slot-row .grow { min-width: 0; overflow-wrap: anywhere; } .steer-slot-row .grow {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.steer-slot-row button { flex: none; white-space: nowrap; }
.queue-name { color: var(--ink); font-size: var(--t-sm); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; } .queue-name { color: var(--ink); font-size: var(--t-sm); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.queue-msg { color: var(--muted); font-size: var(--t-xs); line-height: 1.3; overflow-wrap: anywhere; } .queue-msg { color: var(--muted); font-size: var(--t-xs); line-height: 1.3; overflow-wrap: anywhere; }
.queue-sub { color: var(--faint); font-size: 0.66rem; font-variant-numeric: tabular-nums; flex: none; } .queue-sub { color: var(--faint); font-size: 0.66rem; font-variant-numeric: tabular-nums; flex: none; }
@@ -871,6 +886,63 @@ h2 { font-size: 0.95rem; margin: 0 0 0.65rem; letter-spacing: -0.02em; font-weig
padding: 0.4rem 0.55rem; padding: 0.4rem 0.55rem;
white-space: nowrap; white-space: nowrap;
} }
.stat-tools {
flex-direction: column;
align-items: stretch;
gap: 0.55rem;
}
.stat-tools .period-presets {
flex: 0 1 auto;
width: auto;
}
.stat-filters {
display: flex;
flex-wrap: wrap;
gap: 0.45rem 0.55rem;
align-items: center;
}
.stat-filters input[type="text"],
.stat-filters select {
width: auto;
min-width: 7rem;
max-width: 11rem;
flex: 0 1 auto;
}
.mat-extra-filters {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
}
.mat-extra-filters select {
width: auto;
min-width: 7.5rem;
max-width: 11rem;
padding: 0.35rem 0.55rem;
min-height: var(--control-h);
border-radius: var(--radius-sm);
}
.mat-group td {
padding-top: 0.75rem !important;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink);
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.02);
}
tr.is-archived { opacity: 0.65; }
.kpi-row.kpi-primary {
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
}
.kpi-row.kpi-secondary {
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
margin-top: -0.35rem;
opacity: 0.95;
}
.kpi.kpi-sm .kpi-value { font-size: 1.05rem; }
.kpi.kpi-empty { opacity: 0.55; }
.lead, .tiny, .summary { color: var(--muted); } .lead, .tiny, .summary { color: var(--muted); }
.summary { margin: 0.3rem 0 0; } .summary { margin: 0.3rem 0 0; }
.tiny { font-size: 0.8rem; margin: 0.2rem 0 0; line-height: 1.4; max-width: 52ch; } .tiny { font-size: 0.8rem; margin: 0.2rem 0 0; line-height: 1.4; max-width: 52ch; }
@@ -946,7 +1018,28 @@ article.machine.error:hover { border-color: rgba(248, 113, 113, 0.6); }
article.machine.offline { opacity: 0.62; } article.machine.offline { opacity: 0.62; }
article.machine.offline:hover { opacity: 1; } article.machine.offline:hover { opacity: 1; }
.machine-head { padding: 0.85rem 0.95rem 0.3rem; } .machine-head {
padding: 0.85rem 0.95rem 0.3rem;
display: grid;
grid-template-columns: 1fr;
gap: 0.55rem;
align-items: start;
}
.machine-head.has-cam {
grid-template-columns: 1fr auto;
}
.machine-head-main { min-width: 0; }
.wall-cam-thumb {
width: 4.75rem;
height: 3.35rem;
object-fit: cover;
border-radius: 9px;
border: 1px solid var(--line);
background: var(--card-2);
flex: none;
pointer-events: none;
}
.wall-cam-thumb[hidden] { display: none; }
.machine-title { .machine-title {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1899,6 +1992,31 @@ body.desk-on main {
font-size: var(--t-sm); font-size: var(--t-sm);
} }
.desk-facts b { color: var(--ink); font-weight: 650; } .desk-facts b { color: var(--ink); font-weight: 650; }
.desk-tech-facts {
gap: 0.35rem;
}
.fact-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.5rem;
border-radius: 99px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
font-size: 0.72rem;
color: var(--muted);
max-width: 100%;
}
.fact-chip .ico { width: 0.85em; height: 0.85em; opacity: 0.75; flex: none; }
.fact-chip-label { color: var(--faint); }
.fact-chip b {
color: var(--ink);
font-weight: 650;
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.desk-foot { .desk-foot {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -2320,16 +2438,33 @@ article.machine.flash { animation: flash 2s var(--ease); }
.lib-tools { .lib-tools {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.45rem; align-items: center;
margin-bottom: 0.65rem; gap: 0.35rem;
margin-bottom: 0.55rem;
position: sticky; position: sticky;
top: var(--topbar-h); top: var(--topbar-h);
z-index: 5; z-index: 5;
background: linear-gradient(180deg, var(--card) 72%, transparent); background: linear-gradient(180deg, var(--card) 72%, transparent);
padding-bottom: 0.35rem; padding-bottom: 0.3rem;
}
.lib-tools .search-wrap {
flex: 1 1 8rem;
min-width: 7rem;
max-width: 16rem;
}
.lib-tools input[type="search"] { flex: 1 1 auto; width: 100%; margin: 0; min-width: 0; }
.lib-tools select {
flex: 0 1 auto;
width: auto;
min-width: 6.5rem;
max-width: 9.5rem;
margin: 0;
padding: 0.35rem 0.5rem;
}
@media (min-width: 1100px) {
.lib-tools { flex-wrap: nowrap; }
.lib-tools .search-wrap { max-width: 12rem; }
} }
.lib-tools input[type="search"] { flex: 1 1 12rem; margin: 0; }
.lib-tools select { flex: 0 1 11rem; width: auto; margin: 0; }
.lib-group { .lib-group {
font-size: var(--t-xs); font-size: var(--t-xs);
font-weight: 650; font-weight: 650;
@@ -2486,6 +2621,27 @@ label.button {
} }
label.button:hover { background: rgba(255, 255, 255, 0.04); } label.button:hover { background: rgba(255, 255, 255, 0.04); }
.page-head-side button { display: inline-flex; align-items: center; gap: 0.4rem; } .page-head-side button { display: inline-flex; align-items: center; gap: 0.4rem; }
.proj-file-list {
display: grid;
gap: 0.35rem;
}
.proj-file, .proj-asset {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.55rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.02);
min-width: 0;
text-decoration: none;
color: inherit;
}
.proj-file { flex-direction: column; align-items: flex-start; gap: 0.15rem; }
.proj-file:hover, .proj-asset:hover { border-color: var(--line-strong); }
.proj-asset a { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
.proj-asset .tiny { flex: none; color: var(--muted); }
.lib-card { position: relative; transition: box-shadow 0.2s var(--ease); } .lib-card { position: relative; transition: box-shadow 0.2s var(--ease); }
.lib-card.drop { box-shadow: 0 0 0 2px var(--accent), var(--shadow); } .lib-card.drop { box-shadow: 0 0 0 2px var(--accent), var(--shadow); }
.drop-hint { margin: 0.6rem 0 0; font-size: var(--t-xs); color: var(--faint); text-align: center; } .drop-hint { margin: 0.6rem 0 0; font-size: var(--t-xs); color: var(--faint); text-align: center; }
@@ -3300,6 +3456,37 @@ td .cell-file {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.proto-table {
table-layout: fixed;
width: 100%;
}
.proto-table th:nth-child(1),
.proto-table td.proto-printer { width: 7.5rem; }
.proto-table th:nth-child(2),
.proto-table td.proto-file { width: auto; }
.proto-table th:nth-child(3) { width: 6.5rem; }
.proto-table th:nth-child(4),
.proto-table th:nth-child(5),
.proto-table th:nth-child(6) { width: 4.5rem; }
.proto-table th:nth-child(7) { width: 6.5rem; }
.proto-table th:nth-child(8),
.proto-table td.proto-spool { width: 10rem; }
.proto-table th:nth-child(9) { width: 5.5rem; }
.proto-table td.proto-file .cell-file {
max-width: 100%;
}
.proto-table td.proto-file .mono-badge {
margin-top: 0.15rem;
opacity: 0.85;
}
.proto-table td.proto-spool {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.proto-table td.proto-spool select {
max-width: 100%;
}
td.actions-cell { td.actions-cell {
white-space: nowrap; white-space: nowrap;
text-align: right; text-align: right;
+28 -16
View File
@@ -29,7 +29,8 @@ function sourceLabel(source, estimate) {
return source || ""; return source || "";
} }
const ENERGY_PART_COLORS = { const COST_PART_COLORS = {
filament: "#0d9488",
base: "#94a3b8", base: "#94a3b8",
nozzle: "#ea580c", nozzle: "#ea580c",
bed: "#2563eb", bed: "#2563eb",
@@ -37,19 +38,20 @@ const ENERGY_PART_COLORS = {
}; };
function kwhLabel(value) { function kwhLabel(value) {
if (value == null || value === "") return "—";
return (Number(value) || 0).toLocaleString("de-DE", { maximumFractionDigits: 3 }) + " kWh"; return (Number(value) || 0).toLocaleString("de-DE", { maximumFractionDigits: 3 }) + " kWh";
} }
function energyPieSvg(parts) { function costPieSvg(parts) {
const items = (parts || []).filter((p) => Number(p.kwh) > 0); const items = (parts || []).filter((p) => Number(p.cost) > 0);
const total = items.reduce((sum, p) => sum + Number(p.kwh || 0), 0); const total = items.reduce((sum, p) => sum + Number(p.cost || 0), 0);
if (!items.length || total <= 0) return ""; if (!items.length || total <= 0) return "";
const cx = 42; const cx = 42;
const cy = 42; const cy = 42;
const r = 36; const r = 36;
let angle = -Math.PI / 2; let angle = -Math.PI / 2;
const slices = items.map((p) => { const slices = items.map((p) => {
const share = Number(p.kwh) / total; const share = Number(p.cost) / total;
const sweep = share * Math.PI * 2; const sweep = share * Math.PI * 2;
const x1 = cx + r * Math.cos(angle); const x1 = cx + r * Math.cos(angle);
const y1 = cy + r * Math.sin(angle); const y1 = cy + r * Math.sin(angle);
@@ -57,38 +59,48 @@ function energyPieSvg(parts) {
const x2 = cx + r * Math.cos(angle); const x2 = cx + r * Math.cos(angle);
const y2 = cy + r * Math.sin(angle); const y2 = cy + r * Math.sin(angle);
const large = sweep > Math.PI ? 1 : 0; const large = sweep > Math.PI ? 1 : 0;
const color = ENERGY_PART_COLORS[p.key] || "#64748b"; const color = COST_PART_COLORS[p.key] || "#64748b";
if (share >= 0.999) { if (share >= 0.999) {
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${color}"/>`; return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${color}"/>`;
} }
return `<path d="M ${cx} ${cy} L ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)} Z" fill="${color}"/>`; return `<path d="M ${cx} ${cy} L ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)} Z" fill="${color}"/>`;
}).join(""); }).join("");
return `<svg class="energy-pie" viewBox="0 0 84 84" role="img" aria-label="Stromanteile">${slices}</svg>`; return `<svg class="energy-pie" viewBox="0 0 84 84" role="img" aria-label="Kostenanteile">${slices}</svg>`;
} }
function energyBreakdownHtml(energy) { function costBreakdownHtml(energy, filamentCost) {
const parts = energy.parts || []; const fil = Number(filamentCost) || 0;
const energyParts = (energy && energy.parts) || [];
const parts = [];
if (fil > 1e-6) {
parts.push({ key: "filament", label: "Filament", kwh: null, cost: fil });
}
energyParts.forEach((p) => parts.push(p));
if (!parts.length) return ""; if (!parts.length) return "";
const src = energy.source === "ed" ? "aus ED" : "pauschal"; const energyEuro = Number((energy && energy.cost) || 0);
const rate = energy.euro_per_kwh != null const totalEuro = fil + energyEuro;
const src = energy && energy.source === "ed" ? "Strom aus ED" : (energyParts.length ? "Strom pauschal" : "");
const rate = energy && energy.euro_per_kwh != null
? Number(energy.euro_per_kwh).toLocaleString("de-DE", { style: "currency", currency: "EUR", maximumFractionDigits: 3 }) + "/kWh" ? Number(energy.euro_per_kwh).toLocaleString("de-DE", { style: "currency", currency: "EUR", maximumFractionDigits: 3 }) + "/kWh"
: ""; : "";
const rows = parts.map((p) => { const rows = parts.map((p) => {
const color = ENERGY_PART_COLORS[p.key] || "#64748b"; const color = COST_PART_COLORS[p.key] || "#64748b";
return `<tr> return `<tr>
<td><span class="energy-swatch" style="background:${color}"></span>${escapeHtml(p.label)}</td> <td><span class="energy-swatch" style="background:${color}"></span>${escapeHtml(p.label)}</td>
<td class="num">${kwhLabel(p.kwh)}</td> <td class="num">${kwhLabel(p.kwh)}</td>
<td class="num">${euro(p.cost)}</td> <td class="num">${euro(p.cost)}</td>
</tr>`; </tr>`;
}).join(""); }).join("");
const sumKwh = energy && energy.kwh != null ? kwhLabel(energy.kwh) : "—";
const hint = [src, rate].filter(Boolean).join(" · ");
return `<div class="energy-break"> return `<div class="energy-break">
<div class="energy-break-chart">${energyPieSvg(parts)}</div> <div class="energy-break-chart">${costPieSvg(parts)}</div>
<div class="energy-break-table"> <div class="energy-break-table">
<div class="lib-head"><h3>Strom</h3><span class="tiny">${escapeHtml(src)}${rate ? " · " + rate : ""}</span></div> <div class="lib-head"><h3>Kosten</h3><span class="tiny">${escapeHtml(hint)}</span></div>
<div class="table-wrap"><table> <div class="table-wrap"><table>
<thead><tr><th>Anteil</th><th class="num">kWh</th><th class="num">€</th></tr></thead> <thead><tr><th>Anteil</th><th class="num">kWh</th><th class="num">€</th></tr></thead>
<tbody>${rows} <tbody>${rows}
<tr class="energy-total"><td>Summe</td><td class="num">${kwhLabel(energy.kwh)}</td><td class="num">${euro(energy.cost)}</td></tr> <tr class="energy-total"><td>Summe</td><td class="num">${sumKwh}</td><td class="num">${euro(totalEuro)}</td></tr>
</tbody> </tbody>
</table></div> </table></div>
</div> </div>
@@ -151,7 +163,7 @@ function render(data) {
${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""} ${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""}
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""} ${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
</div> </div>
${energyBreakdownHtml(energy)} ${costBreakdownHtml(energy, run.filament_cost)}
</div> </div>
<div class="card"> <div class="card">
<h2>Spulen</h2> <h2>Spulen</h2>
+221 -68
View File
@@ -78,11 +78,10 @@
</div> </div>
<div class="flow-step ok" data-pane="send" id="step-mode"> <div class="flow-step ok" data-pane="send" id="step-mode">
<h3><span class="step-num">3</span>Wann</h3> <h3><span class="step-num">3</span>Start</h3>
<div class="segmented" id="mode" role="radiogroup"> <div class="segmented" id="mode" role="radiogroup">
<button type="button" data-mode="now" class="on">Jetzt<small>sofort starten</small></button> <button type="button" data-mode="this" class="on">Dieser Drucker<small>frei → start, sonst Warteschlange</small></button>
<button type="button" data-mode="free">Wenn frei<small>auf diesem Drucker</small></button> <button type="button" data-mode="next">Nächster passender<small>Grotti wählt nach Sorte</small></button>
<button type="button" data-mode="next">Nächster freier<small>Grotti wählt</small></button>
</div> </div>
</div> </div>
@@ -189,7 +188,7 @@ let ablaufDefaults = {
check: { max_delta_mm: 3.0, timeout_min: 2, x: 125, y: 125, ref_x: 25, ref_y: 125, z_clear: 20 }, check: { max_delta_mm: 3.0, timeout_min: 2, x: 125, y: 125, ref_x: 25, ref_y: 125, z_clear: 20 },
}; };
let ablaufSteps = []; let ablaufSteps = [];
const sel = { fileId: null, hosts: new Set(), mode: "now", tab: "send" }; const sel = { fileId: null, hosts: new Set(), mode: "this", tab: "send" };
let aceHostsKey = ""; let aceHostsKey = "";
/* ---------- 3D-Pfad ---------- */ /* ---------- 3D-Pfad ---------- */
@@ -202,6 +201,25 @@ function ensureViewer() {
return pathViewer; return pathViewer;
} }
let toolpathGen = 0;
function scheduleToolpath(file, progress, deferMs) {
const gen = ++toolpathGen;
const run = () => {
if (gen !== toolpathGen) return;
showToolpath(file, progress);
};
if (deferMs > 0) {
if (window.requestIdleCallback) {
requestIdleCallback(run, { timeout: Math.max(800, deferMs) });
} else {
setTimeout(run, deferMs);
}
return;
}
run();
}
async function showToolpath(file, progress) { async function showToolpath(file, progress) {
const card = document.getElementById("viewer-card"); const card = document.getElementById("viewer-card");
const caption = document.getElementById("viewer-caption"); const caption = document.getElementById("viewer-caption");
@@ -221,6 +239,7 @@ async function showToolpath(file, progress) {
meta.textContent = "Pfad wird gelesen …"; meta.textContent = "Pfad wird gelesen …";
try { try {
const data = await api("/dateien/" + file.id + "/pfad?tp=8"); const data = await api("/dateien/" + file.id + "/pfad?tp=8");
if (String(sel.fileId) !== String(file.id)) return;
viewer.setPath(data); viewer.setPath(data);
viewerFileId = file.id; viewerFileId = file.id;
meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte"; meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte";
@@ -262,7 +281,9 @@ function saveSendMemory() {
function loadSendMemory() { function loadSendMemory() {
try { try {
const data = JSON.parse(localStorage.getItem(SEND_KEY) || "{}"); const data = JSON.parse(localStorage.getItem(SEND_KEY) || "{}");
if (["now", "free", "next"].includes(data.mode)) sel.mode = data.mode; let mode = data.mode;
if (mode === "now" || mode === "free") mode = "this";
if (["this", "next"].includes(mode)) sel.mode = mode;
if (["send", "ablauf"].includes(data.tab)) sel.tab = data.tab; if (["send", "ablauf"].includes(data.tab)) sel.tab = data.tab;
if (Array.isArray(data.hosts)) sel.hosts = new Set(data.hosts); if (Array.isArray(data.hosts)) sel.hosts = new Set(data.hosts);
} catch (error) { /* ignore */ } } catch (error) { /* ignore */ }
@@ -309,10 +330,22 @@ function slotTitle(host, slot) {
return bits.join(" · "); return bits.join(" · ");
} }
function paintIndexOf(filament, fallback) {
const raw = filament && filament.paint_index;
if (raw === 0 || raw === "0") return 0;
const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
function filamentLabel(filament, index) { function filamentLabel(filament, index) {
const kind = filament.type || "Filament"; const paint = paintIndexOf(filament, index);
const kind = filament.profile || filament.type || "Filament";
const color = filament.color ? " · " + filament.color : ""; const color = filament.color ? " · " + filament.color : "";
return "Farbe " + (index + 1) + " · " + kind + color; return "T" + paint + " · " + kind + color;
}
function canStartNow(p) {
return isFree(p) && !p.needs_clear;
} }
function spoolsOnHost(host) { function spoolsOnHost(host) {
@@ -326,6 +359,19 @@ function spoolWarnText() {
const maps = collectAceMaps(); const maps = collectAceMaps();
const hosts = selectedHosts(); const hosts = selectedHosts();
const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean)); const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean));
const fileProfiles = ((file.filaments || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean));
function profileOk(spoolRows) {
if (!fileProfiles.length) return true;
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
if (!names.length) return true;
return fileProfiles.every((want) => names.some((n) => {
if (n === want) return true;
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, "");
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
return !["meta", "matte", "matt", "silk", "plus", "hf", "cf"].includes(first);
}));
}
for (const host of hosts) { for (const host of hosts) {
const name = (printers.find((p) => p.host === host) || {}).name || host; const name = (printers.find((p) => p.host === host) || {}).name || host;
const mapping = maps[host] || []; const mapping = maps[host] || [];
@@ -334,18 +380,23 @@ function spoolWarnText() {
let left = 0; let left = 0;
let any = false; let any = false;
const types = []; const types = [];
const mappedSpools = [];
mapping.forEach((item) => { mapping.forEach((item) => {
const slot = Number(item.ams_index) + 1; const slot = Number(item.ams_index) + 1;
const spool = onHost.find((s) => Number(s.ace_slot) === slot); const spool = onHost.find((s) => Number(s.ace_slot) === slot);
if (spool) { if (spool) {
any = true; any = true;
left += Number(spool.remaining_g) || 0; left += Number(spool.remaining_g) || 0;
mappedSpools.push(spool);
if (spool.material_type) types.push(spool.material_type.toLowerCase()); if (spool.material_type) types.push(spool.material_type.toLowerCase());
} }
}); });
if (!any) return name + ": keine Grotti-Spule auf den gewählten ACE-Plätzen — Abzug entfällt."; if (!any) return name + ": keine Grotti-Spule auf den gewählten ACE-Plätzen — Abzug entfällt.";
if (left + 0.5 < need) return "ACE auf " + name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + "."; if (left + 0.5 < need) return "ACE auf " + name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) { if (!profileOk(mappedSpools)) {
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
}
if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", ACE " + types.join("/") + ")."; return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", ACE " + types.join("/") + ").";
} }
continue; continue;
@@ -359,8 +410,11 @@ function spoolWarnText() {
if (left + 0.5 < need) { if (left + 0.5 < need) {
return name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + "."; return name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
} }
if (!profileOk(external)) {
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
}
const types = external.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean); const types = external.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) { if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", Drucker " + types.join("/") + ")."; return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", Drucker " + types.join("/") + ").";
} }
} }
@@ -432,16 +486,17 @@ function renderAceMaps(force) {
title.className = "ace-printer-name"; title.className = "ace-printer-name";
title.textContent = printer.name; title.textContent = printer.name;
block.appendChild(title); block.appendChild(title);
paints.forEach((filament, paintIndex) => { paints.forEach((filament, uiIndex) => {
const paint = paintIndexOf(filament, uiIndex);
const row = document.createElement("label"); const row = document.createElement("label");
row.className = "ace-row"; row.className = "ace-row";
const swatch = document.createElement("span"); const swatch = document.createElement("span");
swatch.className = "ace-swatch"; swatch.className = "ace-swatch";
if (filament.color) swatch.style.background = filament.color; if (filament.color) swatch.style.background = filament.color;
const text = document.createElement("span"); const text = document.createElement("span");
text.textContent = filamentLabel(filament, paintIndex); text.textContent = filamentLabel(filament, uiIndex);
const select = document.createElement("select"); const select = document.createElement("select");
select.dataset.paint = String(paintIndex); select.dataset.paint = String(paint);
select.innerHTML = '<option value="">Platz wählen</option>'; select.innerHTML = '<option value="">Platz wählen</option>';
printer.slots.forEach((slot) => { printer.slots.forEach((slot) => {
const option = document.createElement("option"); const option = document.createElement("option");
@@ -452,16 +507,30 @@ function renderAceMaps(force) {
const hex = (spool && spool.colors && spool.colors[0] && spool.colors[0].hex) || slot.color || ""; const hex = (spool && spool.colors && spool.colors[0] && spool.colors[0].hex) || slot.color || "";
if (hex) option.style.borderLeft = "4px solid " + hex; if (hex) option.style.borderLeft = "4px solid " + hex;
const spoolType = (spool && spool.material_type) || slot.type || ""; const spoolType = (spool && spool.material_type) || slot.type || "";
// Vorauswahl: gleiche Sorte (Grotti-Spule bevorzugt), sonst Platz in Reihenfolge const spoolName = ((spool && spool.name) || "").toLowerCase();
if (!select.dataset.picked && filament.type && spoolType const wantProfile = (filament.profile || "").toLowerCase();
// Vorauswahl: Katalog-Sorte exakt (Meta ≠ Matte), sonst grober Typ
let profileHit = false;
if (wantProfile && spoolName) {
if (spoolName === wantProfile) profileHit = true;
else if (spoolName.startsWith(wantProfile) && !/[a-z0-9]/i.test(spoolName.charAt(wantProfile.length) || "")) {
const rest = spoolName.slice(wantProfile.length).replace(/^[\s·\-_|/]+/, "");
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
if (!["meta", "matte", "matt", "silk", "plus", "hf", "cf"].includes(first)) profileHit = true;
}
}
if (!select.dataset.picked && profileHit) {
option.selected = true;
select.dataset.picked = "1";
} else if (!select.dataset.picked && !wantProfile && filament.type && spoolType
&& filament.type.toLowerCase() === String(spoolType).toLowerCase()) { && filament.type.toLowerCase() === String(spoolType).toLowerCase()) {
option.selected = true; option.selected = true;
select.dataset.picked = "1"; select.dataset.picked = "1";
} }
select.appendChild(option); select.appendChild(option);
}); });
if (!select.dataset.picked && printer.slots[paintIndex]) { if (!select.dataset.picked && printer.slots[uiIndex]) {
select.value = String(printer.slots[paintIndex].index); select.value = String(printer.slots[uiIndex].index);
} }
select.addEventListener("change", updateSpoolWarn); select.addEventListener("change", updateSpoolWarn);
row.appendChild(swatch); row.appendChild(swatch);
@@ -638,8 +707,9 @@ function fillFolderFilter() {
} }
function moveSelectHtml(file) { function moveSelectHtml(file) {
if (!hasWatchDir || file.source !== "watch") return ""; if (!hasWatchDir) return "";
const current = folderKey(file); if (file.source !== "watch" && file.source !== "upload") return "";
const current = file.source === "upload" ? "" : folderKey(file);
const opts = [ const opts = [
`<option value="" selected disabled>Verschieben…</option>`, `<option value="" selected disabled>Verschieben…</option>`,
`<option value="__root__">Wurzel</option>`, `<option value="__root__">Wurzel</option>`,
@@ -701,8 +771,8 @@ function sliceOf(file) {
} }
function materialText(file) { function materialText(file) {
const types = [...new Set(((file && file.filaments) || []).map((f) => f.type).filter(Boolean))]; const labels = [...new Set(((file && file.filaments) || []).map((f) => f.profile || f.type).filter(Boolean))];
return types.join("+"); return labels.join("+");
} }
function layerHeightText(file) { function layerHeightText(file) {
@@ -757,8 +827,7 @@ function thumbHtml(file) {
function libItemHtml(file) { function libItemHtml(file) {
const project = projects.find((p) => String(p.id) === String(file.project_id)); const project = projects.find((p) => String(p.id) === String(file.project_id));
const del = file.source === "upload" const del = `<button type="button" class="icon-btn" data-delete="${file.id}" title="Datei löschen">${icon("trash")}</button>`;
? `<button type="button" class="icon-btn" data-delete="${file.id}" title="Hochgeladene Datei löschen">${icon("x")}</button>` : "";
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}"> return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
${thumbHtml(file)} ${thumbHtml(file)}
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span> <span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
@@ -882,17 +951,25 @@ function updateSendButton() {
let note = ""; let note = "";
if (!file) problem = "Erst eine Datei wählen."; if (!file) problem = "Erst eine Datei wählen.";
else if (file.kind === "3mf") problem = "Die 3MF hat keinen G-code. Mit G-code slicen."; else if (file.kind === "3mf") problem = "Die 3MF hat keinen G-code. Mit G-code slicen.";
if (sel.mode === "now") { if (sel.mode === "this") {
if (!problem && !chosen.length) problem = "Einen oder mehrere Drucker wählen."; if (!problem && !chosen.length) problem = "Einen oder mehrere Drucker wählen.";
const busy = chosen.filter((p) => !isFree(p)); const ready = chosen.filter(canStartNow);
if (!problem && busy.length) note = namesText(busy) + " " + (busy.length === 1 ? "ist" : "sind") + " nicht frei. „Wenn frei“ reiht stattdessen ein."; const later = chosen.filter((p) => !canStartNow(p));
text = chosen.length ? "Jetzt starten auf " + namesText(chosen) : "Jetzt starten"; if (ready.length && !later.length) {
} else if (sel.mode === "free") { text = "Jetzt starten auf " + namesText(ready);
text = chosen.length ? "Einreihen für " + namesText(chosen) : "Einreihen: nächster freier " + familyOfSelection(); note = "";
note = chosen.length ? "Startet von selbst, sobald der Drucker bereit und die Platte frei ist." : "Ohne Drucker: der nächste freie passende übernimmt."; } else if (!ready.length && later.length) {
text = "Einreihen für " + namesText(later);
note = "Startet, sobald der Drucker bereit und die Platte frei ist.";
} else if (ready.length && later.length) {
text = "Starten / einreihen auf " + namesText(chosen);
note = namesText(ready) + " sofort, " + namesText(later) + " in die Warteschlange.";
} else {
text = "Starten";
}
} else { } else {
text = "Einreihen: nächster freier " + familyOfSelection(); text = "Einreihen: nächster passender " + familyOfSelection();
note = "Grotti nimmt den ersten freien Drucker mit passender Maschine."; note = "Grotti wählt den nächsten freien Drucker mit passender Sorte (z. B. PLA Meta ≠ Matte).";
} }
const mismatch = chosen.filter((p) => familyMismatch(p, file)); const mismatch = chosen.filter((p) => familyMismatch(p, file));
if (!problem && mismatch.length && !document.getElementById("send-override").checked) { if (!problem && mismatch.length && !document.getElementById("send-override").checked) {
@@ -902,7 +979,7 @@ function updateSendButton() {
button.disabled = !!problem || !!sendPoll; button.disabled = !!problem || !!sendPoll;
hint.textContent = problem || note; hint.textContent = problem || note;
hint.classList.toggle("bad", !!problem); hint.classList.toggle("bad", !!problem);
targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : ""; targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti nach Sorte." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
} }
function updateAblaufButton() { function updateAblaufButton() {
@@ -971,7 +1048,7 @@ function selectFile(id) {
} }
document.querySelectorAll("#lib-list .lib-item").forEach((n) => n.classList.toggle("on", n.dataset.file === String(id))); document.querySelectorAll("#lib-list .lib-item").forEach((n) => n.classList.toggle("on", n.dataset.file === String(id)));
refreshFlow(); refreshFlow();
showToolpath(file, 0); scheduleToolpath(file, 0, 0);
if (window.matchMedia("(max-width: 1080px)").matches) { if (window.matchMedia("(max-width: 1080px)").matches) {
document.querySelector(".send-panel").scrollIntoView({ behavior: "smooth", block: "start" }); document.querySelector(".send-panel").scrollIntoView({ behavior: "smooth", block: "start" });
} }
@@ -1015,19 +1092,72 @@ async function load() {
renderLibrary(); renderLibrary();
renderTabs(); renderTabs();
refreshFlow(); refreshFlow();
const file = selectedFile(); let file = selectedFile();
if (file) showToolpath(file, 0); // Orca/Anycubic: Metadaten oft ohne paint_index → ACE zeigt T0/T1 statt T1/T10
// Deep-Link von Orca /orca_senden: Senden in den Blick, Optionen offen für ACE if (file && file.id && filamentsNeedPaintIndex(file.filaments)) {
if (new URLSearchParams(location.search).get("file") && file) { try {
const fresh = await api("/dateien/" + file.id + "/meta", { method: "POST" });
if (fresh && fresh.id) {
const idx = files.findIndex((row) => String(row.id) === String(fresh.id));
if (idx >= 0) files[idx] = fresh;
else files.unshift(fresh);
file = selectedFile();
renderLibrary();
refreshFlow();
}
} catch (error) { /* manuell: Ordner neu lesen */ }
}
const q = new URLSearchParams(location.search);
const deepLink = !!(q.get("file") || q.get("orca"));
// Deep-Link / Orca-App: ACE/Senden zuerst — 3D-Pfad friert sonst den Tab ein
if (file) scheduleToolpath(file, 0, deepLink ? 900 : 0);
if (deepLink) {
sel.tab = "send"; sel.tab = "send";
renderTabs(); renderTabs();
const opts = document.getElementById("options"); const opts = document.getElementById("options");
if (opts) opts.open = true; if (opts) opts.open = true;
const panel = document.querySelector(".send-panel"); const panel = document.querySelector(".send-panel");
if (panel) panel.scrollIntoView({ behavior: "smooth", block: "start" }); if (panel) panel.scrollIntoView({ behavior: "smooth", block: "start" });
if (file) lastOrcaFocusId = String(file.id);
if (q.get("orca")) pullOrcaFocus();
} }
} }
let lastOrcaFocusId = null;
async function pullOrcaFocus() {
try {
const data = await api("/orca-fokus");
if (!data || data.file_id == null) return;
const id = String(data.file_id);
const age = Date.now() / 1000 - Number(data.ts || 0);
if (age > 180) return;
const already = id === String(sel.fileId) && selectedFile();
if (id === lastOrcaFocusId && already) return;
const switched = lastOrcaFocusId != null && id !== lastOrcaFocusId;
lastOrcaFocusId = id;
if (already) return;
let file = files.find((row) => String(row.id) === id);
if (!file) {
files = await api("/dateien");
renderLibrary();
file = files.find((row) => String(row.id) === id);
}
if (file) {
selectFile(file.id);
sel.tab = "send";
renderTabs();
if (switched) toast("Neue Slice-Datei ausgewählt.", "good");
}
} catch (error) { /* nächster Tick */ }
}
function filamentsNeedPaintIndex(filaments) {
const rows = filaments || [];
if (rows.length < 2) return false;
return rows.some((item) => !item || item.paint_index == null);
}
async function refreshPrinters() { async function refreshPrinters() {
if (document.hidden) return; if (document.hidden) return;
try { try {
@@ -1105,32 +1235,36 @@ async function watchJobs(ids) {
await tick(); await tick();
} }
async function postSenden(hosts, whenFree) {
return api("/senden", {
method: "POST",
json: {
file_id: Number(sel.fileId),
hosts,
confirm: true,
override_machine: document.getElementById("send-override").checked,
bed_leveling: document.getElementById("send-bed-level").checked,
ace_maps: collectAceMaps(),
when_free: whenFree,
},
});
}
async function doSend() { async function doSend() {
const button = document.getElementById("send-go"); const button = document.getElementById("send-go");
const whenFree = sel.mode !== "now";
button.disabled = true; button.disabled = true;
document.getElementById("send-note").textContent = ""; document.getElementById("send-note").textContent = "";
try { try {
const payload = await api("/senden", {
method: "POST",
json: {
file_id: Number(sel.fileId),
hosts: selectedHosts(),
confirm: true,
override_machine: document.getElementById("send-override").checked,
bed_leveling: document.getElementById("send-bed-level").checked,
ace_maps: collectAceMaps(),
when_free: whenFree,
},
});
const projectId = document.getElementById("send-project")?.value; const projectId = document.getElementById("send-project")?.value;
if (projectId && sel.fileId) { if (projectId && sel.fileId) {
try { try {
await api("/dateien/" + sel.fileId, { method: "PATCH", json: { project_id: Number(projectId) } }); await api("/dateien/" + sel.fileId, { method: "PATCH", json: { project_id: Number(projectId) } });
} catch (error) { /* Projektbindung optional */ } } catch (error) { /* Projektbindung optional */ }
} }
const jobs = payload.jobs || [];
if (whenFree || payload.mode === "when_free") { if (sel.mode === "next") {
const payload = await postSenden([], true);
const jobs = payload.jobs || [];
document.getElementById("send-progress").hidden = true; document.getElementById("send-progress").hidden = true;
toast((jobs.length > 1 ? jobs.length + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good"); toast((jobs.length > 1 ? jobs.length + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good");
updateSendButton(); updateSendButton();
@@ -1138,9 +1272,35 @@ async function doSend() {
refreshPrinters(); refreshPrinters();
return; return;
} }
showJobsProgress(jobs);
const chosen = selectedPrinters();
const ready = chosen.filter(canStartNow).map((p) => p.host);
const later = chosen.filter((p) => !canStartNow(p)).map((p) => p.host);
let queued = 0;
let nowJobs = [];
if (later.length) {
const payload = await postSenden(later, true);
queued += (payload.jobs || []).length;
}
if (ready.length) {
const payload = await postSenden(ready, false);
nowJobs = payload.jobs || [];
}
if (queued && !nowJobs.length) {
document.getElementById("send-progress").hidden = true;
toast((queued > 1 ? queued + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good");
updateSendButton();
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
refreshPrinters();
return;
}
if (queued && nowJobs.length) {
toast(queued + " eingereiht, " + nowJobs.length + " startet jetzt.", "good");
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
}
showJobsProgress(nowJobs);
if (typeof refreshSendQueue === "function") refreshSendQueue(); if (typeof refreshSendQueue === "function") refreshSendQueue();
await watchJobs(jobs.map((job) => job.id)); await watchJobs(nowJobs.map((job) => job.id));
} catch (error) { } catch (error) {
updateSendButton(); updateSendButton();
toast(error.message || String(error), "bad"); toast(error.message || String(error), "bad");
@@ -1445,7 +1605,7 @@ document.getElementById("targets").addEventListener("click", (event) => {
toast("Abläufe gehen nur auf Kobra 3.", "bad"); toast("Abläufe gehen nur auf Kobra 3.", "bad");
return; return;
} }
if (sel.tab === "send" && sel.mode === "next") sel.mode = "free"; if (sel.tab === "send" && sel.mode === "next") sel.mode = "this";
if (sel.hosts.has(host)) sel.hosts.delete(host); if (sel.hosts.has(host)) sel.hosts.delete(host);
else sel.hosts.add(host); else sel.hosts.add(host);
saveSendMemory(); saveSendMemory();
@@ -1479,19 +1639,11 @@ document.getElementById("send-bed-level").addEventListener("change", updateOptio
document.getElementById("send-override").addEventListener("change", () => { refreshFlow(); }); document.getElementById("send-override").addEventListener("change", () => { refreshFlow(); });
document.getElementById("send-project")?.addEventListener("change", updateOptionsSummary); document.getElementById("send-project")?.addEventListener("change", updateOptionsSummary);
document.getElementById("send-go").addEventListener("click", (event) => { document.getElementById("send-go").addEventListener("click", () => {
const button = event.currentTarget; doSend();
const warn = spoolWarnText();
if (sel.mode === "now") {
armButton(button, warn ? "Warnung — trotzdem starten?" : "Wirklich starten? Nochmal klicken", doSend);
} else if (warn) {
armButton(button, "Warnung — trotzdem einreihen?", doSend);
} else {
doSend();
}
}); });
document.getElementById("ablauf-go").addEventListener("click", (event) => { document.getElementById("ablauf-go").addEventListener("click", () => {
armButton(event.currentTarget, "Ablauf wirklich einreihen? Nochmal klicken", doAblauf); doAblauf();
}); });
document.getElementById("ablauf-add-print").addEventListener("click", () => addStep(defaultPrintStep())); document.getElementById("ablauf-add-print").addEventListener("click", () => addStep(defaultPrintStep()));
@@ -1591,5 +1743,6 @@ loadSendMemory();
loadAblaufMemory(); loadAblaufMemory();
load().catch((error) => toast(error.message || String(error), "bad")); load().catch((error) => toast(error.message || String(error), "bad"));
setInterval(refreshPrinters, 5000); setInterval(refreshPrinters, 5000);
setInterval(pullOrcaFocus, 2000);
</script> </script>
{% endblock %} {% endblock %}
+4 -4
View File
@@ -13,15 +13,14 @@
<div class="flow-diagram"> <div class="flow-diagram">
<span class="step done">Datei</span><span class="step-sep"></span> <span class="step done">Datei</span><span class="step-sep"></span>
<span class="step done">Drucker</span><span class="step-sep"></span> <span class="step done">Drucker</span><span class="step-sep"></span>
<span class="step now">Jetzt · Wenn frei · Nächster freier</span><span class="step-sep"></span> <span class="step now">Dieser Drucker · Nächster passender</span><span class="step-sep"></span>
<span class="step">Druck</span><span class="step-sep"></span> <span class="step">Druck</span><span class="step-sep"></span>
<span class="step">Platte räumen</span> <span class="step">Platte räumen</span>
</div> </div>
<ol> <ol>
<li><strong>Datei wählen.</strong> Unter <a href="/dateien">Dateien</a> links in der Bibliothek. Der Slice-Ordner wird beobachtet, Hochladen geht per Knopf oder Ziehen.</li> <li><strong>Datei wählen.</strong> Unter <a href="/dateien">Dateien</a> links in der Bibliothek. Der Slice-Ordner wird beobachtet, Hochladen geht per Knopf oder Ziehen.</li>
<li><strong>Drucker wählen.</strong> Die Kacheln zeigen live, was jeder Drucker gerade tut. Passt die Maschine nicht zur Datei, ist die Kachel blass.</li> <li><strong>Drucker wählen.</strong> Die Kacheln zeigen live, was jeder Drucker gerade tut. Passt die Maschine nicht zur Datei, ist die Kachel blass.</li>
<li><strong>Wann.</strong> <em>Jetzt</em> startet sofort. <em>Wenn frei</em> reiht für diesen Drucker ein. <em>Nächster freier</em> lässt Grotti den ersten freien passenden Drucker nehmen.</li> <li><strong>Start.</strong> <em>Dieser Drucker</em>: frei → sofort, sonst Warteschlange. <em>Nächster passender</em>: Grotti wählt nach Maschine und Sorte (PLA Meta ≠ Matte).</li>
<li><strong>Starten.</strong> „Jetzt“ will zwei Klicks. Der zweite ist die Bestätigung.</li>
<li><strong>Während des Drucks</strong> zeigt die <a href="/">Wand</a> Fortschritt, Fertig-Uhrzeit und Temperaturen. Ein Klick auf die Karte öffnet das Cockpit mit Kamera (falls vorhanden), Steuerung, Bettnetz und 3D-Pfad. Die Seitenleiste zeigt immer, was läuft und was wartet.</li> <li><strong>Während des Drucks</strong> zeigt die <a href="/">Wand</a> Fortschritt, Fertig-Uhrzeit und Temperaturen. Ein Klick auf die Karte öffnet das Cockpit mit Kamera (falls vorhanden), Steuerung, Bettnetz und 3D-Pfad. Die Seitenleiste zeigt immer, was läuft und was wartet.</li>
<li><strong>Danach</strong> ist die Platte belegt. Ein wartender Auftrag startet erst, wenn auf der Karte <em>Platte geräumt</em> gedrückt wurde, oder ein Ablauf sie selbst freischiebt.</li> <li><strong>Danach</strong> ist die Platte belegt. Ein wartender Auftrag startet erst, wenn auf der Karte <em>Platte geräumt</em> gedrückt wurde, oder ein Ablauf sie selbst freischiebt.</li>
</ol> </ol>
@@ -49,7 +48,8 @@
<dl class="terms"> <dl class="terms">
<dt>Auftrag</dt><dd>Ein Eintrag in der Warteschlange: eine Datei für einen Drucker oder für den nächsten freien.</dd> <dt>Auftrag</dt><dd>Ein Eintrag in der Warteschlange: eine Datei für einen Drucker oder für den nächsten freien.</dd>
<dt>Ablauf</dt><dd>Auftrag mit mehreren Schritten und Wiederholungen. Nur Kobra 3.</dd> <dt>Ablauf</dt><dd>Auftrag mit mehreren Schritten und Wiederholungen. Nur Kobra 3.</dd>
<dt>Wenn frei</dt><dd>Startet, sobald der Drucker bereit und die Platte geräumt ist.</dd> <dt>Dieser Drucker</dt><dd>Frei und Platte klar → sofort; sonst Warteschlange für genau diesen Drucker.</dd>
<dt>Nächster passender</dt><dd>Grotti claimt den nächsten freien Drucker mit passender Katalog-Sorte und Restfilament.</dd>
<dt>Platte belegt</dt><dd>Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.</dd> <dt>Platte belegt</dt><dd>Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.</dd>
<dt>Alert</dt><dd>Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.</dd> <dt>Alert</dt><dd>Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.</dd>
<dt>Ereignis</dt><dd>Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.</dd> <dt>Ereignis</dt><dd>Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.</dd>
+3 -3
View File
@@ -9,7 +9,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}"> <link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}"> <link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}"> <link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=66"> <link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=68">
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}"> <link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta"> <meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
<script> <script>
@@ -81,7 +81,6 @@
<span class="nav-label">Live</span> <span class="nav-label">Live</span>
<a href="/" class="{{ 'on' if page == 'wand' else '' }}" title="Drucker"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a> <a href="/" class="{{ 'on' if page == 'wand' else '' }}" title="Drucker"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a> <a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a> <a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/auftraege" class="{{ 'on' if page == 'auftraege' else '' }} nav-desk" title="Aufträge"><svg class="ico"><use href="#i-folder"/></svg><span>Aufträge</span></a> <a href="/auftraege" class="{{ 'on' if page == 'auftraege' else '' }} nav-desk" title="Aufträge"><svg class="ico"><use href="#i-folder"/></svg><span>Aufträge</span></a>
<span class="nav-label nav-desk">Rückschau</span> <span class="nav-label nav-desk">Rückschau</span>
@@ -89,6 +88,7 @@
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-chart"/></svg><span>Statistik</span></a> <a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-chart"/></svg><span>Statistik</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a> <a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
<span class="nav-label nav-desk">Verwalten</span> <span class="nav-label nav-desk">Verwalten</span>
<a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a>
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a> <a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
<a href="/wartung" class="{{ 'on' if page == 'wartung' else '' }} nav-desk" title="Wartung"><svg class="ico"><use href="#i-wrench"/></svg><span>Wartung</span></a> <a href="/wartung" class="{{ 'on' if page == 'wartung' else '' }} nav-desk" title="Wartung"><svg class="ico"><use href="#i-wrench"/></svg><span>Wartung</span></a>
</nav> </nav>
@@ -138,8 +138,8 @@
</nav> </nav>
<dialog id="mobile-more-sheet" class="mobile-more-sheet"> <dialog id="mobile-more-sheet" class="mobile-more-sheet">
<nav aria-label="Weitere Seiten"> <nav aria-label="Weitere Seiten">
<a href="/kameras">Kameras</a>
<a href="/auftraege">Aufträge</a> <a href="/auftraege">Aufträge</a>
<a href="/projekte">Projekte</a>
<a href="/protokoll">Protokoll</a> <a href="/protokoll">Protokoll</a>
<a href="/statistik">Statistik</a> <a href="/statistik">Statistik</a>
<a href="/aktivitaet">Aktivität</a> <a href="/aktivitaet">Aktivität</a>
+118 -5
View File
@@ -40,6 +40,11 @@
<button type="button" class="fchip" data-filter="loaded">Am Drucker</button> <button type="button" class="fchip" data-filter="loaded">Am Drucker</button>
<button type="button" class="fchip" data-filter="shelf">Regal</button> <button type="button" class="fchip" data-filter="shelf">Regal</button>
<button type="button" class="fchip" data-filter="low">Wenig Rest</button> <button type="button" class="fchip" data-filter="low">Wenig Rest</button>
<button type="button" class="fchip" data-filter="archive">Archiv</button>
</div>
<div class="mat-extra-filters">
<select id="spool-filter-type" title="Sorte"><option value="">Alle Sorten</option></select>
<select id="spool-filter-color" title="Farbe"><option value="">Alle Farben</option></select>
</div> </div>
<div class="search-wrap"> <div class="search-wrap">
<svg class="ico"><use href="#i-search"/></svg> <svg class="ico"><use href="#i-search"/></svg>
@@ -141,6 +146,9 @@
<div class="actions"> <div class="actions">
<button type="submit" class="btn-primary">Speichern</button> <button type="submit" class="btn-primary">Speichern</button>
<button type="button" class="ghost" id="spool-shelf" hidden>Ins Regal</button> <button type="button" class="ghost" id="spool-shelf" hidden>Ins Regal</button>
<button type="button" class="ghost" id="spool-empty" hidden title="Rest auf 0, Regal, archivieren">Leer</button>
<button type="button" class="ghost" id="spool-archive" hidden>Archivieren</button>
<button type="button" class="ghost" id="spool-restore" hidden>Wiederherstellen</button>
<button type="button" class="ghost" id="spool-label" hidden>Etikett</button> <button type="button" class="ghost" id="spool-label" hidden>Etikett</button>
<button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button> <button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
</div> </div>
@@ -237,6 +245,8 @@ let picked = [];
let colorLabel = ""; let colorLabel = "";
let autoName = ""; let autoName = "";
let spoolFilter = "all"; let spoolFilter = "all";
let spoolTypeFilter = "";
let spoolColorFilter = "";
let spoolSearch = ""; let spoolSearch = "";
let sortenSearch = ""; let sortenSearch = "";
let view = "spools"; let view = "spools";
@@ -556,6 +566,9 @@ function fill(spool) {
document.getElementById("spool-delete").hidden = !spool; document.getElementById("spool-delete").hidden = !spool;
document.getElementById("spool-label").hidden = !spool; document.getElementById("spool-label").hidden = !spool;
document.getElementById("spool-shelf").hidden = !spool || !(spool.printer_host); document.getElementById("spool-shelf").hidden = !spool || !(spool.printer_host);
document.getElementById("spool-empty").hidden = !spool || spool.archived;
document.getElementById("spool-archive").hidden = !spool || spool.archived;
document.getElementById("spool-restore").hidden = !spool || !spool.archived;
document.getElementById("color-box").open = !spool || !picked.length; document.getElementById("color-box").open = !spool || !picked.length;
showHint(); showHint();
renderPalette(); renderPalette();
@@ -583,12 +596,30 @@ function fillSorten(material) {
renderSorten(); renderSorten();
} }
function isSpoolArchived(spool) {
return !!(spool && (spool.archived || spool.archived_at));
}
function isSpoolEmpty(spool) {
return (Number(spool.remaining_g) || 0) <= 0.5;
}
function shownSpools() { function shownSpools() {
const q = spoolSearch.trim().toLowerCase(); const q = spoolSearch.trim().toLowerCase();
const type = spoolTypeFilter.trim().toLowerCase();
const color = spoolColorFilter.trim().toLowerCase();
return spools.filter((spool) => { return spools.filter((spool) => {
const archived = isSpoolArchived(spool) || isSpoolEmpty(spool);
if (spoolFilter === "archive") {
if (!archived) return false;
} else if (archived) {
return false;
}
if (spoolFilter === "loaded" && !spool.printer_host) return false; if (spoolFilter === "loaded" && !spool.printer_host) return false;
if (spoolFilter === "shelf" && spool.printer_host) return false; if (spoolFilter === "shelf" && spool.printer_host) return false;
if (spoolFilter === "low" && (Number(spool.remaining_g) || 0) >= 100) return false; if (spoolFilter === "low" && (Number(spool.remaining_g) || 0) >= 100) return false;
if (type && (spool.material_type || "").toLowerCase() !== type) return false;
if (color && (spool.color || "").toLowerCase() !== color) return false;
if (!q) return true; if (!q) return true;
const hay = [ const hay = [
spool.name, spool.chargen_nr, spool.code, spool.material_type, spool.color, spool.name, spool.chargen_nr, spool.code, spool.material_type, spool.color,
@@ -598,6 +629,28 @@ function shownSpools() {
}); });
} }
function fillSpoolMetaFilters() {
const typeSel = document.getElementById("spool-filter-type");
const colorSel = document.getElementById("spool-filter-color");
if (!typeSel || !colorSel) return;
const types = [...new Set(spools.map((s) => (s.material_type || "").trim()).filter(Boolean))]
.sort((a, b) => a.localeCompare(b, "de"));
const colors = [...new Set(spools.map((s) => (s.color || "").trim()).filter(Boolean))]
.sort((a, b) => a.localeCompare(b, "de"));
const curType = spoolTypeFilter;
const curColor = spoolColorFilter;
typeSel.innerHTML = '<option value="">Alle Sorten</option>' + types.map((t) =>
`<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`
).join("");
colorSel.innerHTML = '<option value="">Alle Farben</option>' + colors.map((c) =>
`<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`
).join("");
typeSel.value = types.includes(curType) ? curType : "";
colorSel.value = colors.includes(curColor) ? curColor : "";
spoolTypeFilter = typeSel.value;
spoolColorFilter = colorSel.value;
}
function shownSorten() { function shownSorten() {
const q = sortenSearch.trim().toLowerCase(); const q = sortenSearch.trim().toLowerCase();
if (!q) return materials; if (!q) return materials;
@@ -635,11 +688,15 @@ function renderSpools() {
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter); btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
}); });
fillSelects(); fillSelects();
fillSpoolMetaFilters();
const body = document.getElementById("spool-rows"); const body = document.getElementById("spool-rows");
body.innerHTML = ""; body.innerHTML = "";
const shown = shownSpools(); const shown = shownSpools();
document.getElementById("spool-count").textContent = String(shown.length); const activeCount = spools.filter((s) => !isSpoolArchived(s) && !isSpoolEmpty(s)).length;
document.getElementById("spool-count").textContent = String(
spoolFilter === "archive" ? shown.length : activeCount
);
const note = document.getElementById("spool-list-note"); const note = document.getElementById("spool-list-note");
if (!spools.length) { if (!spools.length) {
note.textContent = ""; note.textContent = "";
@@ -651,9 +708,11 @@ function renderSpools() {
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>'; body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>';
return; return;
} }
note.textContent = shown.length === spools.length note.textContent = spoolFilter === "archive"
? (shown.length === 1 ? "1 Spule" : shown.length + " Spulen") ? (shown.length === 1 ? "1 archivierte Spule" : shown.length + " archivierte Spulen")
: shown.length + " von " + spools.length; : shown.length === activeCount
? (shown.length === 1 ? "1 Spule" : shown.length + " Spulen")
: shown.length + " von " + activeCount;
const ordered = [...shown].sort((a, b) => { const ordered = [...shown].sort((a, b) => {
const ah = a.printer_host || "zzz"; const ah = a.printer_host || "zzz";
@@ -671,7 +730,7 @@ function renderSpools() {
body.appendChild(head); body.appendChild(head);
} }
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.className = "pick"; tr.className = "pick" + (isSpoolArchived(spool) || isSpoolEmpty(spool) ? " is-archived" : "");
if (spoolPanelOpen && String(spool.id) === document.getElementById("spool-id").value) tr.classList.add("on"); if (spoolPanelOpen && String(spool.id) === document.getElementById("spool-id").value) tr.classList.add("on");
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td class=\"num\"></td>"; tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td class=\"num\"></td>";
tr.children[0].textContent = spool.name; tr.children[0].textContent = spool.name;
@@ -876,6 +935,52 @@ document.getElementById("spool-shelf").addEventListener("click", async () => {
showNote("spool-note", error); showNote("spool-note", error);
} }
}); });
document.getElementById("spool-empty").addEventListener("click", () => {
const id = document.getElementById("spool-id").value;
if (!id) return;
armButton(document.getElementById("spool-empty"), "Als leer markieren?", async () => {
try {
const saved = await api("/spulen/" + id + "/leer", { method: "POST", json: {} });
toast("Spule als leer archiviert.");
await load();
fill(saved);
} catch (error) {
showNote("spool-note", error);
}
});
});
document.getElementById("spool-archive").addEventListener("click", async () => {
const id = document.getElementById("spool-id").value;
if (!id) return;
try {
const saved = await api("/spulen/" + id, {
method: "PATCH",
json: { archived: true, printer_host: "", ace_slot: null },
});
toast("Spule archiviert.");
await load();
fill(saved);
} catch (error) {
showNote("spool-note", error);
}
});
document.getElementById("spool-restore").addEventListener("click", async () => {
const id = document.getElementById("spool-id").value;
if (!id) return;
try {
const left = Number(document.getElementById("spool-left").value) || 0;
const saved = await api("/spulen/" + id, {
method: "PATCH",
json: { archived: false, remaining_g: left > 0.5 ? left : 1000 },
});
toast("Spule wiederhergestellt.");
spoolFilter = "all";
await load();
fill(saved);
} catch (error) {
showNote("spool-note", error);
}
});
document.getElementById("spool-label").addEventListener("click", () => { document.getElementById("spool-label").addEventListener("click", () => {
const id = document.getElementById("spool-id").value; const id = document.getElementById("spool-id").value;
if (id) window.open("/etikett/" + id, "_blank", "noopener"); if (id) window.open("/etikett/" + id, "_blank", "noopener");
@@ -890,6 +995,14 @@ document.getElementById("spool-delete").addEventListener("click", () => {
closeSpoolPanel(); closeSpoolPanel();
}); });
}); });
document.getElementById("spool-filter-type")?.addEventListener("change", (event) => {
spoolTypeFilter = event.target.value || "";
renderSpools();
});
document.getElementById("spool-filter-color")?.addEventListener("change", (event) => {
spoolColorFilter = event.target.value || "";
renderSpools();
});
document.getElementById("sorten-new").addEventListener("click", () => { document.getElementById("sorten-new").addEventListener("click", () => {
fillSorten(null); fillSorten(null);
+311
View File
@@ -0,0 +1,311 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Projekte</h1>
<p class="tiny">Arbeitsmappen mit Ordner unter dem Slice-Pfad — G-code und Konstruktionsdaten.</p>
</div>
<div class="actions">
<button type="button" class="btn-primary" id="proj-new"><svg class="ico"><use href="#i-plus"/></svg>Neues Projekt</button>
</div>
</div>
<div class="split">
<section class="card">
<div class="card-head lib-head">
<svg class="ico head-ico"><use href="#i-folder"/></svg>
<h2>Liste</h2>
<span class="count-pill" id="proj-count">0</span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th></tr></thead>
<tbody id="proj-rows"></tbody>
</table>
</div>
</section>
<section class="card" id="proj-panel">
<h2 id="proj-title">Neues Projekt</h2>
<form id="proj-form" novalidate>
<input id="proj-id" type="hidden">
<div class="field"><label for="proj-name">Name</label><input id="proj-name" required autocomplete="off"></div>
<div class="grid-2">
<div class="field">
<label for="proj-status">Status</label>
<select id="proj-status">
<option value="idee">Idee</option>
<option value="bereit">bereit</option>
<option value="druckt">druckt</option>
<option value="fertig">fertig</option>
</select>
</div>
<div class="field">
<label for="proj-folder">Ordner <span class="tip" title="Relativ unter dem Slice-Ordner, z. B. Projekte/2609_002 — kein D:\…"><svg class="ico"><use href="#i-info"/></svg></span></label>
<input id="proj-folder" placeholder="Projekte/…" autocomplete="off">
<p class="field-help">Leer = automatisch <code>Projekte/&lt;Name&gt;</code>. Nur Relativpfad, kein Laufwerk.</p>
</div>
</div>
<div class="field"><label for="proj-notes">Notiz</label><textarea id="proj-notes" rows="3"></textarea></div>
<div class="actions">
<button type="submit" class="btn-primary" id="proj-save">Speichern</button>
<a class="ghost" id="proj-files-link" href="/dateien" hidden>Dateien</a>
<button type="button" class="warn" id="proj-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
</div>
</form>
<p id="proj-note" class="tiny"></p>
<div id="proj-detail" hidden>
<h3 class="section-gap">Druckdateien</h3>
<div id="proj-gcodes" class="proj-file-list"></div>
<h3 class="section-gap">Weitere Dateien</h3>
<p class="tiny">STEP, STL, PDF, SolidWorks usw. im Projektordner — nicht in der Druck-Bibliothek.</p>
<div class="actions" style="margin-bottom:0.5rem">
<label class="button ghost" for="proj-asset-file"><svg class="ico"><use href="#i-upload"/></svg>Hochladen</label>
<input id="proj-asset-file" type="file" multiple hidden>
</div>
<div id="proj-assets" class="proj-file-list"></div>
</div>
</section>
</div>
{% endblock %}
{% block script %}
<script>
const STATUS_LABEL = { idee: "Idee", bereit: "bereit", druckt: "druckt", fertig: "fertig" };
let projects = [];
let selectedId = null;
function fillBlank() {
selectedId = null;
document.getElementById("proj-id").value = "";
document.getElementById("proj-name").value = "";
document.getElementById("proj-status").value = "idee";
document.getElementById("proj-folder").value = "";
document.getElementById("proj-notes").value = "";
document.getElementById("proj-title").textContent = "Neues Projekt";
document.getElementById("proj-delete").hidden = true;
document.getElementById("proj-files-link").hidden = true;
document.getElementById("proj-detail").hidden = true;
document.getElementById("proj-note").textContent = "";
renderList();
}
function fill(project) {
selectedId = project.id;
document.getElementById("proj-id").value = project.id;
document.getElementById("proj-name").value = project.name || "";
document.getElementById("proj-status").value = project.status || "idee";
document.getElementById("proj-folder").value = project.folder || "";
document.getElementById("proj-notes").value = project.notes || "";
document.getElementById("proj-title").textContent = project.name || "Projekt";
document.getElementById("proj-delete").hidden = false;
const link = document.getElementById("proj-files-link");
link.hidden = false;
link.href = "/dateien";
document.getElementById("proj-detail").hidden = false;
document.getElementById("proj-note").textContent = "";
renderList();
renderDetail(project);
}
function renderList() {
const body = document.getElementById("proj-rows");
document.getElementById("proj-count").textContent = String(projects.length);
if (!projects.length) {
body.innerHTML = '<tr><td class="empty" colspan="4"><div class="empty-state">' + icon("folder") + "<p>Noch kein Projekt.</p></div></td></tr>";
return;
}
body.innerHTML = "";
projects.forEach((project) => {
const tr = document.createElement("tr");
tr.className = "pick" + (String(project.id) === String(selectedId) ? " on" : "");
tr.innerHTML = "<td></td><td></td><td></td><td class=\"num\"></td>";
tr.children[0].textContent = project.name;
tr.children[1].textContent = STATUS_LABEL[project.status] || project.status || "";
tr.children[2].textContent = project.folder || "—";
tr.children[2].title = project.folder || "";
tr.children[3].textContent = String(project.file_count ?? (project.files || []).length);
tr.addEventListener("click", () => openProject(project.id));
body.appendChild(tr);
});
}
function renderDetail(project) {
const gcodes = document.getElementById("proj-gcodes");
const files = project.files || [];
if (!files.length) {
gcodes.innerHTML = '<p class="tiny">Keine G-code/3MF-Dateien verknüpft. Dateien in den Projektordner legen oder unter Dateien verschieben.</p>';
} else {
gcodes.innerHTML = files.map((file) => {
const mats = (file.filaments || []).map((f) => f.type || f.material_type || f.name || "").filter(Boolean);
const mat = [...new Set(mats)].join(" / ");
const meta = [mat, file.weight_g ? Math.round(file.weight_g) + " g" : ""].filter(Boolean).join(" · ");
return `<a class="proj-file" href="/dateien">${escapeHtml(file.name)}<span class="tiny">${escapeHtml(meta)}</span></a>`;
}).join("");
}
loadAssets(project.id);
}
async function loadAssets(projectId) {
const box = document.getElementById("proj-assets");
try {
const rows = await api("/projekte/" + projectId + "/assets");
if (!rows.length) {
box.innerHTML = '<p class="tiny">Noch keine weiteren Dateien.</p>';
return;
}
box.innerHTML = rows.map((item) => {
const href = withToken("/api/v1/projekte/" + projectId + "/assets/" + encodeURIComponent(item.rel).replace(/%2F/g, "/"));
return `<div class="proj-asset">
<a href="${escapeHtml(href)}" download>${escapeHtml(item.rel)}</a>
<span class="tiny">${escapeHtml(sizeText(item.size_bytes))}</span>
<button type="button" class="icon-btn" data-asset-del="${escapeHtml(item.rel)}" title="Löschen">${icon("trash")}</button>
</div>`;
}).join("");
} catch (error) {
box.innerHTML = '<p class="tiny">' + escapeHtml(error.message || String(error)) + "</p>";
}
}
function sizeText(bytes) {
const n = Number(bytes) || 0;
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1).replace(".", ",") + " KB";
return (n / (1024 * 1024)).toFixed(1).replace(".", ",") + " MB";
}
async function openProject(id) {
try {
const row = await api("/projekte/" + id);
fill(row);
} catch (error) {
showNote("proj-note", error);
}
}
async function load() {
projects = await api("/projekte");
renderList();
if (selectedId) {
const still = projects.find((p) => String(p.id) === String(selectedId));
if (still) await openProject(selectedId);
else fillBlank();
}
}
let folderTouched = false;
document.getElementById("proj-new").addEventListener("click", () => {
folderTouched = false;
fillBlank();
document.getElementById("proj-name").focus();
});
document.getElementById("proj-folder").addEventListener("input", () => {
folderTouched = true;
});
document.getElementById("proj-name").addEventListener("input", () => {
if (folderTouched || document.getElementById("proj-id").value) return;
const name = document.getElementById("proj-name").value.trim();
const folderEl = document.getElementById("proj-folder");
if (!name) {
folderEl.value = "";
return;
}
const slug = name
.replace(/[^\w\- äöüÄÖÜß.]+/gi, "-")
.replace(/\s+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^[-.]+|[-.]+$/g, "")
.slice(0, 60) || "projekt";
folderEl.value = "Projekte/" + slug;
});
document.getElementById("proj-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("proj-id").value;
const name = document.getElementById("proj-name").value.trim();
if (!name) {
toast("Name fehlt.", "bad");
document.getElementById("proj-name").focus();
return;
}
const folderRaw = document.getElementById("proj-folder").value.trim();
if (/^[a-zA-Z]:[\\/]/.test(folderRaw) || folderRaw.startsWith("\\\\") || folderRaw.includes(":")) {
toast("Ordner nur relativ unter dem Slice-Pfad — z. B. Projekte/2609_002, kein D:\\…", "bad");
document.getElementById("proj-folder").focus();
return;
}
const payload = {
name,
status: document.getElementById("proj-status").value,
notes: document.getElementById("proj-notes").value,
folder: folderRaw,
};
const saveBtn = document.getElementById("proj-save");
saveBtn.disabled = true;
try {
const saved = id
? await api("/projekte/" + id, { method: "PATCH", json: payload })
: await api("/projekte", { method: "POST", json: payload });
toast(id ? "Projekt gespeichert." : "Projekt angelegt.", "good");
folderTouched = true;
await load();
await openProject(saved.id);
} catch (error) {
showNote("proj-note", error);
} finally {
saveBtn.disabled = false;
}
});
document.getElementById("proj-delete").addEventListener("click", () => {
const id = document.getElementById("proj-id").value;
if (!id) return;
armButton(document.getElementById("proj-delete"), "Ordner inkl. Inhalt?", async () => {
try {
await api("/projekte/" + id + "?force=1", { method: "DELETE" });
toast("Projekt gelöscht.");
fillBlank();
await load();
} catch (error) {
showNote("proj-note", error);
}
});
});
document.getElementById("proj-asset-file").addEventListener("change", async (event) => {
const id = document.getElementById("proj-id").value;
const files = [...(event.target.files || [])];
event.target.value = "";
if (!id || !files.length) return;
try {
for (const file of files) {
const body = new FormData();
body.append("file", file);
await api("/projekte/" + id + "/assets", { method: "POST", body });
}
toast(files.length === 1 ? "Datei hochgeladen." : files.length + " Dateien hochgeladen.");
await loadAssets(id);
} catch (error) {
showNote("proj-note", error);
}
});
document.getElementById("proj-assets").addEventListener("click", (event) => {
const btn = event.target.closest("[data-asset-del]");
if (!btn) return;
const id = document.getElementById("proj-id").value;
const rel = btn.dataset.assetDel;
if (!id || !rel) return;
armButton(btn, "löschen?", async () => {
await api("/projekte/" + id + "/assets/" + encodeURIComponent(rel).replace(/%2F/g, "/"), { method: "DELETE" });
toast("Datei gelöscht.");
await loadAssets(id);
});
});
load().catch((error) => showNote("proj-note", error));
</script>
{% endblock %}
+19 -3
View File
@@ -23,7 +23,7 @@
</div> </div>
<p id="run-note"></p> <p id="run-note"></p>
<div class="card table-wrap"> <div class="card table-wrap">
<table> <table class="proto-table">
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead> <thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
<tbody id="run-rows"></tbody> <tbody id="run-rows"></tbody>
</table> </table>
@@ -65,6 +65,16 @@ function filteredRuns() {
}); });
} }
function shortRunName(filename) {
let name = (filename || "").split("/").pop() || "";
if (!name) return "";
name = name.replace(/^\d{8}-\d{6}-/, "");
name = name.replace(/^\d{4}-\d{4}-/, "");
name = name.replace(/\.gcode$/i, "");
name = name.replace(/_\d+(?:\.\d+)?_(?:\d+h)?(?:\d+m)?(?:\d+s)?$/i, "");
return name || (filename || "").split("/").pop() || "";
}
function render() { function render() {
fillPrinterFilter(); fillPrinterFilter();
const body = document.getElementById("run-rows"); const body = document.getElementById("run-rows");
@@ -83,12 +93,15 @@ function render() {
tr.className = "pick"; tr.className = "pick";
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>"; tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
tr.children[0].textContent = run.printer_name; tr.children[0].textContent = run.printer_name;
tr.children[0].className = "proto-printer";
const fileCell = tr.children[1]; const fileCell = tr.children[1];
fileCell.className = "proto-file";
const fullName = (run.filename || "").split("/").pop() || ("#" + run.id);
const link = document.createElement("a"); const link = document.createElement("a");
link.href = "/protokoll/" + run.id; link.href = "/protokoll/" + run.id;
link.className = "cell-file"; link.className = "cell-file";
link.title = (run.filename || "").split("/").pop() || ("#" + run.id); link.title = fullName;
link.textContent = (run.filename || "").split("/").pop() || ("#" + run.id); link.textContent = shortRunName(run.filename) || fullName;
fileCell.appendChild(link); fileCell.appendChild(link);
if (run.chargen_nr) { if (run.chargen_nr) {
const code = document.createElement("div"); const code = document.createElement("div");
@@ -167,7 +180,10 @@ function render() {
} else { } else {
const spool = spools.find((item) => item.id === run.spool_id); const spool = spools.find((item) => item.id === run.spool_id);
tr.children[7].textContent = spool ? spool.name : "abgezogen"; tr.children[7].textContent = spool ? spool.name : "abgezogen";
tr.children[7].title = spool ? spool.name : "";
tr.children[7].className = "proto-spool";
} }
if (!tr.children[7].className) tr.children[7].className = "proto-spool";
if (run.file_id) { if (run.file_id) {
const again = document.createElement("button"); const again = document.createElement("button");
again.type = "button"; again.type = "button";
+37 -32
View File
@@ -9,7 +9,7 @@
<a class="ghost" id="csv-link" href="/api/v1/statistik.csv">CSV</a> <a class="ghost" id="csv-link" href="/api/v1/statistik.csv">CSV</a>
</div> </div>
</div> </div>
<div class="toolbar page-tools"> <div class="toolbar page-tools stat-tools">
<div class="segmented period-presets fchips" id="period" role="group" aria-label="Zeitraum"> <div class="segmented period-presets fchips" id="period" role="group" aria-label="Zeitraum">
<button type="button" data-period="all" class="fchip on">Alles</button> <button type="button" data-period="all" class="fchip on">Alles</button>
<button type="button" data-period="day" class="fchip">Tag</button> <button type="button" data-period="day" class="fchip">Tag</button>
@@ -19,20 +19,24 @@
<button type="button" data-period="last_week" class="fchip">Letzte Woche</button> <button type="button" data-period="last_week" class="fchip">Letzte Woche</button>
<button type="button" data-period="last_month" class="fchip">Letzter Monat</button> <button type="button" data-period="last_month" class="fchip">Letzter Monat</button>
</div> </div>
<input type="date" id="since" title="Von" class="date-field"> <div class="stat-filters">
<input type="date" id="until" title="Bis" class="date-field"> <input type="date" id="since" title="Von" class="date-field">
<select id="host"><option value="">Alle Drucker</option></select> <input type="date" id="until" title="Bis" class="date-field">
<input type="text" id="material" placeholder="Sorte" size="10"> <select id="host"><option value="">Alle Drucker</option></select>
<input type="text" id="brand" placeholder="Marke" size="10"> <input type="text" id="material" placeholder="Sorte" size="8" autocomplete="off">
<div class="tools-end"><button type="button" id="reload" class="ghost"><svg class="ico"><use href="#i-refresh"/></svg>Aktualisieren</button></div> <input type="text" id="brand" placeholder="Marke" size="8" autocomplete="off">
<input type="text" id="color" placeholder="Farbe" size="8" autocomplete="off">
</div>
</div> </div>
<p id="stat-note"></p> <p id="stat-note"></p>
<div id="stat" class="statistik"></div> <div id="stat" class="statistik"></div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script> <script>
function kpi(label, value, hint, ico) { function kpi(label, value, hint, ico, extraClass) {
return `<div class="kpi"><div class="kpi-label">${ico ? icon(ico) : ""}${escapeHtml(label)}</div><div class="kpi-value">${value}</div>${hint ? '<div class="kpi-sub">' + escapeHtml(hint) + "</div>" : ""}</div>`; const empty = !value || value === "—" || value === "0 %";
const cls = ["kpi", extraClass || "", empty ? "kpi-empty" : ""].filter(Boolean).join(" ");
return `<div class="${cls}"><div class="kpi-label">${ico ? icon(ico) : ""}${escapeHtml(label)}</div><div class="kpi-value">${value}</div>${hint ? '<div class="kpi-sub">' + escapeHtml(hint) + "</div>" : ""}</div>`;
} }
function isoDate(date) { function isoDate(date) {
@@ -165,10 +169,11 @@ function render(data) {
<tr> <tr>
<td>${escapeHtml(m.material_type || "—")}</td> <td>${escapeHtml(m.material_type || "—")}</td>
<td>${escapeHtml(m.brand || "")}</td> <td>${escapeHtml(m.brand || "")}</td>
<td>${escapeHtml(m.color || "")}</td>
<td class="num">${grams(m.weight_g)}</td> <td class="num">${grams(m.weight_g)}</td>
<td class="num">${euro(m.cost)}</td> <td class="num">${euro(m.cost)}</td>
<td class="num">${m.jobs}</td> <td class="num">${m.jobs}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="5">Kein Spulenabzug im Zeitraum.</td></tr>'; </tr>`).join("") || '<tr><td class="empty" colspan="6">Kein Spulenabzug im Zeitraum.</td></tr>';
const chartPrinters = (data.by_printer || []).map((p) => ({ const chartPrinters = (data.by_printer || []).map((p) => ({
...p, ...p,
@@ -180,16 +185,18 @@ function render(data) {
})); }));
document.getElementById("stat").innerHTML = ` document.getElementById("stat").innerHTML = `
<div class="kpi-row"> <div class="kpi-row kpi-primary">
${kpi("Läufe", String(jobs), "", "layers")} ${kpi("Läufe", String(jobs), "", "layers")}
${kpi("Druckzeit", hours(o.duration_s), "", "clock")} ${kpi("Druckzeit", hours(o.duration_s), "", "clock")}
${kpi("Verbrauch", grams(o.weight_g), "", "weight")} ${kpi("Verbrauch", grams(o.weight_g), "", "weight")}
${kpi("Kosten", euro(o.cost), "", "euro")} ${kpi("Kosten", euro(o.cost), "", "euro")}
${kpi("Erfolg", success + " %", (o.completed || 0) + " fertig · " + (o.failed || 0) + " abgebrochen", "circle-check")} ${kpi("Erfolg", success + " %", (o.completed || 0) + " fertig · " + (o.failed || 0) + " abgebrochen", "circle-check")}
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss", "ban")} </div>
${kpi("Stück-Yield", o.yield_percent != null ? Math.round(o.yield_percent) + " %" : "—", (o.pieces_ok || 0) + " gut · " + (o.pieces_bad || 0) + " schlecht", "percent")} <div class="kpi-row kpi-secondary">
${kpi("€ / Teil", o.cost_per_part != null ? euro(o.cost_per_part) : "—", "Material + Strom ÷ gut", "tag")} ${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss", "ban", "kpi-sm")}
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit", "gauge")} ${kpi("Stück-Yield", o.yield_percent != null ? Math.round(o.yield_percent) + " %" : "—", (o.pieces_ok || 0) + " gut · " + (o.pieces_bad || 0) + " schlecht", "percent", "kpi-sm")}
${kpi("€ / Teil", o.cost_per_part != null ? euro(o.cost_per_part) : "—", "Material + Strom ÷ gut", "tag", "kpi-sm")}
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit", "gauge", "kpi-sm")}
</div> </div>
<div class="split"> <div class="split">
<div class="card"> <div class="card">
@@ -198,7 +205,7 @@ function render(data) {
</div> </div>
<div class="card"> <div class="card">
<h2>Verbrauch je Material</h2> <h2>Verbrauch je Material</h2>
${barChart(chartMaterials, "weight_g", (m) => [m.material_type, m.brand].filter(Boolean).join(" · ") || "—")} ${barChart(chartMaterials, "weight_g", (m) => [m.material_type, m.brand, m.color].filter(Boolean).join(" · ") || "—")}
</div> </div>
</div> </div>
<div class="card"> <div class="card">
@@ -218,7 +225,7 @@ function render(data) {
<div class="card"> <div class="card">
<h2>Nach Material</h2> <h2>Nach Material</h2>
<div class="table-wrap"><table> <div class="table-wrap"><table>
<thead><tr><th>Sorte</th><th>Marke</th><th>Gramm</th><th>Kosten</th><th>Läufe</th></tr></thead> <thead><tr><th>Sorte</th><th>Marke</th><th>Farbe</th><th>Gramm</th><th>Kosten</th><th>Läufe</th></tr></thead>
<tbody>${materials}</tbody> <tbody>${materials}</tbody>
</table></div> </table></div>
</div>`; </div>`;
@@ -231,17 +238,25 @@ async function load() {
const host = document.getElementById("host").value; const host = document.getElementById("host").value;
const material = document.getElementById("material").value.trim(); const material = document.getElementById("material").value.trim();
const brand = document.getElementById("brand").value.trim(); const brand = document.getElementById("brand").value.trim();
const color = document.getElementById("color").value.trim();
if (since) params.set("since", since); if (since) params.set("since", since);
if (until) params.set("until", until); if (until) params.set("until", until);
if (host) params.set("host", host); if (host) params.set("host", host);
if (material) params.set("material", material); if (material) params.set("material", material);
if (brand) params.set("brand", brand); if (brand) params.set("brand", brand);
if (color) params.set("color", color);
const q = params.toString(); const q = params.toString();
document.getElementById("csv-link").href = "/api/v1/statistik.csv" + (q ? "?" + q : ""); document.getElementById("csv-link").href = "/api/v1/statistik.csv" + (q ? "?" + q : "");
const data = await api("/statistik" + (q ? "?" + q : "")); const data = await api("/statistik" + (q ? "?" + q : ""));
render(data); render(data);
} }
let filterTimer = null;
function scheduleLoad() {
clearTimeout(filterTimer);
filterTimer = setTimeout(() => load().catch((error) => showNote("stat-note", error)), 280);
}
function applyPeriod(key) { function applyPeriod(key) {
const range = periodRange(key); const range = periodRange(key);
document.getElementById("since").value = range.since; document.getElementById("since").value = range.since;
@@ -256,28 +271,18 @@ document.getElementById("period").addEventListener("click", (event) => {
applyPeriod(button.dataset.period); applyPeriod(button.dataset.period);
}); });
document.getElementById("reload").addEventListener("click", () => {
load().catch((error) => showNote("stat-note", error));
});
["since", "until"].forEach((id) => { ["since", "until"].forEach((id) => {
document.getElementById(id).addEventListener("change", () => { document.getElementById(id).addEventListener("change", () => {
clearPeriodActive(); clearPeriodActive();
load().catch((error) => showNote("stat-note", error)); scheduleLoad();
}); });
}); });
document.getElementById("host").addEventListener("change", () => { document.getElementById("host").addEventListener("change", scheduleLoad);
load().catch((error) => showNote("stat-note", error)); ["material", "brand", "color"].forEach((id) => {
});
["material", "brand"].forEach((id) => {
const node = document.getElementById(id); const node = document.getElementById(id);
if (!node) return; if (!node) return;
node.addEventListener("change", () => load().catch((error) => showNote("stat-note", error))); node.addEventListener("input", scheduleLoad);
node.addEventListener("keydown", (event) => { node.addEventListener("change", scheduleLoad);
if (event.key === "Enter") {
event.preventDefault();
load().catch((error) => showNote("stat-note", error));
}
});
}); });
load().catch((error) => showNote("stat-note", error)); load().catch((error) => showNote("stat-note", error));
</script> </script>
+121 -32
View File
@@ -340,6 +340,14 @@ function flowHtml(printer) {
return bits.join(""); return bits.join("");
} }
function filamentPauseHint(printer) {
if (!printer) return "";
if (printer.filament_empty) return printer.pause_reason || printer.message || "Filament leer";
const text = String(printer.pause_reason || printer.message || "");
if (/filament|spule|material.?leer|gerissen|runout|10107/i.test(text)) return text;
return "";
}
function stripsHtml(printer, state) { function stripsHtml(printer, state) {
const bits = []; const bits = [];
const busy = state === "printing" || state === "paused" || state === "heating"; const busy = state === "printing" || state === "paused" || state === "heating";
@@ -348,6 +356,14 @@ function stripsHtml(printer, state) {
if (state === "error") { if (state === "error") {
bits.push(`<div class="strip bad">${icon("alert")}<span class="grow">${escapeHtml(printer.message || "Drucker meldet einen Fehler.")}</span><button type="button" class="warn" data-cmd="clear_error">Fehler löschen</button></div>`); bits.push(`<div class="strip bad">${icon("alert")}<span class="grow">${escapeHtml(printer.message || "Drucker meldet einen Fehler.")}</span><button type="button" class="warn" data-cmd="clear_error">Fehler löschen</button></div>`);
} }
const filamentHint = state === "paused" ? filamentPauseHint(printer) : "";
if (filamentHint) {
bits.push(`<div class="strip warn filament-swap">
<div class="strip-head">${icon("alert")}<span class="grow"><b>Neue Rolle in Material dem Drucker zuweisen</b></span></div>
<span class="strip-msg">${escapeHtml(filamentHint)} — sonst zählt der Verbrauch weiter zur alten Spule. Ort = dieser Drucker, speichern, dann Weiter.</span>
<div class="strip-actions"><a class="ghost" href="/material">Zu Material</a></div>
</div>`);
}
const failed = !queueBusy && printer.queue_failed; const failed = !queueBusy && printer.queue_failed;
if (failed) { if (failed) {
const step = failed.step_label_current || "Schritt " + ((Number(failed.step_index) || 0) + 1); const step = failed.step_label_current || "Schritt " + ((Number(failed.step_index) || 0) + 1);
@@ -417,7 +433,11 @@ function aceSlotLabel(slot) {
function aceSteerHtml(printer) { function aceSteerHtml(printer) {
const dryers = printer.dryers || []; const dryers = printer.dryers || [];
if (!dryers.length) return ""; const loaded = wallSpools
.filter((s) => s.printer_host === printer.host && s.ace_slot)
.slice()
.sort((a, b) => Number(a.ace_slot) - Number(b.ace_slot));
if (!dryers.length && !loaded.length) return "";
const bits = []; const bits = [];
dryers.forEach((dryer) => { dryers.forEach((dryer) => {
const id = Number(dryer.id); const id = Number(dryer.id);
@@ -443,10 +463,6 @@ function aceSteerHtml(printer) {
</div> </div>
</div>`); </div>`);
}); });
const loaded = wallSpools
.filter((s) => s.printer_host === printer.host && s.ace_slot)
.slice()
.sort((a, b) => Number(a.ace_slot) - Number(b.ace_slot));
if (loaded.length) { if (loaded.length) {
bits.push(`<div class="steer-ace-slots"><div class="steer-ace-head"><b>Plätze</b><span class="tiny">Sorte und Farbe aus Grotti</span></div>`); bits.push(`<div class="steer-ace-slots"><div class="steer-ace-head"><b>Plätze</b><span class="tiny">Sorte und Farbe aus Grotti</span></div>`);
loaded.forEach((spool) => { loaded.forEach((spool) => {
@@ -472,16 +488,22 @@ function steerPanelHtml(printer) {
const fan = typeof printer.fan === "number" ? Math.round(printer.fan * 100) : 0; const fan = typeof printer.fan === "number" ? Math.round(printer.fan * 100) : 0;
const aceKey = aceSteerKey(printer); const aceKey = aceSteerKey(printer);
return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}" data-ace-key="${escapeHtml(aceKey)}"> return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}" data-ace-key="${escapeHtml(aceKey)}">
<div class="steer-row"> <div class="steer-group">
<label>${icon("nozzle")} Düse <input type="number" data-steer="nozzle" data-steer-action="nozzle" min="0" max="350" value="${escapeHtml(String(n))}"></label> <div class="steer-group-label">Heizung</div>
<label>${icon("bed")} Bett <input type="number" data-steer="bed" data-steer-action="bed" min="0" max="150" value="${escapeHtml(String(b))}"></label> <div class="steer-row">
<button type="button" data-steer-go="temps">Temp</button> <label>${icon("nozzle")} Düse <input type="number" data-steer="nozzle" data-steer-action="nozzle" min="0" max="350" value="${escapeHtml(String(n))}"></label>
<button type="button" class="ghost" data-steer-go="heaters_off" title="Heizung und Lüfter aus">Aus</button> <label>${icon("bed")} Bett <input type="number" data-steer="bed" data-steer-action="bed" min="0" max="150" value="${escapeHtml(String(b))}"></label>
<button type="button" data-steer-go="temps">Temp</button>
<button type="button" class="ghost" data-steer-go="heaters_off" title="Heizung und Lüfter aus">Aus</button>
</div>
</div> </div>
<div class="steer-row"> <div class="steer-group">
<label>Speed % <input type="number" data-steer="speed" data-steer-action="speed" min="10" max="200" value="${escapeHtml(String(sp))}"></label> <div class="steer-group-label">Druck</div>
<label>Flow % <input type="number" data-steer="flow" data-steer-action="flow" min="50" max="150" value="${escapeHtml(String(flow))}"></label> <div class="steer-row">
<label>${icon("fan")} Fan % <input type="number" data-steer="fan" data-steer-action="fan" min="0" max="100" value="${escapeHtml(String(fan))}"></label> <label>Speed % <input type="number" data-steer="speed" data-steer-action="speed" min="10" max="200" value="${escapeHtml(String(sp))}"></label>
<label>Flow % <input type="number" data-steer="flow" data-steer-action="flow" min="50" max="150" value="${escapeHtml(String(flow))}"></label>
<label>${icon("fan")} Fan % <input type="number" data-steer="fan" data-steer-action="fan" min="0" max="100" value="${escapeHtml(String(fan))}"></label>
</div>
</div> </div>
${aceSteerHtml(printer)} ${aceSteerHtml(printer)}
<p class="tiny">Enter im Feld setzt nur diesen Wert.</p> <p class="tiny">Enter im Feld setzt nur diesen Wert.</p>
@@ -564,19 +586,24 @@ function factsHtml(printer) {
`; `;
} }
function factChip(label, value, ico) {
if (!value && value !== 0) return "";
return `<span class="fact-chip">${ico || ""}<span class="fact-chip-label">${escapeHtml(label)}</span><b>${escapeHtml(String(value))}</b></span>`;
}
function techFactsHtml(printer) { function techFactsHtml(printer) {
const part = fanPct(printer, "part") || fanPct(printer); const part = fanPct(printer, "part") || fanPct(printer);
const chamberFan = fanPct(printer, "chamber"); const chamberFan = fanPct(printer, "chamber");
const auxFan = fanPct(printer, "aux"); const auxFan = fanPct(printer, "aux");
const speed = speedPct(printer); const speed = speedPct(printer);
return ` return [
${part ? "<span class=\"fact-ico\">" + icon("fan") + " Teil <b>" + part + "</b></span>" : ""} part ? factChip("Teil", part, icon("fan")) : "",
${chamberFan ? "<span class=\"fact-ico\">" + icon("fan-chamber") + " Bauraum <b>" + chamberFan + "</b></span>" : ""} chamberFan ? factChip("Bauraum", chamberFan, icon("fan-chamber")) : "",
${auxFan ? "<span class=\"fact-ico\">" + icon("fan-aux") + " Aux <b>" + auxFan + "</b></span>" : ""} auxFan ? factChip("Aux", auxFan, icon("fan-aux")) : "",
${speed ? "<span>Speed <b>" + speed + "</b></span>" : ""} speed ? factChip("Speed", speed) : "",
${anycubicTaskLine(printer)} anycubicTaskLine(printer),
<span>Host <b>${escapeHtml(printer.host)}</b></span> factChip("Host", printer.host),
`; ].filter(Boolean).join("");
} }
function anycubicTaskLine(printer) { function anycubicTaskLine(printer) {
@@ -584,13 +611,11 @@ function anycubicTaskLine(printer) {
if (!task || typeof task !== "object") return ""; if (!task || typeof task !== "object") return "";
const state = String(task.state || "").trim(); const state = String(task.state || "").trim();
const tid = task.task_id != null && String(task.task_id).trim() ? String(task.task_id) : ""; const tid = task.task_id != null && String(task.task_id).trim() ? String(task.task_id) : "";
const file = task.filename ? String(task.filename) : ""; if (!state && !tid) return "";
if (!state && !tid && !file) return "";
const bits = []; const bits = [];
if (state) bits.push(state); if (state) bits.push(state);
if (tid) bits.push("task " + tid); if (tid) bits.push("#" + tid);
if (file) bits.push(file); return factChip("Anycubic", bits.join(" · "));
return "<span>Anycubic <b>" + escapeHtml(bits.join(" · ")) + "</b></span>";
} }
function jobLineVisible(printer, state, filename) { function jobLineVisible(printer, state, filename) {
@@ -626,12 +651,15 @@ function buildCard(printer) {
article.dataset.needsClear = needsClear(printer) ? "1" : ""; article.dataset.needsClear = needsClear(printer) ? "1" : "";
article.innerHTML = ` article.innerHTML = `
<div class="machine-head"> <div class="machine-head">
<div class="machine-title"> <div class="machine-head-main">
<h2></h2> <div class="machine-title">
${badge} <h2></h2>
<span data-role="chip"></span> ${badge}
<span data-role="chip"></span>
</div>
<div data-role="hero"></div>
</div> </div>
<div data-role="hero"></div> <img class="wall-cam-thumb" data-role="wall-cam" alt="" hidden>
</div> </div>
<div class="machine-body"> <div class="machine-body">
<div data-role="bar"></div> <div data-role="bar"></div>
@@ -655,6 +683,59 @@ function buildCard(printer) {
return article; return article;
} }
/** Wand-Thumb immer Snapshot — nie Stream, unabhängig von der Browser-Einstellung. */
function wallCamSnapshotUrl(printer) {
if (!printer || !printer.camera || printer.has_camera === false) return "";
return withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 3000);
}
function patchWallCam(article, printer) {
const img = article.querySelector('[data-role="wall-cam"]');
const head = article.querySelector(".machine-head");
if (!img) return;
const next = wallCamSnapshotUrl(printer);
if (!next) {
img.hidden = true;
img.removeAttribute("src");
img.dataset.srcKey = "";
img.dataset.pending = "";
if (head) head.classList.remove("has-cam");
return;
}
img.alt = "Kamera " + (printer.name || printer.host || "");
if (img.dataset.srcKey === next) {
img.hidden = false;
if (head) head.classList.add("has-cam");
return;
}
img.dataset.pending = next;
const probe = new Image();
probe.onload = () => {
if (img.dataset.pending !== next) return;
img.dataset.srcKey = next;
img.dataset.pending = "";
img.src = next;
img.hidden = false;
if (head) head.classList.add("has-cam");
};
probe.onerror = () => {
if (img.dataset.pending !== next) return;
img.dataset.pending = "";
img.hidden = true;
img.removeAttribute("src");
img.dataset.srcKey = "";
if (head) head.classList.remove("has-cam");
};
probe.src = next;
}
function refreshWallCams() {
for (const [host, article] of cardNodes.entries()) {
const printer = printerCache.get(host);
if (printer) patchWallCam(article, printer);
}
}
function steerHasFocus(root) { function steerHasFocus(root) {
const active = document.activeElement; const active = document.activeElement;
return !!(active && root.contains(active) && active.matches("input, select, textarea, button")); return !!(active && root.contains(active) && active.matches("input, select, textarea, button"));
@@ -772,6 +853,7 @@ function patchCard(article, printer) {
setHtml(role("flow"), flowHtml(printer)); setHtml(role("flow"), flowHtml(printer));
setHtml(role("strips"), stripsHtml(printer, state)); setHtml(role("strips"), stripsHtml(printer, state));
setHtml(role("controls"), actionsHtml(state, printer)); setHtml(role("controls"), actionsHtml(state, printer));
patchWallCam(article, printer);
} }
function refreshDeskCamera(printer) { function refreshDeskCamera(printer) {
@@ -1618,6 +1700,12 @@ async function handleWallClick(event) {
} }
button.disabled = true; button.disabled = true;
try { try {
if (button.dataset.cmd === "fortsetzen") {
const card = printerCache.get(host);
if (card && filamentPauseHint(card)) {
toast("Neue Spule unter Material dem Drucker zuweisen — sonst bleibt der Abzug bei der alten Rolle.");
}
}
await api("/drucker/" + encodeURIComponent(host) + "/" + button.dataset.cmd, { method: "POST", json: {} }); await api("/drucker/" + encodeURIComponent(host) + "/" + button.dataset.cmd, { method: "POST", json: {} });
showNote("note", null); showNote("note", null);
refresh(); refresh();
@@ -1665,6 +1753,7 @@ deskEl.addEventListener("keydown", async (event) => {
refresh().catch((error) => showNote("note", error)); refresh().catch((error) => showNote("note", error));
createPoller(() => refresh().catch((error) => showNote("note", error)), 12000).start(); createPoller(() => refresh().catch((error) => showNote("note", error)), 12000).start();
setInterval(refreshWallCams, 3000);
setInterval(() => { setInterval(() => {
api("/dateien").then((rows) => { libraryFiles = rows || []; }).catch(() => {}); api("/dateien").then((rows) => { libraryFiles = rows || []; }).catch(() => {});
api("/spulen").then((rows) => { wallSpools = rows || []; }).catch(() => {}); api("/spulen").then((rows) => { wallSpools = rows || []; }).catch(() => {});