Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db6a85a703 | ||
|
|
5081212212 |
No files matched your search
@@ -4,6 +4,38 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.7.0] – 2026-11-02
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Treiber:** Moonraker bleibt der Weg für Kobra 3 und S1 (Rinkhals, kein Firmware-Restart). Kobra 4 spricht die Stock-Firmware über den LAN-Broker (Info, ACE-Plätze, Upload auf `fileUploadurl`, Start per MQTT). Die laufende Farm auf dem Pi bleibt auf Moonraker.
|
||||||
|
- **Marlin:** Eigener Dienst hält die USB-Leitung. Grotti startet ihn selbst, sobald ein i3 oder Tenlog eingetragen ist, und beendet ihn nicht. Läuft er schon, bleibt er über einen Neustart von Grotti hinweg. Start pro Auftrag als Strom oder SD-Karte (`marlin_mode`: `stream` oder `sd`). Anycubic i3 ein Kopf, Tenlog zwei Köpfe (T0/T1, auf der Wand beide Temperaturen). Kein ACE, kein Heiz-Panel. Auf dem Pi liegt der Prozess in `grotti-marlin.service`.
|
||||||
|
- **Problem melden:** Knopf in der Kopfzeile. Kopie lokal für den Admin der Installation, dazu Versand an den Empfänger auf git.gundel.dev (Issue). Token nur dort.
|
||||||
|
- **Etiketten:** Papierformat in den Einstellungen (Standard 70×40 mm, Presets, `@page`).
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **Senden:** Upload und Start laufen über den Treiber (Moonraker-Vorbereitung unverändert). Hand-Abbruch in der Warteschlange stoppt Marlin und Kobra 4 ebenfalls.
|
||||||
|
- **Ideen:** API und Store entfernt; `/ideen` bleibt Weiterleitung auf Aufträge. Tabelle `ideas` bleibt.
|
||||||
|
- **Dateien:** Beim Wechsel der Datei bleibt der Drucker nur, wenn Familie und Rolle passen — sonst der passende freie. Orca-Upload und Watch-Kopie derselben Datei werden zusammengelegt (`watch_dir_local` in `orca-senden.json`).
|
||||||
|
- **Live-UI:** Drucker-Ereignisse zeichnen Wand und Zieldrucker nur noch einmal und nur bei geändertem Markup; Karten werden nicht mehr bei jedem Tick umgehängt.
|
||||||
|
- **Dateien / Wand / Material / Einstellungen:** Familien Kobra 4, i3 und Tenlog. Bettnetz nur bei Moonraker, Strom/SD bei Marlin, Mainsail nur bei Moonraker, Fehler löschen über den Treiber, Platzwahl nach Drucker (ACE / Köpfe / am Drucker), Host-Feld folgt der Familie.
|
||||||
|
|
||||||
|
## [3.6.1] – 2026-11-01
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Abbruch fehlt nach Neustart:** Der History-Abgleich hat den Live-Lauf übernommen und gelöscht, das Ereignis „Druck abgebrochen“ zeigte noch auf diese Zeile. Der Fremdschlüssel hat den ganzen Watcher-Tick zurückgerollt, der Abbruch kam nie ins Protokoll, und der neue Druck hat dieselbe Zeile weiterbenutzt. Das Ereignis hängt sich an den überlebenden Lauf. Wechselt die Datei, wird der offene Lauf geschlossen statt umbenannt. Ein Live-Lauf einer anderen Datei wird nicht mehr übernommen.
|
||||||
|
- **Spulenwechsel:** Nach Filamentpause kein vorzeitiger Spulenabzug mehr (`watcher_resume`). Beim Zurücknehmen eines Abzugs wird der letzte Abschnitt wieder geöffnet; der Messwert wird anteilig nach mm verteilt. Beim Einlegen während des Drucks entsteht der erste Abschnitt auch dann (0→jetzt), wenn die alte Rolle schon im Regal ist oder ACE ohne Start-Abschnitt lief. Fallback slot-genau (Platz / ACE-Map / letzter Abschnitt). ACE-Start öffnet Abschnitte für gemappte Plätze mit Spule — ohne Wechsel bleibt Anteil-Split.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **systemd:** `TimeoutStopSec=20` und `KillMode=mixed`, damit ein hängender Stopp nicht 90 s blockiert.
|
||||||
|
- **Orca:** Anleitung und Empfehlung zeigen auf Grotti am Pi (`http://10.10.1.41:8770/`), nicht mehr lokal.
|
||||||
|
- **Dateien:** Unveränderte Watch-Dateien werden beim Sync nur per Größe/mtime geprüft, nicht erneut geöffnet. `ace_trailer` liegt in der Datenbank; die Dateiliste öffnet die Datei nur noch einmal, wenn der Wert fehlt. Wand zeigt Drucker sofort, die Bibliothek lädt im Hintergrund. Pi-Paket: `http_threads` 8.
|
||||||
|
- **3D-Pfad:** Ungespeicherte Bahnen werden nacheinander gebaut, nicht parallel. `http_threads` begrenzt die Request-Threads. Sonst liest die Vorschau mehrere G-codes gleichzeitig und füllt auf einem Pi mit 2 GB den Speicher.
|
||||||
|
- **UI:** Wärmeres Orange-Licht (App und Login), Warten-Status in Amber statt Knopf-Orange, Druckerwand mit heller Kartenkante, größerer Prozentzahl und atmendem Druck-Streifen.
|
||||||
|
|
||||||
## [3.6.0] – 2026-10-25
|
## [3.6.0] – 2026-10-25
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://git.gundel.dev/dg/grotti"><img src="https://img.shields.io/badge/Version-3.6.0-e85d04?style=flat-square" alt="Version 3.6.0"></a>
|
<a href="https://git.gundel.dev/dg/grotti"><img src="https://img.shields.io/badge/Version-3.7.0-e85d04?style=flat-square" alt="Version 3.7.0"></a>
|
||||||
<img src="https://img.shields.io/badge/Python-3.11+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
|
<img src="https://img.shields.io/badge/Python-3.11+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
|
||||||
<img src="https://img.shields.io/badge/PostgreSQL-14+-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
|
<img src="https://img.shields.io/badge/PostgreSQL-14+-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
|
||||||
<img src="https://img.shields.io/badge/Moonraker-Klipper-2ecc71?style=flat-square" alt="Moonraker">
|
<img src="https://img.shields.io/badge/Moonraker-Klipper-2ecc71?style=flat-square" alt="Moonraker">
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
"token": "",
|
"token": "",
|
||||||
"secret_key": "",
|
"secret_key": "",
|
||||||
"fqdn": "",
|
"fqdn": "",
|
||||||
|
"etikett": {
|
||||||
|
"width_mm": 70,
|
||||||
|
"height_mm": 40
|
||||||
|
},
|
||||||
|
"meldung_relay": "https://git.gundel.dev/hooks/grotti-meldung",
|
||||||
"log_level": "INFO",
|
"log_level": "INFO",
|
||||||
"auto_queue": true,
|
"auto_queue": true,
|
||||||
"auto_retry": true,
|
"auto_retry": true,
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ Pro Drucker: Name, Host, Familie, **gesperrt**, optional **Start-Zeitfenster**.
|
|||||||
|
|
||||||
Öffentliche Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host aus dem aktuellen Request. Wichtig, wenn du Etiketten von einem Handy scannst, das nicht `127.0.0.1` kennt.
|
Öffentliche Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host aus dem aktuellen Request. Wichtig, wenn du Etiketten von einem Handy scannst, das nicht `127.0.0.1` kennt.
|
||||||
|
|
||||||
|
## Etiketten
|
||||||
|
|
||||||
|
Breite und Höhe in Millimetern für Spulen- und Drucketiketten (Browser-Druck). Standard 70×40 mm; Voreinstellungen z. B. 62×29 mm. Der QR wird auf flacheren Rollen kleiner. Liegt in `config.json` unter `etikett`.
|
||||||
|
|
||||||
## Telegram, ntfy und Alerts
|
## Telegram, ntfy und Alerts
|
||||||
|
|
||||||
Telegram: Bot-Token und Chat-ID, pro Ereignisart an/aus, Test-Knopf. **ntfy:** Server, Topic, optional Token — gleiche Meldungen, unabhängig schaltbar, ebenfalls mit Test. Ereignisse stehen auch in der Sidebar unter Alerts.
|
Telegram: Bot-Token und Chat-ID, pro Ereignisart an/aus, Test-Knopf. **ntfy:** Server, Topic, optional Token — gleiche Meldungen, unabhängig schaltbar, ebenfalls mit Test. Ereignisse stehen auch in der Sidebar unter Alerts.
|
||||||
|
|||||||
@@ -44,10 +44,6 @@ Auf der **Druckerwand** erscheint je Drucker ein kleines Snapshot-Bild (ca. alle
|
|||||||
|
|
||||||
Zeitstrahl: Starts, Schritte, Fertig, Fehler, Abbrüche, offline. Filter nach Drucker, Art, Zeitraum. Ungelesene Alerts sind markiert; in der Sidebar kannst du sie als gelesen setzen.
|
Zeitstrahl: Starts, Schritte, Fertig, Fehler, Abbrüche, offline. Filter nach Drucker, Art, Zeitraum. Ungelesene Alerts sind markiert; in der Sidebar kannst du sie als gelesen setzen.
|
||||||
|
|
||||||
## Ideen
|
|
||||||
|
|
||||||
`/ideen` — Notizen und Ideen für später. Kein Druckfluss, nur Merkliste.
|
|
||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
- [ACE und Spulen](Anleitung-ACE.md)
|
- [ACE und Spulen](Anleitung-ACE.md)
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
# Orca an Grotti
|
# Orca an Grotti
|
||||||
|
|
||||||
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.
|
Stand **25.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. Ziel ist die Instanz auf dem Pi (`http://10.10.1.41:8770/`), nicht mehr lokal.
|
||||||
|
|
||||||
## Einmal einrichten
|
## Einmal einrichten
|
||||||
|
|
||||||
1. Grotti starten und im Browser anmelden (die Session bleibt für die Senden-Seite).
|
1. Grotti auf dem Pi erreichbar machen und im Browser anmelden (die Session bleibt für die Senden-Seite).
|
||||||
2. Unter **Einstellungen → API-Token** ein Token anlegen (Rolle Operator reicht). Den Wert `grt_…` nur einmal sichtbar.
|
2. Unter **Einstellungen → API-Token** ein Token anlegen (Rolle Operator reicht). Den Wert `grt_…` nur einmal sichtbar.
|
||||||
3. Datei `data/orca-senden.json` anlegen (gleicher Ordner wie `config.json`):
|
3. Datei `data/orca-senden.json` anlegen (im Grotti-Repo unter Windows, gleicher Ordner wie lokal `config.json`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"url": "http://127.0.0.1:8770",
|
"url": "http://10.10.1.41:8770",
|
||||||
"token": "grt_…",
|
"token": "grt_…",
|
||||||
"browser": "app"
|
"browser": "app",
|
||||||
|
"watch_dir_local": "D:/Slice"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`url` ist die Adresse, unter der du Grotti im Browser öffnest.
|
`url` ist die Adresse des Pi (LAN-IP oder Hostname).
|
||||||
`browser`: `app` (Standard) = eigenes Desktop-Fenster; `tab` = klassischer Browser-Tab.
|
`browser`: `app` (Standard) = eigenes Desktop-Fenster; `tab` = klassischer Browser-Tab.
|
||||||
|
`watch_dir_local`: Windows-Pfad desselben Slice-Ordners, den Grotti auf dem Pi beobachtet. Ohne diesen Eintrag lädt das Skript jede Exportdatei zusätzlich hoch — in der Bibliothek erscheint sie dann doppelt (einmal Watch mit Projekt, einmal Upload).
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ Damit unter **Gerät** die Grotti-Oberfläche erscheint:
|
|||||||
1. Orca → Drucker-Dropdown → **Physikalischer Drucker** (Verbindung/Stecker-Symbol).
|
1. Orca → Drucker-Dropdown → **Physikalischer Drucker** (Verbindung/Stecker-Symbol).
|
||||||
2. **Host-Typ:** z. B. `Octo/Klipper` (egal, solange du nicht „Drucken“ an den Host schickst).
|
2. **Host-Typ:** z. B. `Octo/Klipper` (egal, solange du nicht „Drucken“ an den Host schickst).
|
||||||
3. **Hostname / API:** leer lassen — oder nur eine Dummy-Adresse. **Nicht** die Moonraker-IP eines Kobras eintragen.
|
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).
|
4. **Device UI / Web-UI-URL:** `http://10.10.1.41:8770/` (Grotti auf dem Pi).
|
||||||
5. Speichern → Reiter **Gerät** öffnen → einmal bei Grotti anmelden.
|
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.
|
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.
|
||||||
|
|||||||
@@ -73,8 +73,6 @@ Grotti-Schalter „Trockenlauf / PROBE statt z_push“ noch nicht gebaut — bis
|
|||||||
|
|
||||||
## Backlog (später)
|
## Backlog (später)
|
||||||
|
|
||||||
Ideen liegen in Grotti unter **Ideen** (Status „später“), nicht nur hier:
|
|
||||||
|
|
||||||
- **Abdeckungen gegen Verkanten** — Führung/Rutsche vorn, damit geschobene Teile nicht an Bettkante, Rahmen oder in der Kiste verkanten und den nächsten Lauf blockieren.
|
- **Abdeckungen gegen Verkanten** — Führung/Rutsche vorn, damit geschobene Teile nicht an Bettkante, Rahmen oder in der Kiste verkanten und den nächsten Lauf blockieren.
|
||||||
- **Kobra S1** — eigener Abräum-Ablauf (geschlossen, Tür).
|
- **Kobra S1** — eigener Abräum-Ablauf (geschlossen, Tür).
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ Jede Spule in Grotti bekommt automatisch eine `chargen_nr` der Form `S-YYYYMMDD-
|
|||||||
|
|
||||||
Scannen: auf Material den Code tippen oder per USB-Handscanner (Tastatur + Enter). Lookup `GET /api/v1/spulen/by-code/<code>`. QR auf dem Etikett öffnet `/material?spool=<id>`.
|
Scannen: auf Material den Code tippen oder per USB-Handscanner (Tastatur + Enter). Lookup `GET /api/v1/spulen/by-code/<code>`. QR auf dem Etikett öffnet `/material?spool=<id>`.
|
||||||
|
|
||||||
Etikett: `/etikett/<id>`, Druck über den Browser. QR per lokalem `qrcode.min.js` (`web/static/`). Basis-URL für den QR kommt aus Einstellungen → **FQDN** (`config.json` Key `fqdn`); leer = Host der aktuellen Anfrage. Beispiel: `https://grotti.example.de/material?spool=12`.
|
Etikett: `/etikett/<id>`, Druck über den Browser. QR per lokalem `qrcode.min.js` (`web/static/`). Basis-URL für den QR kommt aus Einstellungen → **FQDN** (`config.json` Key `fqdn`); leer = Host der aktuellen Anfrage. Beispiel: `https://grotti.example.de/material?spool=12`. Format (Breite/Höhe, `@page`) aus Einstellungen → **Etiketten** (`config.json` Key `etikett`, Standard 70×40 mm).
|
||||||
|
|
||||||
## Druck-Chargen
|
## Druck-Chargen
|
||||||
|
|
||||||
`D-YYYYMMDD-0001` vergibt der Watcher am aktiven Lauf (History-Job, sonst Live). Die nächste Nummer zählt Läufe und schon vergebene Objekt-Etiketten, damit eine Charge nicht doppelt entsteht. `adopt_live_run` übernimmt die Nummer nur auf einen fertigen History-Lauf ohne eigene Charge. Objekt-Etiketten (`D-…-01`) wandern nur mit, wenn der Ziel-Lauf dieselbe Charge behält; sonst fallen sie mit dem Live-Lauf weg und werden unter der verbleibenden Charge neu vergeben.
|
`D-YYYYMMDD-0001` vergibt der Watcher am aktiven Lauf (History-Job, sonst Live). Die nächste Nummer zählt Läufe und schon vergebene Objekt-Etiketten, damit eine Charge nicht doppelt entsteht. `adopt_live_run` übernimmt die Nummer nur auf einen fertigen History-Lauf ohne eigene Charge. Objekt-Etiketten (`D-…-01`) wandern nur mit, wenn der Ziel-Lauf dieselbe Charge behält; sonst fallen sie mit dem Live-Lauf weg und werden unter der verbleibenden Charge neu vergeben.
|
||||||
|
|
||||||
Druck über `/etikett/lauf/<id>?mode=druck|objekt`. Auf dem Bogen: Charge, **Soll … g** (Slicer-Filamentmenge), Drucker, Material (Abzug oder Send/ACE/Lauf-Spule), QR 20 mm im 70‑mm-Bogen. Wand: Knopf **Etikett** bei Druck/Pause. Urteil Ausschuss ändert die Charge nicht — gedruckte Bögen verwerfen.
|
Druck über `/etikett/lauf/<id>?mode=druck|objekt`. Auf dem Bogen: Charge, **Soll … g** (Slicer-Filamentmenge), Drucker, Material (Abzug oder Send/ACE/Lauf-Spule), QR proportional zur Etikettenhöhe. Wand: Knopf **Etikett** bei Druck/Pause. Urteil Ausschuss ändert die Charge nicht — gedruckte Bögen verwerfen.
|
||||||
|
|
||||||
## 3D-Pfad
|
## 3D-Pfad
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ Zusammengelegt wird nur mit **Beweis**, nicht nur wegen Zeit und Dateiname:
|
|||||||
|
|
||||||
Ein Neustart mit `print_duration` ≤ 60 s wird nie zusammengelegt.
|
Ein Neustart mit `print_duration` ≤ 60 s wird nie zusammengelegt.
|
||||||
|
|
||||||
|
Ein Live-Lauf wird nur übernommen, wenn der Dateiname übereinstimmt (eine noch namenlose Hülle zählt zum neuen Druck). Ein Abbruch-Ereignis auf eine bereits gelöschte Zeile rollt den Abgleich nicht zurück.
|
||||||
|
|
||||||
## Pfade
|
## Pfade
|
||||||
|
|
||||||
| Pfad | Datei |
|
| Pfad | Datei |
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Problem melden
|
||||||
|
|
||||||
|
Stand 2.11.2026. Nutzer melden aus der Grotti-Oberfläche. Du bist oft nicht Admin ihrer Installation.
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
1. Knopf in der Kopfzeile öffnet einen kurzen Text.
|
||||||
|
2. Die Installation speichert eine Kopie in PostgreSQL (`meldungen`, offen/erledigt). Der Admin dort sieht die Liste unter Einstellungen.
|
||||||
|
3. Dieselbe Meldung geht an den Empfänger `meldung_relay` (Standard `https://git.gundel.dev/hooks/grotti-meldung`).
|
||||||
|
4. Der Empfänger legt ein Issue im Grotti-Repo an. Der Forgejo-Token liegt nur dort, nicht in der Installation und nicht im Browser.
|
||||||
|
|
||||||
|
Mitgeschickt: Text, Version, Seite, Drucker-Host, letzter Fehlertext. Kein Benutzername der fremden Installation.
|
||||||
|
|
||||||
|
Kommt der Empfänger nicht ran, bleibt die lokale Kopie. Der Absender sieht, dass die Meldung bei dir nicht angekommen ist.
|
||||||
|
|
||||||
|
## Empfänger starten
|
||||||
|
|
||||||
|
```
|
||||||
|
export FORGEJO_URL=https://git.gundel.dev
|
||||||
|
export FORGEJO_TOKEN=…
|
||||||
|
export FORGEJO_REPO=dg/grotti
|
||||||
|
python scripts/meldung_relay.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Reverse-Proxy: `/hooks/grotti-meldung` → `127.0.0.1:8780`. In `config.json` der Installationen optional `"meldung_relay": "https://git.gundel.dev/hooks/grotti-meldung"`.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Treiber
|
||||||
|
|
||||||
|
Stand 2.11.2026. Die Karte, die Moonraker heute liefert, ist der Vertrag. Ein Treiber füllt, was die Maschine hergibt.
|
||||||
|
|
||||||
|
| Treiber | Wer | Transport |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `moonraker` | Kobra 3, Kobra S1 | Moonraker plus Rinkhals-Richtlinie: kein Firmware-Restart, ACE-Start über MQTT, Kamera unter `/webcam/`, Bettnetz |
|
||||||
|
| `anycubic_lan` | Kobra 4, Stock-Firmware | MQTTS `:9883`, Upload auf `fileUploadurl`, Plätze über `multiColorBox`. Kamera der Firmware (FLV) zeigt die Wand noch nicht |
|
||||||
|
| `marlin` | Anycubic i3, Tenlog | Dienst auf dem Rechner mit dem Kabel. Grotti spricht nur `127.0.0.1` |
|
||||||
|
|
||||||
|
## Fähigkeiten
|
||||||
|
|
||||||
|
Wer eine Fähigkeit ändert, ändert diese Liste im selben Schritt wie das Changelog.
|
||||||
|
|
||||||
|
| Drucker | Status | Start | Pause / Abbruch | ACE | Bettnetz | Kamera | Mainsail | Heizen Wand | Ablauf |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| Kobra 3 | ja | ja | ja | ja | ja | MJPEG | ja | ja | ja |
|
||||||
|
| Kobra S1 | ja | ja | ja | ja | ja | MJPEG | ja | ja | nein |
|
||||||
|
| Kobra 4 | ja | ja (LAN) | ja | ja | nein | noch nicht (FLV) | nein | nein | nein |
|
||||||
|
| Anycubic i3 | ja | Strom oder SD | ja | nein | nein | nein | nein | nein (G-code) | nein |
|
||||||
|
| Tenlog | ja (2 Köpfe) | Strom oder SD | ja | nein (Köpfe) | nein | nein | nein | nein (G-code) | nein |
|
||||||
|
|
||||||
|
Die Instanz auf `hoe` bleibt auf `master`. Dieser Stand liegt auf dem Branch `treiber` und wird lokal geprüft.
|
||||||
|
|
||||||
|
## Marlin-Dienst
|
||||||
|
|
||||||
|
Grotti startet ihn selbst, sobald ein i3 oder Tenlog in der Liste steht. Lauscht er schon, bleibt der Prozess: Neustart und Absturz von Grotti schicken keinen Abbruch und öffnen die Leitung nicht neu. Grotti beendet den Dienst nicht.
|
||||||
|
|
||||||
|
Lokal ist das ein eigener Prozess (`python -m grotti.marlin_daemon`, Log `data/marlin-dienst.log`). Auf dem Pi ist es die Unit `grotti-marlin.service`, außerhalb von `grotti.service`, damit ein `systemctl restart grotti` den Druck nicht mitnimmt.
|
||||||
|
|
||||||
|
Port `GROTTI_MARLIN_PORT` (8771). `pyserial` öffnet die Leitung. Der Host des Druckers ist die Schnittstelle, Baud optional dahinter: `COM5`, `COM5@250000`, `/dev/ttyUSB0`. In den Einstellungen eintragen, Familie Anycubic i3 oder Tenlog.
|
||||||
|
|
||||||
|
Beim ersten Öffnen setzt USB den Drucker zurück (DTR). Danach bleibt der Port im Dienst.
|
||||||
|
|
||||||
|
Start pro Auftrag, Feld `marlin_mode` am Senden:
|
||||||
|
|
||||||
|
- `stream` — Zeile für Zeile, jede Zeile wartet auf `ok`. Fortschritt ist die Dateiposition. Bricht die Leitung ab, stirbt der Druck.
|
||||||
|
- `sd` — Datei per `M28`/`M29` auf die Karte (Name 8.3, `TEIL.GCO`), dann `M23`/`M24`. Der Drucker druckt allein, der Dienst fragt `M27` und `M105`.
|
||||||
|
|
||||||
|
`T0` und `T1` gehen unverändert in den Strom. Tenlog (`family: tenlog`) meldet zwei Köpfe unter `tools`, die Wand zeigt beide Temperaturen. `slots` bleibt leer, ein Start verlangt keine ACE-Zuordnung. Pause, Weiter und Abbruch gehen über die Wand. Heizen, Speed und Plätze nicht: das steht im G-code. Pause im Strom hält die nächsten Zeilen an, auf der Karte ist es `M25`/`M24`. Abbruch schaltet die Heizungen aus; auf der Karte zusätzlich `M524`.
|
||||||
|
|
||||||
|
Die Warteschlange „wenn frei“ übergibt den Modus noch nicht, dort gilt Strom.
|
||||||
@@ -287,6 +287,10 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
|
|||||||
text, code = _report_error(body, "Druckabbruch abgelehnt")
|
text, code = _report_error(body, "Druckabbruch abgelehnt")
|
||||||
state["error"] = text
|
state["error"] = text
|
||||||
state["code"] = code
|
state["code"] = code
|
||||||
|
elif action in ("pause", "resume") and report in ("failed",):
|
||||||
|
text, code = _report_error(body, "Druckbefehl abgelehnt")
|
||||||
|
state["error"] = text
|
||||||
|
state["code"] = code
|
||||||
# start: heating / leveling / printing / preheating = angenommen
|
# start: heating / leveling / printing / preheating = angenommen
|
||||||
# stop: stoped / stopped / done = angenommen
|
# stop: stoped / stopped / done = angenommen
|
||||||
state["done"] = True
|
state["done"] = True
|
||||||
@@ -477,6 +481,88 @@ def anycubic_task(host, use_cache=True):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def print_action(host, action, timeout=15):
|
||||||
|
"""Pause, Fortsetzen oder Stopp über denselben MQTT-Kanal wie der Start."""
|
||||||
|
if action not in ("pause", "resume", "stop"):
|
||||||
|
raise LanError("Unbekannter Druckbefehl.")
|
||||||
|
request = {
|
||||||
|
"type": "print",
|
||||||
|
"action": action,
|
||||||
|
"msgid": str(uuid.uuid4()),
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
"data": {"taskid": "-1"},
|
||||||
|
}
|
||||||
|
report = _mqtt_print(
|
||||||
|
host,
|
||||||
|
request,
|
||||||
|
timeout=timeout,
|
||||||
|
timeout_message="Timeout beim MQTT-Druckbefehl.",
|
||||||
|
)
|
||||||
|
_INFO_CACHE.pop(host, None)
|
||||||
|
return {"mqtt": action, "report": report}
|
||||||
|
|
||||||
|
|
||||||
|
def mqtt_report(host, channel, typ, action, timeout=8):
|
||||||
|
"""Eine Abfrage auf slicer/…/<channel>, liefert den Report-Körper."""
|
||||||
|
cred = credentials(host)
|
||||||
|
model_id = cred["modelId"]
|
||||||
|
device_id = cred["deviceId"]
|
||||||
|
topic_query = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/%s" % (
|
||||||
|
model_id, device_id, channel,
|
||||||
|
)
|
||||||
|
topic_report = "anycubic/anycubicCloud/v1/printer/public/%s/%s/%s/report" % (
|
||||||
|
model_id, device_id, channel,
|
||||||
|
)
|
||||||
|
payload = json.dumps({
|
||||||
|
"type": typ,
|
||||||
|
"action": action,
|
||||||
|
"msgid": str(uuid.uuid4()),
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
"data": None,
|
||||||
|
})
|
||||||
|
state = {"body": None}
|
||||||
|
|
||||||
|
def on_connect(client, userdata, flags, reason_code, properties=None):
|
||||||
|
if getattr(reason_code, "is_failure", False):
|
||||||
|
state["body"] = {"state": "failed", "msg": "MQTT-Verbindung abgelehnt."}
|
||||||
|
return
|
||||||
|
client.subscribe(topic_report)
|
||||||
|
client.publish(topic_query, payload=payload, qos=1)
|
||||||
|
|
||||||
|
def on_message(client, userdata, msg):
|
||||||
|
try:
|
||||||
|
state["body"] = json.loads(msg.payload.decode("utf-8", "replace"))
|
||||||
|
except (json.JSONDecodeError, UnicodeError):
|
||||||
|
return
|
||||||
|
|
||||||
|
client = mqtt.Client(
|
||||||
|
mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
protocol=mqtt.MQTTv311,
|
||||||
|
client_id="grotti-%s" % uuid.uuid4().hex[:10],
|
||||||
|
)
|
||||||
|
client.username_pw_set(cred["username"], cred["password"])
|
||||||
|
client.tls_set(cert_reqs=ssl.CERT_NONE)
|
||||||
|
client.tls_insecure_set(True)
|
||||||
|
client.on_connect = on_connect
|
||||||
|
client.on_message = on_message
|
||||||
|
try:
|
||||||
|
client.connect(host, 9883, keepalive=30)
|
||||||
|
except OSError as exc:
|
||||||
|
raise LanError("MQTT am Drucker nicht erreichbar (:9883).") from exc
|
||||||
|
deadline = time.time() + max(3, int(timeout))
|
||||||
|
try:
|
||||||
|
while state["body"] is None and time.time() < deadline:
|
||||||
|
client.loop(timeout=0.25)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if state["body"] is None:
|
||||||
|
raise LanError("Timeout bei der Druckerabfrage.")
|
||||||
|
return state["body"]
|
||||||
|
|
||||||
|
|
||||||
def cancel_print(host, timeout=15):
|
def cancel_print(host, timeout=15):
|
||||||
"""MQTT-Stop wie Rinkhals Docs: action stop, taskid -1 (Anycubic-Task)."""
|
"""MQTT-Stop wie Rinkhals Docs: action stop, taskid -1 (Anycubic-Task)."""
|
||||||
request = {
|
request = {
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ def _oauth(raw):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _http_threads(raw):
|
||||||
|
try:
|
||||||
|
count = int(raw.get("http_threads") or 16)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
count = 16
|
||||||
|
return max(2, min(32, count))
|
||||||
|
|
||||||
|
|
||||||
def _ensure_secret_key(path, raw):
|
def _ensure_secret_key(path, raw):
|
||||||
key = str(raw.get("secret_key") or "").strip()
|
key = str(raw.get("secret_key") or "").strip()
|
||||||
if key:
|
if key:
|
||||||
@@ -168,6 +176,7 @@ def load():
|
|||||||
"secret_key": secret_key,
|
"secret_key": secret_key,
|
||||||
"oauth": _oauth(raw),
|
"oauth": _oauth(raw),
|
||||||
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
|
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
|
||||||
|
"etikett": _etikett(raw),
|
||||||
"printers_file": printers_file,
|
"printers_file": printers_file,
|
||||||
"log_level": str(raw.get("log_level") or "INFO").strip() or "INFO",
|
"log_level": str(raw.get("log_level") or "INFO").strip() or "INFO",
|
||||||
"energy": _energy(raw),
|
"energy": _energy(raw),
|
||||||
@@ -176,6 +185,7 @@ def load():
|
|||||||
"alerts": _alerts(raw),
|
"alerts": _alerts(raw),
|
||||||
"auto_queue": bool(raw.get("auto_queue", True)),
|
"auto_queue": bool(raw.get("auto_queue", True)),
|
||||||
"auto_retry": bool(raw.get("auto_retry", True)),
|
"auto_retry": bool(raw.get("auto_retry", True)),
|
||||||
|
"http_threads": _http_threads(raw),
|
||||||
"_raw": raw,
|
"_raw": raw,
|
||||||
"_path": path,
|
"_path": path,
|
||||||
}
|
}
|
||||||
@@ -224,6 +234,36 @@ def default_watch_suggestion():
|
|||||||
return str((ROOT / DEFAULT_WATCH_REL).resolve())
|
return str((ROOT / DEFAULT_WATCH_REL).resolve())
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ETIKETT = {"width_mm": 70.0, "height_mm": 40.0}
|
||||||
|
ETIKETT_PRESETS = (
|
||||||
|
{"id": "70x40", "label": "70 × 40 mm", "width_mm": 70, "height_mm": 40},
|
||||||
|
{"id": "62x29", "label": "62 × 29 mm (Brother)", "width_mm": 62, "height_mm": 29},
|
||||||
|
{"id": "50x30", "label": "50 × 30 mm", "width_mm": 50, "height_mm": 30},
|
||||||
|
{"id": "100x50", "label": "100 × 50 mm", "width_mm": 100, "height_mm": 50},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _etikett(raw):
|
||||||
|
src = raw.get("etikett") if isinstance(raw.get("etikett"), dict) else {}
|
||||||
|
try:
|
||||||
|
width = float(src.get("width_mm", DEFAULT_ETIKETT["width_mm"]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
width = DEFAULT_ETIKETT["width_mm"]
|
||||||
|
try:
|
||||||
|
height = float(src.get("height_mm", DEFAULT_ETIKETT["height_mm"]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
height = DEFAULT_ETIKETT["height_mm"]
|
||||||
|
width = max(20.0, min(200.0, width))
|
||||||
|
height = max(15.0, min(200.0, height))
|
||||||
|
qr = max(10.0, min(20.0, round(height * 0.5, 1)))
|
||||||
|
return {
|
||||||
|
"width_mm": width,
|
||||||
|
"height_mm": height,
|
||||||
|
"qr_mm": qr,
|
||||||
|
"presets": list(ETIKETT_PRESETS),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def settings_public(cfg):
|
def settings_public(cfg):
|
||||||
tg = cfg.get("telegram") or {}
|
tg = cfg.get("telegram") or {}
|
||||||
oauth = cfg.get("oauth") or {}
|
oauth = cfg.get("oauth") or {}
|
||||||
@@ -233,6 +273,7 @@ def settings_public(cfg):
|
|||||||
"auto_queue": bool(cfg.get("auto_queue", True)),
|
"auto_queue": bool(cfg.get("auto_queue", True)),
|
||||||
"auto_retry": bool(cfg.get("auto_retry", True)),
|
"auto_retry": bool(cfg.get("auto_retry", True)),
|
||||||
"fqdn": str(cfg.get("fqdn") or ""),
|
"fqdn": str(cfg.get("fqdn") or ""),
|
||||||
|
"etikett": _etikett({"etikett": cfg.get("etikett") or {}}),
|
||||||
"legacy_token_set": bool(cfg.get("token")),
|
"legacy_token_set": bool(cfg.get("token")),
|
||||||
"energy": dict(cfg.get("energy") or {}),
|
"energy": dict(cfg.get("energy") or {}),
|
||||||
"telegram": {
|
"telegram": {
|
||||||
@@ -268,6 +309,7 @@ def save_settings(
|
|||||||
auto_retry=None,
|
auto_retry=None,
|
||||||
fqdn=None,
|
fqdn=None,
|
||||||
watch_dir=None,
|
watch_dir=None,
|
||||||
|
etikett=None,
|
||||||
):
|
):
|
||||||
"""Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
|
"""Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
|
||||||
with _SETTINGS_LOCK:
|
with _SETTINGS_LOCK:
|
||||||
@@ -281,6 +323,7 @@ def save_settings(
|
|||||||
auto_retry=auto_retry,
|
auto_retry=auto_retry,
|
||||||
fqdn=fqdn,
|
fqdn=fqdn,
|
||||||
watch_dir=watch_dir,
|
watch_dir=watch_dir,
|
||||||
|
etikett=etikett,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -294,6 +337,7 @@ def _save_settings_locked(
|
|||||||
auto_retry=None,
|
auto_retry=None,
|
||||||
fqdn=None,
|
fqdn=None,
|
||||||
watch_dir=None,
|
watch_dir=None,
|
||||||
|
etikett=None,
|
||||||
):
|
):
|
||||||
path = cfg.get("_path") or CONFIG_PATH
|
path = cfg.get("_path") or CONFIG_PATH
|
||||||
raw = dict(cfg.get("_raw") or {})
|
raw = dict(cfg.get("_raw") or {})
|
||||||
@@ -316,6 +360,13 @@ def _save_settings_locked(
|
|||||||
normalized = normalize_fqdn(fqdn)
|
normalized = normalize_fqdn(fqdn)
|
||||||
raw["fqdn"] = normalized
|
raw["fqdn"] = normalized
|
||||||
cfg["fqdn"] = normalized
|
cfg["fqdn"] = normalized
|
||||||
|
if etikett is not None and isinstance(etikett, dict):
|
||||||
|
parsed = _etikett({"etikett": etikett})
|
||||||
|
raw["etikett"] = {
|
||||||
|
"width_mm": parsed["width_mm"],
|
||||||
|
"height_mm": parsed["height_mm"],
|
||||||
|
}
|
||||||
|
cfg["etikett"] = parsed
|
||||||
if energy is not None:
|
if energy is not None:
|
||||||
raw_energy = {
|
raw_energy = {
|
||||||
"watts": float(energy.get("watts", 40)),
|
"watts": float(energy.get("watts", 40)),
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Drucker-Treiber. Die Karte ist das, was moonraker.query schon liefert.
|
||||||
|
|
||||||
|
Vorhandene Kobra 3 und S1 bleiben auf Moonraker. Kobra 4 spricht die
|
||||||
|
Stock-Firmware über den LAN-Broker. i3 und Tenlog gehen an den Marlin-Dienst.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from grotti.drivers.anycubic_lan import AnycubicLanDriver
|
||||||
|
from grotti.drivers.marlin import MarlinDriver
|
||||||
|
from grotti.drivers.moonraker_drv import MoonrakerDriver
|
||||||
|
from grotti.printers import FAMILY_DRIVER
|
||||||
|
|
||||||
|
_BY_ID = {
|
||||||
|
"moonraker": MoonrakerDriver(),
|
||||||
|
"anycubic_lan": AnycubicLanDriver(),
|
||||||
|
"marlin": MarlinDriver(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def for_printer(printer):
|
||||||
|
family = (printer or {}).get("family") or ""
|
||||||
|
driver_id = (printer or {}).get("driver") or FAMILY_DRIVER.get(family) or "moonraker"
|
||||||
|
return _BY_ID.get(driver_id) or _BY_ID["moonraker"]
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(printers):
|
||||||
|
"""Live-Karten in der Reihenfolge der Drucker. Moonraker-Gruppen bleiben ein Aufruf."""
|
||||||
|
buckets = {}
|
||||||
|
order = []
|
||||||
|
for printer in printers or []:
|
||||||
|
drv = for_printer(printer)
|
||||||
|
key = drv.transport
|
||||||
|
if key not in buckets:
|
||||||
|
order.append(key)
|
||||||
|
buckets[key] = (drv, [])
|
||||||
|
buckets[key][1].append(printer)
|
||||||
|
by_host = {}
|
||||||
|
for key in order:
|
||||||
|
drv, group = buckets[key]
|
||||||
|
for card in drv.snapshot(group):
|
||||||
|
host = card.get("host")
|
||||||
|
if host:
|
||||||
|
by_host[host] = card
|
||||||
|
lives = []
|
||||||
|
for printer in printers or []:
|
||||||
|
host = printer.get("host")
|
||||||
|
card = by_host.get(host)
|
||||||
|
if card:
|
||||||
|
lives.append(card)
|
||||||
|
continue
|
||||||
|
lives.append({
|
||||||
|
"name": printer.get("name") or host,
|
||||||
|
"host": host,
|
||||||
|
"family": printer.get("family") or "",
|
||||||
|
"family_label": printer.get("family_label") or "",
|
||||||
|
"ok": False,
|
||||||
|
"state": "offline",
|
||||||
|
"message": "Kein Treiber-Ergebnis.",
|
||||||
|
"filename": "",
|
||||||
|
"slots": [],
|
||||||
|
"has_camera": False,
|
||||||
|
"camera": "",
|
||||||
|
})
|
||||||
|
return lives
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""Stock-Firmware über den Anycubic-LAN-Broker. Kein Moonraker.
|
||||||
|
|
||||||
|
Info, Plätze und Druckbefehle laufen über MQTTS :9883. Die Datei geht an
|
||||||
|
die fileUploadurl aus der Info-Antwort (:18910/gcode_upload). Die Kamera
|
||||||
|
der Standard-Firmware ist FLV, die Wand zeigt sie noch nicht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from grotti import anycubic_lan
|
||||||
|
from grotti.moonraker import PrinterError, safe_upload_name
|
||||||
|
|
||||||
|
_STATE = {
|
||||||
|
"free": "standby",
|
||||||
|
"idle": "standby",
|
||||||
|
"standby": "standby",
|
||||||
|
"done": "complete",
|
||||||
|
"complete": "complete",
|
||||||
|
"finish": "complete",
|
||||||
|
"finished": "complete",
|
||||||
|
"printing": "printing",
|
||||||
|
"busy": "printing",
|
||||||
|
"heating": "printing",
|
||||||
|
"preheating": "printing",
|
||||||
|
"leveling": "printing",
|
||||||
|
"paused": "paused",
|
||||||
|
"pause": "paused",
|
||||||
|
"error": "error",
|
||||||
|
"failed": "error",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value):
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def card_from_info(info, printer=None):
|
||||||
|
"""LAN-info/report → dieselbe Karte wie moonraker.query, leere Felder bleiben leer."""
|
||||||
|
info = info or {}
|
||||||
|
printer = printer or {}
|
||||||
|
project = info.get("project") if isinstance(info.get("project"), dict) else {}
|
||||||
|
raw_state = str(info.get("state") or project.get("print_status") or "").strip().lower()
|
||||||
|
state = _STATE.get(raw_state, raw_state or "standby")
|
||||||
|
filename = project.get("filename") or project.get("name") or ""
|
||||||
|
if isinstance(filename, str):
|
||||||
|
filename = filename.replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
|
else:
|
||||||
|
filename = ""
|
||||||
|
progress = _number(project.get("progress"))
|
||||||
|
if progress is None:
|
||||||
|
progress = 0.0
|
||||||
|
elif progress > 1:
|
||||||
|
progress = progress / 100.0
|
||||||
|
progress = max(0.0, min(1.0, progress))
|
||||||
|
temp = info.get("temp") if isinstance(info.get("temp"), dict) else {}
|
||||||
|
host = printer.get("host") or info.get("ip") or ""
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"name": printer.get("name") or info.get("printerName") or host,
|
||||||
|
"host": host,
|
||||||
|
"family": printer.get("family") or "",
|
||||||
|
"family_label": printer.get("family_label") or "",
|
||||||
|
"state": state,
|
||||||
|
"message": "",
|
||||||
|
"filename": filename,
|
||||||
|
"progress": progress,
|
||||||
|
"layer": int(_number(project.get("curr_layer")) or 0),
|
||||||
|
"layers": int(_number(project.get("total_layers") or project.get("total_layer")) or 0),
|
||||||
|
"remain": int(_number(project.get("remain_time")) or 0),
|
||||||
|
"nozzle": _number(temp.get("curr_nozzle_temp")),
|
||||||
|
"nozzle_target": _number(temp.get("target_nozzle_temp")),
|
||||||
|
"bed": _number(temp.get("curr_hotbed_temp")),
|
||||||
|
"bed_target": _number(temp.get("target_hotbed_temp")),
|
||||||
|
"fan": _number(info.get("fan_speed_pct")),
|
||||||
|
"slots": [],
|
||||||
|
"has_camera": False,
|
||||||
|
"camera": "",
|
||||||
|
"camera_stream": "",
|
||||||
|
"speed_factor": None,
|
||||||
|
"extrude_factor": None,
|
||||||
|
"mesh": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def slots_from_report(data):
|
||||||
|
"""multiColorBox/getInfo → Plätze mit index ab 0, wie Moonraker-Slots."""
|
||||||
|
data = data or {}
|
||||||
|
boxes = data.get("multi_color_box")
|
||||||
|
if isinstance(boxes, dict):
|
||||||
|
boxes = boxes.get("slots") or boxes.get("boxes") or [boxes]
|
||||||
|
if not isinstance(boxes, list):
|
||||||
|
return []
|
||||||
|
found = []
|
||||||
|
for box_index, box in enumerate(boxes):
|
||||||
|
if not isinstance(box, dict):
|
||||||
|
continue
|
||||||
|
slots = box.get("slots")
|
||||||
|
if not isinstance(slots, list):
|
||||||
|
kind = str(box.get("type") or box.get("material") or "").strip()
|
||||||
|
if kind:
|
||||||
|
found.append({"index": box_index, "type": kind, "color": ""})
|
||||||
|
continue
|
||||||
|
for offset, slot in enumerate(slots):
|
||||||
|
if not isinstance(slot, dict):
|
||||||
|
continue
|
||||||
|
kind = str(slot.get("type") or slot.get("material_type") or slot.get("material") or "").strip()
|
||||||
|
if not kind or kind.lower() == "unknown":
|
||||||
|
continue
|
||||||
|
index = slot.get("index")
|
||||||
|
if isinstance(index, bool) or not isinstance(index, int):
|
||||||
|
index = box_index * 4 + offset
|
||||||
|
found.append({"index": index, "type": kind, "color": ""})
|
||||||
|
found.sort(key=lambda item: item["index"])
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
class AnycubicLanDriver:
|
||||||
|
transport = "anycubic_lan"
|
||||||
|
rinkhals = False
|
||||||
|
firmware_restart = False
|
||||||
|
|
||||||
|
def snapshot(self, printers):
|
||||||
|
cards = []
|
||||||
|
for printer in printers or []:
|
||||||
|
host = printer.get("host") or ""
|
||||||
|
try:
|
||||||
|
card = self.query(host)
|
||||||
|
card["name"] = printer.get("name") or card.get("name") or host
|
||||||
|
card["host"] = host
|
||||||
|
card["family"] = printer.get("family") or ""
|
||||||
|
card["family_label"] = printer.get("family_label") or ""
|
||||||
|
card["slots"] = self.slots(host)
|
||||||
|
except PrinterError as exc:
|
||||||
|
card = {
|
||||||
|
"name": printer.get("name") or host,
|
||||||
|
"host": host,
|
||||||
|
"family": printer.get("family") or "",
|
||||||
|
"family_label": printer.get("family_label") or "",
|
||||||
|
"ok": False,
|
||||||
|
"state": "offline",
|
||||||
|
"message": exc.message,
|
||||||
|
"filename": "",
|
||||||
|
"slots": [],
|
||||||
|
"has_camera": False,
|
||||||
|
"camera": "",
|
||||||
|
}
|
||||||
|
cards.append(card)
|
||||||
|
return cards
|
||||||
|
|
||||||
|
def query(self, host):
|
||||||
|
info = anycubic_lan.print_info(host, timeout=8, use_cache=False)
|
||||||
|
if not info:
|
||||||
|
raise PrinterError("Der Drucker ist nicht erreichbar.")
|
||||||
|
return card_from_info(info, {"host": host})
|
||||||
|
|
||||||
|
def slots(self, host, status=None):
|
||||||
|
try:
|
||||||
|
body = anycubic_lan.mqtt_report(host, "multiColorBox", "multiColorBox", "getInfo")
|
||||||
|
except anycubic_lan.LanError as exc:
|
||||||
|
raise PrinterError(exc.message) from exc
|
||||||
|
data = body.get("data") if isinstance(body, dict) else None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return []
|
||||||
|
return slots_from_report(data)
|
||||||
|
|
||||||
|
def upload(self, host, filename, content, on_progress=None):
|
||||||
|
filename = safe_upload_name(filename)
|
||||||
|
info = anycubic_lan.print_info(host, timeout=8, use_cache=False) or {}
|
||||||
|
url = ((info.get("urls") or {}).get("fileUploadurl") or "").strip()
|
||||||
|
if "/gcode_upload" not in url:
|
||||||
|
raise PrinterError("Der Drucker hat keine Upload-Adresse gemeldet.")
|
||||||
|
body, boundary = _multipart(filename, content)
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=body,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "multipart/form-data; boundary=%s" % boundary,
|
||||||
|
"Content-Length": str(len(body)),
|
||||||
|
"X-File-Length": str(len(content)),
|
||||||
|
"User-Agent": "Grotti",
|
||||||
|
"X-BBL-Client-Name": "Grotti",
|
||||||
|
"X-BBL-Client-Type": "slicer",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
if on_progress:
|
||||||
|
on_progress(0, len(content))
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=_upload_timeout(len(content))) as response:
|
||||||
|
response.read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read(400).decode("utf-8", "replace").strip()
|
||||||
|
raise PrinterError(detail or "Hochladen ist fehlgeschlagen.") from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||||
|
raise PrinterError("Hochladen ist fehlgeschlagen.") from exc
|
||||||
|
if on_progress:
|
||||||
|
on_progress(len(content), len(content))
|
||||||
|
return filename
|
||||||
|
|
||||||
|
def start_print(self, host, filename, bed_leveling=False, ams_settings=None):
|
||||||
|
try:
|
||||||
|
return anycubic_lan.start_print(
|
||||||
|
host,
|
||||||
|
filename,
|
||||||
|
auto_leveling=bool(bed_leveling),
|
||||||
|
ams_settings=ams_settings,
|
||||||
|
)
|
||||||
|
except anycubic_lan.LanError as exc:
|
||||||
|
raise PrinterError(exc.message) from exc
|
||||||
|
|
||||||
|
def command(self, host, action):
|
||||||
|
mapped = {"pause": "pause", "fortsetzen": "resume", "abbrechen": "stop", "resume": "resume", "cancel": "stop"}.get(action)
|
||||||
|
if not mapped:
|
||||||
|
raise PrinterError("Unbekannter Befehl.")
|
||||||
|
try:
|
||||||
|
if mapped == "stop":
|
||||||
|
return anycubic_lan.cancel_print(host)
|
||||||
|
return anycubic_lan.print_action(host, mapped)
|
||||||
|
except anycubic_lan.LanError as exc:
|
||||||
|
raise PrinterError(exc.message) from exc
|
||||||
|
|
||||||
|
def clear_print_error(self, host):
|
||||||
|
raise PrinterError("Fehler löschen gibt es an der Stock-Firmware in Grotti nicht.")
|
||||||
|
|
||||||
|
def heaters_off(self, host):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_timeout(size):
|
||||||
|
size = max(0, int(size or 0))
|
||||||
|
return max(180, min(600, 90 + size // (80 * 1024)))
|
||||||
|
|
||||||
|
|
||||||
|
def _multipart(filename, content):
|
||||||
|
boundary = "grotti-%s" % uuid.uuid4().hex
|
||||||
|
sep = ("--%s\r\n" % boundary).encode()
|
||||||
|
end = ("--%s--\r\n" % boundary).encode()
|
||||||
|
name = sep + b'Content-Disposition: form-data; name="filename"\r\n\r\n' + filename.encode() + b"\r\n"
|
||||||
|
payload = (
|
||||||
|
sep
|
||||||
|
+ ('Content-Disposition: form-data; name="gcode"; filename="%s"\r\n' % filename).encode()
|
||||||
|
+ b"Content-Type: application/octet-stream\r\n\r\n"
|
||||||
|
+ content
|
||||||
|
+ b"\r\n"
|
||||||
|
)
|
||||||
|
return name + payload + end, boundary
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Marlin-Treiber. Spricht den lokalen Dienst, hält selbst keine Leitung.
|
||||||
|
|
||||||
|
Host ist die Schnittstelle, optional mit Baud: ``COM5`` oder ``COM5@250000``.
|
||||||
|
Familie ``tenlog`` hat zwei Köpfe (T0, T1). Die Karte führt sie unter ``tools``.
|
||||||
|
``slots`` bleibt leer, damit ein Start keine ACE-Zuordnung verlangt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from grotti.marlin_link import split_port
|
||||||
|
from grotti.moonraker import PrinterError
|
||||||
|
|
||||||
|
_DOWN = "Der Marlin-Dienst läuft nicht."
|
||||||
|
|
||||||
|
|
||||||
|
def service_url():
|
||||||
|
return (os.environ.get("GROTTI_MARLIN_URL") or "http://127.0.0.1:8771").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _offline(printer, message):
|
||||||
|
host = (printer or {}).get("host") or ""
|
||||||
|
return {
|
||||||
|
"name": (printer or {}).get("name") or host,
|
||||||
|
"host": host,
|
||||||
|
"family": (printer or {}).get("family") or "",
|
||||||
|
"family_label": (printer or {}).get("family_label") or "",
|
||||||
|
"ok": False,
|
||||||
|
"state": "offline",
|
||||||
|
"message": message,
|
||||||
|
"filename": "",
|
||||||
|
"progress": None,
|
||||||
|
"slots": [],
|
||||||
|
"tools": [],
|
||||||
|
"has_camera": False,
|
||||||
|
"camera": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def heads_for(printer):
|
||||||
|
family = (printer or {}).get("family") or ""
|
||||||
|
return 2 if family == "tenlog" else 1
|
||||||
|
|
||||||
|
|
||||||
|
def card_from_status(printer, status):
|
||||||
|
temps = (status or {}).get("temps") or {}
|
||||||
|
heads = int((status or {}).get("heads") or heads_for(printer))
|
||||||
|
tools = []
|
||||||
|
for index in range(heads):
|
||||||
|
key = "T%d" % index
|
||||||
|
cur, target = _pair(temps.get(key) or temps.get("T") or [None, None])
|
||||||
|
tools.append({
|
||||||
|
"index": index,
|
||||||
|
"name": "Kopf %d" % (index + 1),
|
||||||
|
"temp": cur,
|
||||||
|
"target": target,
|
||||||
|
})
|
||||||
|
nozzle = tools[0]["temp"] if tools else None
|
||||||
|
nozzle_target = tools[0]["target"] if tools else None
|
||||||
|
bed, bed_target = _pair(temps.get("B") or [None, None])
|
||||||
|
host = (printer or {}).get("host") or ""
|
||||||
|
return {
|
||||||
|
"name": (printer or {}).get("name") or (status or {}).get("name") or host,
|
||||||
|
"host": host,
|
||||||
|
"family": (printer or {}).get("family") or "",
|
||||||
|
"family_label": (printer or {}).get("family_label") or "",
|
||||||
|
"ok": bool((status or {}).get("ok")),
|
||||||
|
"state": (status or {}).get("state") or "offline",
|
||||||
|
"message": (status or {}).get("message") or "",
|
||||||
|
"filename": (status or {}).get("filename") or "",
|
||||||
|
"progress": (status or {}).get("progress"),
|
||||||
|
"nozzle": nozzle,
|
||||||
|
"nozzle_target": nozzle_target,
|
||||||
|
"bed": bed,
|
||||||
|
"bed_target": bed_target,
|
||||||
|
"slots": [],
|
||||||
|
"tools": tools,
|
||||||
|
"has_camera": False,
|
||||||
|
"camera": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pair(value):
|
||||||
|
if isinstance(value, (list, tuple)) and len(value) >= 2:
|
||||||
|
return value[0], value[1]
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
class MarlinDriver:
|
||||||
|
transport = "marlin"
|
||||||
|
rinkhals = False
|
||||||
|
firmware_restart = False
|
||||||
|
|
||||||
|
def __init__(self, base=None):
|
||||||
|
self.base = (base or service_url()).rstrip("/")
|
||||||
|
|
||||||
|
def snapshot(self, printers):
|
||||||
|
printers = list(printers or [])
|
||||||
|
if printers:
|
||||||
|
self._daemon()
|
||||||
|
cards = []
|
||||||
|
for printer in printers:
|
||||||
|
try:
|
||||||
|
self._ensure(printer)
|
||||||
|
device, _baud = split_port(printer.get("host") or "")
|
||||||
|
status = self._json("GET", "/status?device=%s" % urllib.parse.quote(device))
|
||||||
|
cards.append(card_from_status(printer, status))
|
||||||
|
except PrinterError as exc:
|
||||||
|
cards.append(_offline(printer, exc.message))
|
||||||
|
return cards
|
||||||
|
|
||||||
|
def query(self, host):
|
||||||
|
device, _baud = split_port(host)
|
||||||
|
try:
|
||||||
|
status = self._json("GET", "/status?device=%s" % urllib.parse.quote(device))
|
||||||
|
except PrinterError:
|
||||||
|
raise
|
||||||
|
return card_from_status({"host": host}, status)
|
||||||
|
|
||||||
|
def slots(self, host, status=None):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def upload(self, host, filename, content, on_progress=None):
|
||||||
|
device, _baud = split_port(host)
|
||||||
|
data = content if isinstance(content, bytes) else (content or "").encode("utf-8")
|
||||||
|
result = self._json(
|
||||||
|
"POST",
|
||||||
|
"/datei?device=%s" % urllib.parse.quote(device),
|
||||||
|
data,
|
||||||
|
{"X-Dateiname": filename or "druck.gcode", "Content-Type": "application/octet-stream"},
|
||||||
|
)
|
||||||
|
if on_progress:
|
||||||
|
on_progress(len(data), len(data) or 1)
|
||||||
|
return result.get("filename") or filename
|
||||||
|
|
||||||
|
def start_print(self, host, filename, bed_leveling=False, ams_settings=None, mode="stream"):
|
||||||
|
del bed_leveling, ams_settings
|
||||||
|
device, _baud = split_port(host)
|
||||||
|
chosen = (mode or "stream").strip().lower()
|
||||||
|
if chosen not in ("stream", "sd"):
|
||||||
|
raise PrinterError("Marlin-Start ist Strom oder SD-Karte.")
|
||||||
|
result = self._json("POST", "/start", {
|
||||||
|
"device": device,
|
||||||
|
"filename": filename,
|
||||||
|
"mode": chosen,
|
||||||
|
})
|
||||||
|
return result.get("filename") or filename
|
||||||
|
|
||||||
|
def command(self, host, action):
|
||||||
|
device, _baud = split_port(host)
|
||||||
|
name = (action or "").strip().lower()
|
||||||
|
mapped = {
|
||||||
|
"pause": "pause",
|
||||||
|
"fortsetzen": "fortsetzen",
|
||||||
|
"resume": "fortsetzen",
|
||||||
|
"abbrechen": "abbrechen",
|
||||||
|
"cancel": "abbrechen",
|
||||||
|
}.get(name)
|
||||||
|
if not mapped:
|
||||||
|
raise PrinterError("Unbekannte Aktion.")
|
||||||
|
self._json("POST", "/befehl", {"device": device, "action": mapped})
|
||||||
|
|
||||||
|
def clear_print_error(self, host):
|
||||||
|
device, _baud = split_port(host)
|
||||||
|
self._json("POST", "/befehl", {"device": device, "action": "fehler_loeschen"})
|
||||||
|
|
||||||
|
def heaters_off(self, host):
|
||||||
|
device, _baud = split_port(host)
|
||||||
|
self._json("POST", "/befehl", {"device": device, "action": "heizungen_aus"})
|
||||||
|
|
||||||
|
def attach(self, printer):
|
||||||
|
self._daemon()
|
||||||
|
self._ensure(printer)
|
||||||
|
|
||||||
|
def _daemon(self):
|
||||||
|
try:
|
||||||
|
from grotti.marlin_supervisor import ensure_if_enabled
|
||||||
|
ensure_if_enabled()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _ensure(self, printer):
|
||||||
|
device, baud = split_port(printer.get("host") or "")
|
||||||
|
self._json("POST", "/oeffnen", {
|
||||||
|
"device": device,
|
||||||
|
"baud": baud,
|
||||||
|
"heads": heads_for(printer),
|
||||||
|
"name": printer.get("name") or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
def _json(self, method, path, body=None, headers=None):
|
||||||
|
data = None
|
||||||
|
hdrs = dict(headers or {})
|
||||||
|
if isinstance(body, (dict, list)):
|
||||||
|
data = json.dumps(body).encode("utf-8")
|
||||||
|
hdrs.setdefault("Content-Type", "application/json")
|
||||||
|
elif isinstance(body, bytes):
|
||||||
|
data = body
|
||||||
|
request = urllib.request.Request(self.base + path, data=data, headers=hdrs, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=1.5) as response:
|
||||||
|
raw = response.read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = _error_message(exc)
|
||||||
|
raise PrinterError(detail) from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||||
|
raise PrinterError(_DOWN) from exc
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise PrinterError("Der Marlin-Dienst hat kein JSON geliefert.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _error_message(exc):
|
||||||
|
try:
|
||||||
|
payload = json.loads(exc.read().decode("utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError, UnicodeError):
|
||||||
|
payload = {}
|
||||||
|
return payload.get("message") or _DOWN
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Moonraker-Treiber. Rinkhals ist eine Richtlinie darauf, kein zweiter Client.
|
||||||
|
|
||||||
|
Kobra 3 und S1: kein Firmware-Restart (steckt in moonraker.steuern),
|
||||||
|
ACE-Start über MQTT, Kamera als MJPEG unter /webcam/, Bettnetz aus bed_mesh.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from grotti import moonraker
|
||||||
|
|
||||||
|
|
||||||
|
class MoonrakerDriver:
|
||||||
|
transport = "moonraker"
|
||||||
|
rinkhals = True
|
||||||
|
firmware_restart = False
|
||||||
|
|
||||||
|
def snapshot(self, printers):
|
||||||
|
return moonraker.snapshot(printers)
|
||||||
|
|
||||||
|
def query(self, host):
|
||||||
|
return moonraker.query(host)
|
||||||
|
|
||||||
|
def slots(self, host, status=None):
|
||||||
|
return moonraker.slots(host, status)
|
||||||
|
|
||||||
|
def upload(self, host, filename, content, on_progress=None):
|
||||||
|
return moonraker.upload(host, filename, content, on_progress=on_progress)
|
||||||
|
|
||||||
|
def start_print(self, host, filename, bed_leveling=False, ams_settings=None):
|
||||||
|
return moonraker.start_print(
|
||||||
|
host, filename, bed_leveling=bed_leveling, ams_settings=ams_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
def command(self, host, action):
|
||||||
|
name = (action or "").strip().lower()
|
||||||
|
if name == "cancel":
|
||||||
|
name = "abbrechen"
|
||||||
|
elif name == "resume":
|
||||||
|
name = "fortsetzen"
|
||||||
|
return moonraker.command(host, name)
|
||||||
|
|
||||||
|
def clear_print_error(self, host):
|
||||||
|
return moonraker.clear_print_error(host)
|
||||||
|
|
||||||
|
def heaters_off(self, host):
|
||||||
|
return moonraker.heaters_off(host)
|
||||||
+154
-8
@@ -9,6 +9,7 @@ from grotti import acm, catalog, eject, library, logutil, moonraker, store, usag
|
|||||||
from grotti.cost import energy_cost
|
from grotti.cost import energy_cost
|
||||||
from grotti.errors import GrottiError
|
from grotti.errors import GrottiError
|
||||||
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
|
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
|
||||||
|
from grotti.drivers import for_printer
|
||||||
from grotti.printers import FAMILY_LABELS
|
from grotti.printers import FAMILY_LABELS
|
||||||
|
|
||||||
TERMINAL = {"completed", "cancelled", "error", "interrupted"}
|
TERMINAL = {"completed", "cancelled", "error", "interrupted"}
|
||||||
@@ -128,7 +129,11 @@ def is_filament_resume(
|
|||||||
|
|
||||||
|
|
||||||
def undo_deduction(conn, run):
|
def undo_deduction(conn, run):
|
||||||
"""Spulenabzug eines Laufs zurücknehmen. Abschnitte bleiben."""
|
"""Spulenabzug eines Laufs zurücknehmen.
|
||||||
|
|
||||||
|
Letzten Abschnitt wieder öffnen — nach Filamentpause schließt ein
|
||||||
|
vorzeitiger History-Abgleich den Abschnitt, bevor der Druck weiterläuft.
|
||||||
|
"""
|
||||||
if not run or not run.get("deducted"):
|
if not run or not run.get("deducted"):
|
||||||
return False
|
return False
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -147,6 +152,20 @@ def undo_deduction(conn, run):
|
|||||||
"UPDATE runs SET deducted = false, filament_cost = 0 WHERE id = %s",
|
"UPDATE runs SET deducted = false, filament_cost = 0 WHERE id = %s",
|
||||||
(run["id"],),
|
(run["id"],),
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE run_segments
|
||||||
|
SET mm_end = NULL, closed_at = NULL
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id FROM run_segments
|
||||||
|
WHERE run_id = %s
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
AND mm_end IS NOT NULL
|
||||||
|
""",
|
||||||
|
(run["id"],),
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -583,11 +602,15 @@ def apply_deduction(conn, run, energy, metadata=None):
|
|||||||
final_mm = float(run.get("filament_used_mm") or 0)
|
final_mm = float(run.get("filament_used_mm") or 0)
|
||||||
total_mm = float(run.get("filament_total_mm") or 0)
|
total_mm = float(run.get("filament_total_mm") or 0)
|
||||||
deltas = usage.segment_deltas(segments, final_mm)
|
deltas = usage.segment_deltas(segments, final_mm)
|
||||||
|
# Messwert (oder Soll) anteilig nach mm — nicht Soll gegen Soll-Gesamtlänge,
|
||||||
|
# sonst fehlen Gramm, wenn ein Abschnitt zu früh geschlossen wurde.
|
||||||
|
base_g = used if used > 0 else planned
|
||||||
|
split_mm = sum(d for _, _, d in deltas) or final_mm or total_mm
|
||||||
for spool_id, ace_slot, delta_mm in deltas:
|
for spool_id, ace_slot, delta_mm in deltas:
|
||||||
spool = store.get_spool_row(conn, spool_id)
|
spool = store.get_spool_row(conn, spool_id)
|
||||||
if not spool:
|
if not spool:
|
||||||
continue
|
continue
|
||||||
grams = usage.grams_from_mm(delta_mm, total_mm, planned)
|
grams = usage.grams_from_mm(delta_mm, split_mm, base_g)
|
||||||
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||||||
entries.append({
|
entries.append({
|
||||||
"spool_id": spool_id,
|
"spool_id": spool_id,
|
||||||
@@ -798,6 +821,10 @@ def _sync_one_job(conn, cfg, printer, job, watcher_resume=False):
|
|||||||
conn, printer["host"], ended_at=ended, message=filename or state
|
conn, printer["host"], ended_at=ended, message=filename or state
|
||||||
)
|
)
|
||||||
store.set_printer_active_run(conn, printer["host"], None)
|
store.set_printer_active_run(conn, printer["host"], None)
|
||||||
|
# Resume nach Filamentpause: History kann den alten Teil schon als „fertig“
|
||||||
|
# melden. Abzug und Segment-Schluss erst beim echten Druckende.
|
||||||
|
if watcher_resume:
|
||||||
|
return 0
|
||||||
if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
|
if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
|
||||||
return 1
|
return 1
|
||||||
return 0
|
return 0
|
||||||
@@ -835,7 +862,8 @@ def sync_history(conn, cfg, host=None, watcher_resume=False):
|
|||||||
deducted += _sync_one_job(
|
deducted += _sync_one_job(
|
||||||
conn, cfg, printer, job, watcher_resume=watcher_resume
|
conn, cfg, printer, job, watcher_resume=watcher_resume
|
||||||
)
|
)
|
||||||
deducted += deduct_stale_live_runs(conn, cfg)
|
if not watcher_resume:
|
||||||
|
deducted += deduct_stale_live_runs(conn, cfg)
|
||||||
finally:
|
finally:
|
||||||
conn.execute("SELECT pg_advisory_unlock(%s)", (SYNC_LOCK_KEY,))
|
conn.execute("SELECT pg_advisory_unlock(%s)", (SYNC_LOCK_KEY,))
|
||||||
return {"gelesen": len(collected), "abgezogen": deducted}
|
return {"gelesen": len(collected), "abgezogen": deducted}
|
||||||
@@ -881,6 +909,105 @@ def prepare_send(conn, file_id, host, confirm, override, spool_id, ace_map=None)
|
|||||||
return printer, row, chosen
|
return printer, row, chosen
|
||||||
|
|
||||||
|
|
||||||
|
def _send_via_driver(
|
||||||
|
conn, cfg, drv, printer, row, chosen, host, ace_map, bed_leveling, progress,
|
||||||
|
marlin_mode="stream",
|
||||||
|
):
|
||||||
|
"""Start über einen Treiber ohne Moonraker. Die Datei bleibt ohne Rinkhals-Kopf."""
|
||||||
|
attach = getattr(drv, "attach", None)
|
||||||
|
if attach:
|
||||||
|
try:
|
||||||
|
attach(printer)
|
||||||
|
except moonraker.PrinterError as exc:
|
||||||
|
raise GrottiError(exc.message or "Der Drucker ist nicht erreichbar.", "offline") from exc
|
||||||
|
try:
|
||||||
|
live = drv.query(host)
|
||||||
|
except moonraker.PrinterError as exc:
|
||||||
|
raise GrottiError(exc.message or "Der Drucker ist nicht erreichbar.", "offline") from exc
|
||||||
|
if live.get("state") in ("printing", "paused"):
|
||||||
|
raise GrottiError("Der Drucker druckt schon.", "belegt", 409)
|
||||||
|
if (live.get("state") or "").lower() == "error":
|
||||||
|
raise GrottiError(
|
||||||
|
"Drucker im Fehlerzustand — auf der Wand „Fehler löschen“, dann nochmal starten.",
|
||||||
|
"fehler",
|
||||||
|
409,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
slots = live.get("slots") or drv.slots(host)
|
||||||
|
except moonraker.PrinterError:
|
||||||
|
slots = live.get("slots") or []
|
||||||
|
mapping = acm.normalize_mapping(ace_map)
|
||||||
|
filaments = list(row.get("filaments") or [])
|
||||||
|
if slots and not mapping:
|
||||||
|
raise GrottiError(
|
||||||
|
"Dieser Drucker hat ACE/MMU-Plätze. Bitte Farbe der Datei einem ACE-Platz zuordnen.",
|
||||||
|
"ace",
|
||||||
|
)
|
||||||
|
if slots and mapping and not filaments:
|
||||||
|
filaments = [{"type": "PLA", "color": ""}]
|
||||||
|
progress("vorbereiten", 30, "Datei vorbereiten…")
|
||||||
|
filename, content, plate = library.printer_file(
|
||||||
|
row, conn=conn, data_dir=(cfg or {}).get("data_dir")
|
||||||
|
)
|
||||||
|
filename = moonraker.safe_upload_name(filename)
|
||||||
|
if slots and mapping:
|
||||||
|
content, mapping, filaments, _ = acm.align_tools_for_ace(content, mapping, filaments)
|
||||||
|
ace_payload = acm.build_acm(filaments, mapping, slots) if slots and mapping else None
|
||||||
|
has_trailer = content_has_ace_trailer(content, kind=row.get("kind") or "", plate=plate)
|
||||||
|
warnings = []
|
||||||
|
if slots and mapping and not has_trailer and not ace_payload:
|
||||||
|
warnings.append(acm.TRAILER_WARN)
|
||||||
|
|
||||||
|
def upload_progress(sent, total):
|
||||||
|
if total <= 0:
|
||||||
|
return
|
||||||
|
progress(
|
||||||
|
"hochladen",
|
||||||
|
45 + int(min(1.0, sent / total) * 19),
|
||||||
|
"Hochladen zu %s…" % printer["name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
progress("hochladen", 45, "Hochladen zu %s…" % printer["name"])
|
||||||
|
try:
|
||||||
|
stored = drv.upload(host, filename, content, on_progress=upload_progress)
|
||||||
|
progress("starten", 80, "Druck starten…")
|
||||||
|
start_kwargs = {}
|
||||||
|
if drv.transport == "marlin":
|
||||||
|
start_kwargs["mode"] = marlin_mode if marlin_mode in ("stream", "sd") else "stream"
|
||||||
|
drv.start_print(
|
||||||
|
host,
|
||||||
|
stored,
|
||||||
|
bed_leveling=bool(bed_leveling),
|
||||||
|
ams_settings=ace_payload,
|
||||||
|
**start_kwargs,
|
||||||
|
)
|
||||||
|
except moonraker.PrinterError as exc:
|
||||||
|
try:
|
||||||
|
drv.heaters_off(host)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise GrottiError(exc.message, "drucker") from exc
|
||||||
|
progress("buchen", 95, "Auftrag speichern…")
|
||||||
|
store.add_send(
|
||||||
|
conn, host, stored, row["id"], row["project_id"], chosen,
|
||||||
|
ace_map=mapping, bed_leveling=bool(bed_leveling),
|
||||||
|
)
|
||||||
|
note = "Gestartet auf %s: %s" % (printer["name"], stored)
|
||||||
|
if warnings:
|
||||||
|
note += " · " + " · ".join(warnings)
|
||||||
|
progress("fertig", 100, note)
|
||||||
|
return {
|
||||||
|
"printer": printer["name"],
|
||||||
|
"filename": stored,
|
||||||
|
"plate": plate,
|
||||||
|
"ace": bool(slots and mapping),
|
||||||
|
"ace_trailer": has_trailer,
|
||||||
|
"warnings": warnings,
|
||||||
|
"bed_leveling": bool(bed_leveling),
|
||||||
|
"bed_leveling_wired": drv.transport == "moonraker",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def send_file(
|
def send_file(
|
||||||
conn,
|
conn,
|
||||||
cfg,
|
cfg,
|
||||||
@@ -894,6 +1021,7 @@ def send_file(
|
|||||||
ace_map=None,
|
ace_map=None,
|
||||||
bed_leveling=False,
|
bed_leveling=False,
|
||||||
keep_motors=False,
|
keep_motors=False,
|
||||||
|
marlin_mode="stream",
|
||||||
):
|
):
|
||||||
def progress(step, percent, message):
|
def progress(step, percent, message):
|
||||||
if on_progress:
|
if on_progress:
|
||||||
@@ -916,6 +1044,12 @@ def send_file(
|
|||||||
assert_filament_enough(conn, row, chosen, ace_map, host)
|
assert_filament_enough(conn, row, chosen, ace_map, host)
|
||||||
|
|
||||||
progress("pruefen", 15, "Drucker prüfen…")
|
progress("pruefen", 15, "Drucker prüfen…")
|
||||||
|
drv = for_printer(printer)
|
||||||
|
if drv.transport != "moonraker":
|
||||||
|
return _send_via_driver(
|
||||||
|
conn, cfg, drv, printer, row, chosen, host, ace_map, bed_leveling, progress,
|
||||||
|
marlin_mode=marlin_mode,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
live = moonraker.query(host)
|
live = moonraker.query(host)
|
||||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError, ValueError) as exc:
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError, ValueError) as exc:
|
||||||
@@ -1018,16 +1152,16 @@ def send_file(
|
|||||||
warnings.append(acm.TRAILER_WARN)
|
warnings.append(acm.TRAILER_WARN)
|
||||||
progress("ace", 42, acm.TRAILER_WARN)
|
progress("ace", 42, acm.TRAILER_WARN)
|
||||||
try:
|
try:
|
||||||
stored = moonraker.upload(host, filename, content, on_progress=upload_progress)
|
stored = drv.upload(host, filename, content, on_progress=upload_progress)
|
||||||
if slots and mapping:
|
if slots and mapping:
|
||||||
ace_payload = acm.build_acm(filaments, mapping, slots)
|
ace_payload = acm.build_acm(filaments, mapping, slots)
|
||||||
if ace_payload:
|
if ace_payload:
|
||||||
progress("ace", 65, "ACE-Zuordnung hochladen…")
|
progress("ace", 65, "ACE-Zuordnung hochladen…")
|
||||||
body = acm.acm_bytes(ace_payload)
|
body = acm.acm_bytes(ace_payload)
|
||||||
for acm_name in acm.acm_upload_names(stored):
|
for acm_name in acm.acm_upload_names(stored):
|
||||||
moonraker.upload(host, acm_name, body)
|
drv.upload(host, acm_name, body)
|
||||||
progress("starten", 80, "Druck starten…")
|
progress("starten", 80, "Druck starten…")
|
||||||
moonraker.start_print(
|
drv.start_print(
|
||||||
host,
|
host,
|
||||||
stored,
|
stored,
|
||||||
bed_leveling=bool(bed_leveling),
|
bed_leveling=bool(bed_leveling),
|
||||||
@@ -1035,7 +1169,7 @@ def send_file(
|
|||||||
)
|
)
|
||||||
except moonraker.PrinterError as exc:
|
except moonraker.PrinterError as exc:
|
||||||
try:
|
try:
|
||||||
moonraker.heaters_off(host)
|
drv.heaters_off(host)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
raise GrottiError(exc.message, "drucker") from exc
|
raise GrottiError(exc.message, "drucker") from exc
|
||||||
@@ -1075,7 +1209,7 @@ def control(conn, host, action):
|
|||||||
if not printer:
|
if not printer:
|
||||||
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
||||||
try:
|
try:
|
||||||
moonraker.command(host, action)
|
for_printer(printer).command(host, action)
|
||||||
except moonraker.PrinterError as exc:
|
except moonraker.PrinterError as exc:
|
||||||
raise GrottiError(exc.message, "drucker") from exc
|
raise GrottiError(exc.message, "drucker") from exc
|
||||||
return {"printer": printer["name"], "action": action}
|
return {"printer": printer["name"], "action": action}
|
||||||
@@ -1085,6 +1219,18 @@ def steuern(conn, host, action, params=None):
|
|||||||
printer = store.printer_by_host(conn, host)
|
printer = store.printer_by_host(conn, host)
|
||||||
if not printer:
|
if not printer:
|
||||||
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
||||||
|
drv = for_printer(printer)
|
||||||
|
if action == "clear_error":
|
||||||
|
try:
|
||||||
|
drv.clear_print_error(host)
|
||||||
|
except moonraker.PrinterError as exc:
|
||||||
|
raise GrottiError(exc.message, "drucker") from exc
|
||||||
|
return {"printer": printer["name"], "action": action}
|
||||||
|
if drv.transport != "moonraker":
|
||||||
|
raise GrottiError(
|
||||||
|
"Heizung und Plätze laufen an diesem Drucker über den G-code.",
|
||||||
|
"treiber",
|
||||||
|
)
|
||||||
params = dict(params) if isinstance(params, dict) else {}
|
params = dict(params) if isinstance(params, dict) else {}
|
||||||
if action == "slot_setzen":
|
if action == "slot_setzen":
|
||||||
try:
|
try:
|
||||||
|
|||||||
+70
-9
@@ -46,7 +46,14 @@ def sync_watch(conn, cfg, force=False):
|
|||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if not force and _LAST_WATCH_SYNC and now - _LAST_WATCH_SYNC < _WATCH_MIN_INTERVAL_S:
|
if not force and _LAST_WATCH_SYNC and now - _LAST_WATCH_SYNC < _WATCH_MIN_INTERVAL_S:
|
||||||
return {"gelesen": 0, "hinweis": "", "skipped": True}
|
return {"gelesen": 0, "hinweis": "", "skipped": True}
|
||||||
|
known = {}
|
||||||
|
if not force:
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT stored_path, size_bytes, mtime FROM files WHERE source = 'watch'"
|
||||||
|
).fetchall():
|
||||||
|
known[row["stored_path"]] = (int(row["size_bytes"] or 0), float(row["mtime"] or 0))
|
||||||
seen = []
|
seen = []
|
||||||
|
gelesen = 0
|
||||||
for path in folder.rglob("*"):
|
for path in folder.rglob("*"):
|
||||||
if not path.is_file() or skip_path(path, cfg["data_dir"]):
|
if not path.is_file() or skip_path(path, cfg["data_dir"]):
|
||||||
continue
|
continue
|
||||||
@@ -54,14 +61,26 @@ def sync_watch(conn, cfg, force=False):
|
|||||||
if not (lower.endswith(".gcode") or lower.endswith(".3mf")):
|
if not (lower.endswith(".gcode") or lower.endswith(".3mf")):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
resolved = str(path.resolve())
|
||||||
|
seen.append(resolved)
|
||||||
|
if not force:
|
||||||
|
st = path.stat()
|
||||||
|
prev = known.get(resolved)
|
||||||
|
if prev and prev[0] == st.st_size and prev[1] == float(st.st_mtime):
|
||||||
|
continue
|
||||||
store.upsert_scanned(conn, meta_for(path), force=force)
|
store.upsert_scanned(conn, meta_for(path), force=force)
|
||||||
seen.append(str(path.resolve()))
|
gelesen += 1
|
||||||
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)
|
link_watch_files_to_projects(conn, cfg)
|
||||||
|
dropped = drop_upload_watch_duplicates(conn, cfg)
|
||||||
_LAST_WATCH_SYNC = now
|
_LAST_WATCH_SYNC = now
|
||||||
return {"gelesen": len(seen), "hinweis": ""}
|
result = {"gelesen": gelesen, "hinweis": ""}
|
||||||
|
if dropped:
|
||||||
|
result["duplikate"] = dropped
|
||||||
|
result["hinweis"] = "%d doppelte Upload-Zeile(n) entfernt." % dropped
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _apply_meta(conn, file_id, row, meta):
|
def _apply_meta(conn, file_id, row, meta):
|
||||||
@@ -77,6 +96,7 @@ def _apply_meta(conn, file_id, row, meta):
|
|||||||
"kind": meta.get("kind") or row.get("kind"),
|
"kind": meta.get("kind") or row.get("kind"),
|
||||||
"plate": meta.get("plate") or "",
|
"plate": meta.get("plate") or "",
|
||||||
"slice_meta": meta.get("slice_meta") or {},
|
"slice_meta": meta.get("slice_meta") or {},
|
||||||
|
"ace_trailer": bool(meta.get("ace_trailer")),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,7 +115,7 @@ def refresh_one_file(conn, file_id, watch_dir=None):
|
|||||||
row = store.get_file_row(conn, file_id) or row
|
row = store.get_file_row(conn, file_id) or row
|
||||||
except (OSError, zipfile.BadZipFile):
|
except (OSError, zipfile.BadZipFile):
|
||||||
pass
|
pass
|
||||||
return store.file_out(row, watch_dir=watch_dir)
|
return store.file_out(row, watch_dir=watch_dir, conn=conn)
|
||||||
|
|
||||||
|
|
||||||
def filaments_need_paint_index(filaments):
|
def filaments_need_paint_index(filaments):
|
||||||
@@ -276,7 +296,7 @@ def move_file(conn, cfg, file_id, 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() and source == "watch":
|
if dest.resolve() == src.resolve() and source == "watch":
|
||||||
return store.file_out(row, watch_dir=root)
|
return store.file_out(row, watch_dir=root, conn=conn)
|
||||||
if dest.exists() and dest.resolve() != src.resolve():
|
if dest.exists() and dest.resolve() != src.resolve():
|
||||||
dest = unique_path(dest_dir, src.name)
|
dest = unique_path(dest_dir, src.name)
|
||||||
try:
|
try:
|
||||||
@@ -295,7 +315,7 @@ def move_file(conn, cfg, file_id, folder):
|
|||||||
)
|
)
|
||||||
_link_file_to_project_folder(conn, file_id, str(dest.resolve()), root)
|
_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, conn=conn)
|
||||||
|
|
||||||
|
|
||||||
def path_allowed_for_delete(stored_path, cfg):
|
def path_allowed_for_delete(stored_path, cfg):
|
||||||
@@ -365,7 +385,7 @@ def rename_library_file(conn, cfg, file_id, new_name):
|
|||||||
raise GrottiError("Ungültiger Dateiname.", "datei")
|
raise GrottiError("Ungültiger Dateiname.", "datei")
|
||||||
dest = src.with_name(final)
|
dest = src.with_name(final)
|
||||||
if dest.resolve() == src.resolve():
|
if dest.resolve() == src.resolve():
|
||||||
return store.file_out(row, watch_dir=cfg.get("watch_dir"))
|
return store.file_out(row, watch_dir=cfg.get("watch_dir"), conn=conn)
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
raise GrottiError("Unter diesem Namen liegt schon eine Datei.", "datei")
|
raise GrottiError("Unter diesem Namen liegt schon eine Datei.", "datei")
|
||||||
try:
|
try:
|
||||||
@@ -382,7 +402,7 @@ def rename_library_file(conn, cfg, file_id, new_name):
|
|||||||
mtime=stat.st_mtime,
|
mtime=stat.st_mtime,
|
||||||
)
|
)
|
||||||
row = store.get_file_row(conn, file_id)
|
row = store.get_file_row(conn, file_id)
|
||||||
return store.file_out(row, watch_dir=cfg.get("watch_dir"))
|
return store.file_out(row, watch_dir=cfg.get("watch_dir"), conn=conn)
|
||||||
|
|
||||||
|
|
||||||
_PRINT_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
|
_PRINT_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
|
||||||
@@ -541,15 +561,56 @@ def delete_project_disk_folder(cfg, folder_rel, force=False):
|
|||||||
|
|
||||||
|
|
||||||
def save_upload(conn, filename, content, cfg, project_id=None):
|
def save_upload(conn, filename, content, cfg, project_id=None):
|
||||||
|
name = safe_filename(filename)
|
||||||
|
twin = store.find_watch_twin(conn, name, len(content or b""))
|
||||||
|
if twin:
|
||||||
|
# Orca hat schon in den Slice-Ordner geschrieben — kein zweiter Upload-Eintrag
|
||||||
|
if project_id and not twin.get("project_id"):
|
||||||
|
store.set_file_project(conn, twin["id"], project_id)
|
||||||
|
twin = store.get_file_row(conn, twin["id"]) or twin
|
||||||
|
return store.file_out(twin, watch_dir=cfg.get("watch_dir"), conn=conn)
|
||||||
directory = cfg["data_dir"] / "eingang"
|
directory = cfg["data_dir"] / "eingang"
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
path = unique_path(directory, filename)
|
path = unique_path(directory, name)
|
||||||
path.write_bytes(content)
|
path.write_bytes(content)
|
||||||
meta = meta_for(path)
|
meta = meta_for(path)
|
||||||
meta["project_id"] = project_id
|
meta["project_id"] = project_id
|
||||||
return store.insert_upload(conn, meta)
|
return store.insert_upload(conn, meta)
|
||||||
|
|
||||||
|
|
||||||
|
def drop_upload_watch_duplicates(conn, cfg):
|
||||||
|
"""Upload-Zeilen entfernen, die dieselbe Datei schon als Watch (mit Projekt) haben."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.id AS upload_id, w.id AS watch_id, u.stored_path, u.project_id AS upload_project,
|
||||||
|
w.project_id AS watch_project
|
||||||
|
FROM files u
|
||||||
|
JOIN files w
|
||||||
|
ON w.source = 'watch'
|
||||||
|
AND w.name = u.name
|
||||||
|
AND w.size_bytes = u.size_bytes
|
||||||
|
WHERE u.source = 'upload'
|
||||||
|
ORDER BY u.id
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
removed = 0
|
||||||
|
for row in rows:
|
||||||
|
upload_id = row["upload_id"]
|
||||||
|
watch_id = row["watch_id"]
|
||||||
|
if row.get("upload_project") and not row.get("watch_project"):
|
||||||
|
store.set_file_project(conn, watch_id, row["upload_project"])
|
||||||
|
store.retarget_file_id(conn, upload_id, watch_id)
|
||||||
|
stored = row.get("stored_path") or ""
|
||||||
|
store.delete_file(conn, upload_id)
|
||||||
|
if stored:
|
||||||
|
try:
|
||||||
|
Path(stored).unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
removed += 1
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def ensure_library_gcode(conn, cfg, host, filename):
|
def ensure_library_gcode(conn, cfg, host, filename):
|
||||||
"""Wenn die laufende Datei fehlt: einmal vom Drucker nach Grotti ziehen."""
|
"""Wenn die laufende Datei fehlt: einmal vom Drucker nach Grotti ziehen."""
|
||||||
base = (filename or "").replace("\\", "/").split("/")[-1]
|
base = (filename or "").replace("\\", "/").split("/")[-1]
|
||||||
@@ -559,7 +620,7 @@ def ensure_library_gcode(conn, cfg, host, filename):
|
|||||||
if known:
|
if known:
|
||||||
path = Path(known.get("stored_path") or "")
|
path = Path(known.get("stored_path") or "")
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
return store.file_out(known)
|
return store.file_out(known, conn=conn)
|
||||||
from grotti import moonraker
|
from grotti import moonraker
|
||||||
|
|
||||||
pulled = moonraker.download_gcode(host, base)
|
pulled = moonraker.download_gcode(host, base)
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Marlin-Dienst. Lauscht nur auf dem eigenen Rechner.
|
||||||
|
|
||||||
|
Start::
|
||||||
|
|
||||||
|
python -m grotti.marlin_daemon
|
||||||
|
|
||||||
|
Port über ``GROTTI_MARLIN_PORT`` (Standard 8771). Die Web-App spricht ihn
|
||||||
|
unter ``GROTTI_MARLIN_URL`` an. Der Prozess hält die USB-Ports, auch wenn
|
||||||
|
Grotti neu startet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
from grotti.marlin_link import MarlinError, MarlinService
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(handler):
|
||||||
|
length = int(handler.headers.get("Content-Length") or 0)
|
||||||
|
raw = handler.rfile.read(length) if length else b""
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise MarlinError("Der Aufruf ist kein Objekt.")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _send(handler, code, payload):
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
handler.send_response(code)
|
||||||
|
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
handler.send_header("Content-Length", str(len(body)))
|
||||||
|
handler.end_headers()
|
||||||
|
handler.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
return
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
service = self.server.service
|
||||||
|
path = self.path.split("?", 1)[0]
|
||||||
|
try:
|
||||||
|
if path == "/gesundheit":
|
||||||
|
_send(self, 200, {"ok": True})
|
||||||
|
return
|
||||||
|
if path == "/status":
|
||||||
|
device = _query_device(self.path)
|
||||||
|
_send(self, 200, service.session(device).status())
|
||||||
|
return
|
||||||
|
_send(self, 404, {"message": "Unbekannt."})
|
||||||
|
except MarlinError as exc:
|
||||||
|
_send(self, 404, {"message": exc.message})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
service = self.server.service
|
||||||
|
path = self.path.split("?", 1)[0]
|
||||||
|
try:
|
||||||
|
if path == "/oeffnen":
|
||||||
|
data = _read_json(self)
|
||||||
|
service.connect(
|
||||||
|
data.get("device") or "",
|
||||||
|
baud=data.get("baud") or 115200,
|
||||||
|
heads=data.get("heads") or 1,
|
||||||
|
name=data.get("name") or "",
|
||||||
|
)
|
||||||
|
_send(self, 200, service.session(data.get("device")).status())
|
||||||
|
return
|
||||||
|
if path == "/datei":
|
||||||
|
device = _query_device(self.path)
|
||||||
|
name = self.headers.get("X-Dateiname") or "druck.gcode"
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
content = self.rfile.read(length) if length else b""
|
||||||
|
stored = service.store_file(device, name, content)
|
||||||
|
_send(self, 200, {"filename": stored})
|
||||||
|
return
|
||||||
|
if path == "/start":
|
||||||
|
data = _read_json(self)
|
||||||
|
shown = service.start(
|
||||||
|
data.get("device") or "",
|
||||||
|
data.get("filename") or "",
|
||||||
|
(data.get("mode") or "stream").strip().lower(),
|
||||||
|
)
|
||||||
|
_send(self, 200, {"filename": shown})
|
||||||
|
return
|
||||||
|
if path == "/befehl":
|
||||||
|
data = _read_json(self)
|
||||||
|
_befehl(service.session(data.get("device") or ""), data.get("action") or "")
|
||||||
|
_send(self, 200, service.session(data.get("device")).status())
|
||||||
|
return
|
||||||
|
_send(self, 404, {"message": "Unbekannt."})
|
||||||
|
except MarlinError as exc:
|
||||||
|
code = 409 if "druckt schon" in exc.message else 400
|
||||||
|
_send(self, code, {"message": exc.message})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
_send(self, 400, {"message": "Der Aufruf ist kein JSON."})
|
||||||
|
|
||||||
|
|
||||||
|
def _query_device(path):
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
query = parse_qs(urlsplit(path).query)
|
||||||
|
device = (query.get("device") or [""])[0].strip()
|
||||||
|
if not device:
|
||||||
|
raise MarlinError("Die serielle Schnittstelle fehlt.")
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
def _befehl(session, action):
|
||||||
|
name = (action or "").strip().lower()
|
||||||
|
if name in ("pause",):
|
||||||
|
session.pause()
|
||||||
|
return
|
||||||
|
if name in ("fortsetzen", "resume"):
|
||||||
|
session.resume()
|
||||||
|
return
|
||||||
|
if name in ("abbrechen", "cancel"):
|
||||||
|
session.cancel()
|
||||||
|
return
|
||||||
|
if name in ("heizungen_aus", "heaters_off"):
|
||||||
|
session.heaters_off()
|
||||||
|
return
|
||||||
|
if name in ("fehler_loeschen", "clear_error"):
|
||||||
|
session.clear_error()
|
||||||
|
return
|
||||||
|
raise MarlinError("Unbekannte Aktion.")
|
||||||
|
|
||||||
|
|
||||||
|
def serve(port, service=None):
|
||||||
|
httpd = ThreadingHTTPServer(("127.0.0.1", int(port)), Handler)
|
||||||
|
httpd.service = service or MarlinService()
|
||||||
|
return httpd
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
port = int(os.environ.get("GROTTI_MARLIN_PORT") or 8771)
|
||||||
|
httpd = serve(port)
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"""Marlin über eine serielle Leitung.
|
||||||
|
|
||||||
|
Der Dienst hält den Port. Ein Neustart der Web-App schließt ihn nicht.
|
||||||
|
Start ist Strom (Zeile, dann ``ok``) oder SD-Karte (``M28``/``M29``, dann ``M24``).
|
||||||
|
Zwei Köpfe, wie am Tenlog, sind T0 und T1. Die Leitung kennt kein ACE.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class MarlinError(Exception):
|
||||||
|
def __init__(self, message):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
_TEMP = re.compile(
|
||||||
|
r"(?<![A-Z])(T\d+|T|B):(-?\d+(?:\.\d+)?)(?:\s*/\s*(-?\d+(?:\.\d+)?))?",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_SD_BYTE = re.compile(r"SD printing byte\s+(\d+)\s*/\s*(\d+)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def split_port(host):
|
||||||
|
"""``COM5`` oder ``COM5@250000``. Baud sonst 115200."""
|
||||||
|
text = (host or "").strip()
|
||||||
|
if "@" in text:
|
||||||
|
device, raw = text.rsplit("@", 1)
|
||||||
|
try:
|
||||||
|
baud = int(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise MarlinError("Baudrate hinter @ ist keine Zahl.") from exc
|
||||||
|
if baud <= 0:
|
||||||
|
raise MarlinError("Baudrate hinter @ ist keine Zahl.")
|
||||||
|
return device.strip(), baud
|
||||||
|
return text, 115200
|
||||||
|
|
||||||
|
|
||||||
|
def gcode_lines(content):
|
||||||
|
"""Druckzeilen ohne Kommentare und leere Zeilen. ``T0``/``T1`` bleiben."""
|
||||||
|
if isinstance(content, str):
|
||||||
|
raw = content
|
||||||
|
else:
|
||||||
|
raw = (content or b"").decode("utf-8", "replace")
|
||||||
|
lines = []
|
||||||
|
for row in raw.splitlines():
|
||||||
|
line = row.split(";", 1)[0].strip()
|
||||||
|
if line:
|
||||||
|
lines.append(line)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def sd_filename(name):
|
||||||
|
"""8.3, wie es ältere Marlin-Karten erwarten."""
|
||||||
|
stem = Path(name or "").stem
|
||||||
|
chars = [char for char in stem.upper() if char.isalnum()]
|
||||||
|
base = "".join(chars)[:8] or "GROTTI"
|
||||||
|
return base + ".GCO"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_temps(text):
|
||||||
|
found = {}
|
||||||
|
for key, cur, target in _TEMP.findall(text or ""):
|
||||||
|
found[key.upper()] = (float(cur), float(target or 0))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sd_progress(text):
|
||||||
|
match = _SD_BYTE.search(text or "")
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
total = int(match.group(2))
|
||||||
|
if total <= 0:
|
||||||
|
return None
|
||||||
|
return int(match.group(1)) / total
|
||||||
|
|
||||||
|
|
||||||
|
class SerialPort:
|
||||||
|
"""Dünne Hülle um pyserial. Tests geben eine eigene Leitung hinein."""
|
||||||
|
|
||||||
|
def __init__(self, raw):
|
||||||
|
self.raw = raw
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
self.raw.write(data)
|
||||||
|
|
||||||
|
def readline(self):
|
||||||
|
return self.raw.readline()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
close = getattr(self.raw, "close", None)
|
||||||
|
if close:
|
||||||
|
close()
|
||||||
|
|
||||||
|
|
||||||
|
def open_pyserial(device, baud):
|
||||||
|
try:
|
||||||
|
import serial
|
||||||
|
except ImportError as exc:
|
||||||
|
raise MarlinError("pyserial fehlt. pip install pyserial") from exc
|
||||||
|
try:
|
||||||
|
raw = serial.Serial(device, baudrate=baud, timeout=2)
|
||||||
|
except Exception as exc:
|
||||||
|
raise MarlinError(
|
||||||
|
"Serielle Schnittstelle %s geht nicht auf: %s" % (device, exc)
|
||||||
|
) from exc
|
||||||
|
return SerialPort(raw)
|
||||||
|
|
||||||
|
|
||||||
|
class MarlinSession:
|
||||||
|
def __init__(self, device, port, heads=1, name=""):
|
||||||
|
self.device = device
|
||||||
|
self.port = port
|
||||||
|
self.heads = 2 if int(heads or 1) > 1 else 1
|
||||||
|
self.name = name or device
|
||||||
|
self.state = "standby"
|
||||||
|
self.message = ""
|
||||||
|
self.filename = ""
|
||||||
|
self.mode = ""
|
||||||
|
self.progress = None
|
||||||
|
self.temps = {}
|
||||||
|
self._pause = threading.Event()
|
||||||
|
self._stop = False
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
def alive(self):
|
||||||
|
return self.port is not None
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
return {
|
||||||
|
"ok": self.state not in ("offline", "error"),
|
||||||
|
"state": self.state,
|
||||||
|
"message": self.message,
|
||||||
|
"filename": self.filename,
|
||||||
|
"mode": self.mode,
|
||||||
|
"progress": self.progress,
|
||||||
|
"temps": {key: [cur, target] for key, (cur, target) in self.temps.items()},
|
||||||
|
"heads": self.heads,
|
||||||
|
"name": self.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
def transact(self, line, timeout=8):
|
||||||
|
payload = (line.strip() + "\n").encode("ascii", "replace")
|
||||||
|
self.port.write(payload)
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
raw = self.port.readline()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
text = raw.decode("utf-8", "replace").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
low = text.lower()
|
||||||
|
temps = parse_temps(text)
|
||||||
|
if temps:
|
||||||
|
self.temps.update(temps)
|
||||||
|
done = parse_sd_progress(text)
|
||||||
|
if done is not None and self.mode == "sd":
|
||||||
|
self.progress = done
|
||||||
|
if "done printing file" in low:
|
||||||
|
self.state = "complete"
|
||||||
|
self.progress = 1
|
||||||
|
if low.startswith("ok"):
|
||||||
|
return text
|
||||||
|
if low.startswith("error") or low.startswith("!!") or "unknown command" in low:
|
||||||
|
raise MarlinError(text)
|
||||||
|
raise MarlinError("Keine Antwort auf: %s" % line.strip())
|
||||||
|
|
||||||
|
def handshake(self):
|
||||||
|
self.transact("M115", timeout=3)
|
||||||
|
self.transact("M105", timeout=3)
|
||||||
|
self.state = "standby"
|
||||||
|
self.message = ""
|
||||||
|
|
||||||
|
def begin(self, filename, lines, mode):
|
||||||
|
if mode not in ("stream", "sd"):
|
||||||
|
raise MarlinError("Start ist Strom oder SD-Karte.")
|
||||||
|
if self.state in ("printing", "paused"):
|
||||||
|
raise MarlinError("Der Drucker druckt schon.")
|
||||||
|
if not lines:
|
||||||
|
raise MarlinError("Die Datei hat keine Druckzeilen.")
|
||||||
|
self.filename = filename
|
||||||
|
self.mode = mode
|
||||||
|
self.progress = 0
|
||||||
|
self.message = ""
|
||||||
|
self._stop = False
|
||||||
|
self._pause.clear()
|
||||||
|
self.state = "printing"
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run, args=(list(lines), mode), name="marlin-%s" % self.device, daemon=True,
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
if self.state != "printing":
|
||||||
|
raise MarlinError("Es läuft kein Druck.")
|
||||||
|
self._pause.set()
|
||||||
|
self.state = "paused"
|
||||||
|
if self.mode == "sd":
|
||||||
|
with self._lock:
|
||||||
|
self.transact("M25")
|
||||||
|
|
||||||
|
def resume(self):
|
||||||
|
if self.state != "paused":
|
||||||
|
raise MarlinError("Der Druck ist nicht pausiert.")
|
||||||
|
if self.mode == "sd":
|
||||||
|
with self._lock:
|
||||||
|
self.transact("M24")
|
||||||
|
self._pause.clear()
|
||||||
|
self.state = "printing"
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self._stop = True
|
||||||
|
self._pause.clear()
|
||||||
|
with self._lock:
|
||||||
|
if self.mode == "sd":
|
||||||
|
try:
|
||||||
|
self.transact("M524")
|
||||||
|
except MarlinError:
|
||||||
|
try:
|
||||||
|
self.transact("M25")
|
||||||
|
except MarlinError:
|
||||||
|
pass
|
||||||
|
self.heaters_off()
|
||||||
|
self.state = "standby"
|
||||||
|
self.filename = ""
|
||||||
|
self.progress = None
|
||||||
|
self.mode = ""
|
||||||
|
self.message = ""
|
||||||
|
|
||||||
|
def heaters_off(self):
|
||||||
|
with self._lock:
|
||||||
|
if self.heads > 1:
|
||||||
|
self.transact("M104 T0 S0")
|
||||||
|
self.transact("M104 T1 S0")
|
||||||
|
else:
|
||||||
|
self.transact("M104 S0")
|
||||||
|
self.transact("M140 S0")
|
||||||
|
self.transact("M107")
|
||||||
|
|
||||||
|
def clear_error(self):
|
||||||
|
with self._lock:
|
||||||
|
self.transact("M999")
|
||||||
|
self.state = "standby"
|
||||||
|
self.message = ""
|
||||||
|
|
||||||
|
def _run(self, lines, mode):
|
||||||
|
try:
|
||||||
|
if mode == "sd":
|
||||||
|
self._run_sd(lines)
|
||||||
|
else:
|
||||||
|
self._run_stream(lines)
|
||||||
|
except MarlinError as exc:
|
||||||
|
self.state = "error"
|
||||||
|
self.message = exc.message
|
||||||
|
|
||||||
|
def _wait_if_paused(self):
|
||||||
|
while self._pause.is_set() and not self._stop:
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
def _run_stream(self, lines):
|
||||||
|
total = len(lines)
|
||||||
|
polled = time.monotonic()
|
||||||
|
for index, line in enumerate(lines, start=1):
|
||||||
|
self._wait_if_paused()
|
||||||
|
if self._stop:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
self.transact(line)
|
||||||
|
if time.monotonic() - polled > 2:
|
||||||
|
self.transact("M105")
|
||||||
|
polled = time.monotonic()
|
||||||
|
self.progress = index / total
|
||||||
|
if not self._stop:
|
||||||
|
self.state = "complete"
|
||||||
|
self.progress = 1
|
||||||
|
|
||||||
|
def _run_sd(self, lines):
|
||||||
|
stored = sd_filename(self.filename)
|
||||||
|
with self._lock:
|
||||||
|
self.transact("M21")
|
||||||
|
self.transact("M28 %s" % stored)
|
||||||
|
for line in lines:
|
||||||
|
if self._stop:
|
||||||
|
with self._lock:
|
||||||
|
self.transact("M29")
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
self.transact(line)
|
||||||
|
if self._stop:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
self.transact("M29")
|
||||||
|
self.transact("M23 %s" % stored)
|
||||||
|
self.transact("M24")
|
||||||
|
while not self._stop and self.state == "printing":
|
||||||
|
self._wait_if_paused()
|
||||||
|
if self._stop or self.state != "printing":
|
||||||
|
return
|
||||||
|
time.sleep(2)
|
||||||
|
with self._lock:
|
||||||
|
if self._stop or self.state != "printing":
|
||||||
|
return
|
||||||
|
text = self.transact("M27")
|
||||||
|
if self.state == "complete":
|
||||||
|
return
|
||||||
|
if "not sd printing" in text.lower() and (self.progress or 0) > 0:
|
||||||
|
self.state = "complete"
|
||||||
|
self.progress = 1
|
||||||
|
return
|
||||||
|
self.transact("M105")
|
||||||
|
|
||||||
|
|
||||||
|
class MarlinService:
|
||||||
|
def __init__(self, open_port=None):
|
||||||
|
self._open_port = open_port or open_pyserial
|
||||||
|
self.machines = {}
|
||||||
|
self.files = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def connect(self, device, baud=115200, heads=1, name=""):
|
||||||
|
device = (device or "").strip()
|
||||||
|
if not device:
|
||||||
|
raise MarlinError("Die serielle Schnittstelle fehlt.")
|
||||||
|
with self._lock:
|
||||||
|
current = self.machines.get(device)
|
||||||
|
if current and current.alive():
|
||||||
|
if heads:
|
||||||
|
current.heads = 2 if int(heads) > 1 else 1
|
||||||
|
if name:
|
||||||
|
current.name = name
|
||||||
|
return current
|
||||||
|
port = self._open_port(device, int(baud or 115200))
|
||||||
|
session = MarlinSession(device, port, heads=heads, name=name)
|
||||||
|
try:
|
||||||
|
session.handshake()
|
||||||
|
except MarlinError:
|
||||||
|
session.port.close()
|
||||||
|
raise
|
||||||
|
with self._lock:
|
||||||
|
self.machines[device] = session
|
||||||
|
return session
|
||||||
|
|
||||||
|
def session(self, device):
|
||||||
|
with self._lock:
|
||||||
|
found = self.machines.get((device or "").strip())
|
||||||
|
if not found:
|
||||||
|
raise MarlinError("Dieser Drucker ist am Marlin-Dienst nicht angemeldet.")
|
||||||
|
return found
|
||||||
|
|
||||||
|
def store_file(self, device, filename, content):
|
||||||
|
self.session(device)
|
||||||
|
name = Path(filename or "druck.gcode").name
|
||||||
|
if not name:
|
||||||
|
raise MarlinError("Der Dateiname fehlt.")
|
||||||
|
self.files[(device, name)] = content or b""
|
||||||
|
return name
|
||||||
|
|
||||||
|
def start(self, device, filename, mode):
|
||||||
|
session = self.session(device)
|
||||||
|
name = Path(filename or "").name
|
||||||
|
content = self.files.get((device, name))
|
||||||
|
if content is None:
|
||||||
|
raise MarlinError("Die Datei liegt nicht im Marlin-Dienst.")
|
||||||
|
lines = gcode_lines(content)
|
||||||
|
shown = sd_filename(name) if mode == "sd" else name
|
||||||
|
session.begin(shown if mode == "sd" else name, lines, mode)
|
||||||
|
return shown
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Startet den Marlin-Dienst und lässt ihn laufen.
|
||||||
|
|
||||||
|
Grotti beendet den Prozess nicht. Ein Neustart oder Absturz der Web-App
|
||||||
|
schließt die USB-Leitung nicht. Lauscht der Dienst schon, wird kein zweiter
|
||||||
|
gestartet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
UNIT = "grotti-marlin.service"
|
||||||
|
_CREATE_BREAKAWAY = 0x01000000
|
||||||
|
_DETACHED = 0x00000008
|
||||||
|
_NEW_GROUP = 0x00000200
|
||||||
|
_NO_WINDOW = 0x08000000
|
||||||
|
|
||||||
|
_enabled = False
|
||||||
|
_supervisor = None
|
||||||
|
_guard = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def service_url():
|
||||||
|
return (os.environ.get("GROTTI_MARLIN_URL") or "http://127.0.0.1:8771").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def health_check(url=None):
|
||||||
|
target = (url or service_url()) + "/gesundheit"
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(target, timeout=0.4) as response:
|
||||||
|
return response.status == 200
|
||||||
|
except (OSError, urllib.error.URLError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def unit_installed(run=None):
|
||||||
|
if os.name == "nt" or not shutil.which("systemctl"):
|
||||||
|
return False
|
||||||
|
run = run or subprocess.run
|
||||||
|
try:
|
||||||
|
probe = run(
|
||||||
|
["systemctl", "cat", UNIT],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=3,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return False
|
||||||
|
return getattr(probe, "returncode", 1) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def start_unit(run=None):
|
||||||
|
"""Eigene systemd-Unit, außerhalb von grotti.service. Rückgabe: gestartet versucht."""
|
||||||
|
if not unit_installed(run):
|
||||||
|
return False
|
||||||
|
systemctl = shutil.which("systemctl") or "systemctl"
|
||||||
|
run = run or subprocess.run
|
||||||
|
cmd = [systemctl, "start", UNIT]
|
||||||
|
if shutil.which("sudo"):
|
||||||
|
cmd = ["sudo", "-n", *cmd]
|
||||||
|
try:
|
||||||
|
run(cmd, capture_output=True, timeout=20, check=False)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def spawn_detached(data_dir, popen=None):
|
||||||
|
"""Prozess ohne Konsole und ohne Sitzung von Grotti. Grotti-Ende lässt ihn leben."""
|
||||||
|
folder = Path(data_dir)
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path = folder / "marlin-dienst.log"
|
||||||
|
log_file = open(log_path, "ab", buffering=0)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PYTHONPATH"] = str(ROOT) + os.pathsep + env.get("PYTHONPATH", "")
|
||||||
|
env["PYTHONUNBUFFERED"] = "1"
|
||||||
|
args = [sys.executable, "-m", "grotti.marlin_daemon"]
|
||||||
|
popen = popen or subprocess.Popen
|
||||||
|
kwargs = {
|
||||||
|
"args": args,
|
||||||
|
"cwd": str(ROOT),
|
||||||
|
"env": env,
|
||||||
|
"stdin": subprocess.DEVNULL,
|
||||||
|
"stdout": log_file,
|
||||||
|
"stderr": subprocess.STDOUT,
|
||||||
|
"close_fds": os.name != "nt",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
flags = _DETACHED | _NEW_GROUP | _NO_WINDOW
|
||||||
|
try:
|
||||||
|
proc = popen(creationflags=flags | _CREATE_BREAKAWAY, **kwargs)
|
||||||
|
except OSError:
|
||||||
|
proc = popen(creationflags=flags, **kwargs)
|
||||||
|
else:
|
||||||
|
proc = popen(start_new_session=True, **kwargs)
|
||||||
|
finally:
|
||||||
|
log_file.close()
|
||||||
|
return proc.pid
|
||||||
|
|
||||||
|
|
||||||
|
def launch(data_dir, run=None, popen=None):
|
||||||
|
if start_unit(run):
|
||||||
|
return "systemd"
|
||||||
|
spawn_detached(data_dir, popen=popen)
|
||||||
|
return "prozess"
|
||||||
|
|
||||||
|
|
||||||
|
class MarlinSupervisor:
|
||||||
|
def __init__(self, data_dir, healthy=None, start=None, wait_s=4):
|
||||||
|
self.data_dir = Path(data_dir)
|
||||||
|
self.healthy = healthy or health_check
|
||||||
|
self.start = start or (lambda folder: launch(folder))
|
||||||
|
self.wait_s = wait_s
|
||||||
|
self.error = ""
|
||||||
|
self._last_attempt = 0.0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
running = bool(self.healthy())
|
||||||
|
if running:
|
||||||
|
self.error = ""
|
||||||
|
return {"running": running, "message": "" if running else self.error}
|
||||||
|
|
||||||
|
def ensure(self):
|
||||||
|
if self.healthy():
|
||||||
|
self.error = ""
|
||||||
|
return {"running": True, "started": False, "message": ""}
|
||||||
|
with self._lock:
|
||||||
|
if self.healthy():
|
||||||
|
self.error = ""
|
||||||
|
return {"running": True, "started": False, "message": ""}
|
||||||
|
now = time.monotonic()
|
||||||
|
if self._last_attempt and now - self._last_attempt < 8:
|
||||||
|
return {"running": False, "started": False, "message": self.error}
|
||||||
|
self._last_attempt = now
|
||||||
|
try:
|
||||||
|
self.start(self.data_dir)
|
||||||
|
except OSError as exc:
|
||||||
|
self.error = "Der Marlin-Dienst ließ sich nicht starten: %s" % exc
|
||||||
|
return {"running": False, "started": False, "message": self.error}
|
||||||
|
if self._wait():
|
||||||
|
self.error = ""
|
||||||
|
return {"running": True, "started": True, "message": ""}
|
||||||
|
self.error = "Der Marlin-Dienst ist nicht hochgekommen. Log: %s" % (
|
||||||
|
self.data_dir / "marlin-dienst.log"
|
||||||
|
)
|
||||||
|
return {"running": False, "started": True, "message": self.error}
|
||||||
|
|
||||||
|
def _wait(self):
|
||||||
|
deadline = time.monotonic() + self.wait_s
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if self.healthy():
|
||||||
|
return True
|
||||||
|
time.sleep(0.05)
|
||||||
|
return bool(self.healthy())
|
||||||
|
|
||||||
|
|
||||||
|
def disable():
|
||||||
|
global _enabled, _supervisor
|
||||||
|
with _guard:
|
||||||
|
_enabled = False
|
||||||
|
_supervisor = None
|
||||||
|
|
||||||
|
|
||||||
|
def enable(data_dir):
|
||||||
|
global _enabled, _supervisor
|
||||||
|
with _guard:
|
||||||
|
_supervisor = MarlinSupervisor(data_dir)
|
||||||
|
_enabled = True
|
||||||
|
return _supervisor
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_if_enabled():
|
||||||
|
with _guard:
|
||||||
|
supervisor = _supervisor if _enabled else None
|
||||||
|
if supervisor:
|
||||||
|
return supervisor.ensure()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def current_status():
|
||||||
|
with _guard:
|
||||||
|
supervisor = _supervisor if _enabled else None
|
||||||
|
if supervisor:
|
||||||
|
return supervisor.status()
|
||||||
|
return {"running": health_check(), "message": ""}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Problem-Meldungen: lokal speichern und an den Empfänger auf git.gundel.dev schicken."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from grotti.version import __version__
|
||||||
|
|
||||||
|
DEFAULT_RELAY = "https://git.gundel.dev/hooks/grotti-meldung"
|
||||||
|
|
||||||
|
|
||||||
|
def relay_url(cfg):
|
||||||
|
raw = (cfg or {}).get("_raw") or {}
|
||||||
|
return (raw.get("meldung_relay") or DEFAULT_RELAY).strip().rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def create(conn, cfg, *, text, page="", printer_host="", error_text="", user=None):
|
||||||
|
body = (text or "").strip()
|
||||||
|
if not body:
|
||||||
|
raise ValueError("Bitte beschreiben, was nicht stimmt.")
|
||||||
|
if len(body) > 4000:
|
||||||
|
body = body[:4000]
|
||||||
|
user = user or {}
|
||||||
|
payload = {
|
||||||
|
"text": body,
|
||||||
|
"page": (page or "")[:200],
|
||||||
|
"version": __version__,
|
||||||
|
"printer_host": (printer_host or "")[:120],
|
||||||
|
"error_text": (error_text or "")[:2000],
|
||||||
|
}
|
||||||
|
relay_ok, relay_message = send_relay(cfg, payload)
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO meldungen (
|
||||||
|
text, page, version, printer_host, error_text,
|
||||||
|
user_id, username, relay_ok, relay_message
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
payload["text"],
|
||||||
|
payload["page"],
|
||||||
|
payload["version"],
|
||||||
|
payload["printer_host"],
|
||||||
|
payload["error_text"],
|
||||||
|
user.get("id"),
|
||||||
|
(user.get("username") or "")[:80],
|
||||||
|
relay_ok,
|
||||||
|
(relay_message or "")[:500],
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return out(row)
|
||||||
|
|
||||||
|
|
||||||
|
def send_relay(cfg, payload):
|
||||||
|
url = relay_url(cfg)
|
||||||
|
if not url:
|
||||||
|
return False, "Kein Empfänger konfiguriert."
|
||||||
|
data = json.dumps({
|
||||||
|
"text": payload.get("text") or "",
|
||||||
|
"page": payload.get("page") or "",
|
||||||
|
"version": payload.get("version") or "",
|
||||||
|
"printer_host": payload.get("printer_host") or "",
|
||||||
|
"error_text": payload.get("error_text") or "",
|
||||||
|
}).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers={"Content-Type": "application/json", "User-Agent": "Grotti/%s" % __version__},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=12) as response:
|
||||||
|
raw = response.read().decode("utf-8", "replace")
|
||||||
|
try:
|
||||||
|
info = json.loads(raw) if raw else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
info = {}
|
||||||
|
issue = info.get("url") or info.get("html_url") or ""
|
||||||
|
return True, issue or "Empfänger hat die Meldung angenommen."
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
detail = exc.read().decode("utf-8", "replace")[:200]
|
||||||
|
except OSError:
|
||||||
|
detail = str(exc)
|
||||||
|
return False, "Empfänger: HTTP %s %s" % (exc.code, detail)
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||||
|
return False, "Empfänger nicht erreichbar: %s" % exc
|
||||||
|
|
||||||
|
|
||||||
|
def list_rows(conn, status=None, limit=50):
|
||||||
|
limit = max(1, min(200, int(limit or 50)))
|
||||||
|
if status in ("offen", "erledigt"):
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM meldungen
|
||||||
|
WHERE status = %s
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(status, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM meldungen
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [out(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def close(conn, meldung_id):
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE meldungen
|
||||||
|
SET status = 'erledigt', closed_at = now()
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(int(meldung_id),),
|
||||||
|
).fetchone()
|
||||||
|
return out(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def out(row):
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"created_at": row["created_at"].isoformat() if row.get("created_at") else "",
|
||||||
|
"closed_at": row["closed_at"].isoformat() if row.get("closed_at") else "",
|
||||||
|
"status": row.get("status") or "offen",
|
||||||
|
"text": row.get("text") or "",
|
||||||
|
"page": row.get("page") or "",
|
||||||
|
"version": row.get("version") or "",
|
||||||
|
"printer_host": row.get("printer_host") or "",
|
||||||
|
"error_text": row.get("error_text") or "",
|
||||||
|
"username": row.get("username") or "",
|
||||||
|
"relay_ok": row.get("relay_ok"),
|
||||||
|
"relay_message": row.get("relay_message") or "",
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- ACE-Trailer-Flag einmalig speichern, damit die Dateiliste die Datei nicht erneut öffnet.
|
||||||
|
ALTER TABLE files
|
||||||
|
ADD COLUMN IF NOT EXISTS ace_trailer boolean;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS meldungen (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
status text NOT NULL DEFAULT 'offen',
|
||||||
|
text text NOT NULL,
|
||||||
|
page text NOT NULL DEFAULT '',
|
||||||
|
version text NOT NULL DEFAULT '',
|
||||||
|
printer_host text NOT NULL DEFAULT '',
|
||||||
|
error_text text NOT NULL DEFAULT '',
|
||||||
|
user_id uuid,
|
||||||
|
username text NOT NULL DEFAULT '',
|
||||||
|
relay_ok boolean,
|
||||||
|
relay_message text NOT NULL DEFAULT '',
|
||||||
|
closed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS meldungen_status_idx ON meldungen (status, created_at DESC);
|
||||||
@@ -11,6 +11,7 @@ import time
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from grotti import acm, eject, jobs, logutil, moonraker, notify, store
|
from grotti import acm, eject, jobs, logutil, moonraker, notify, store
|
||||||
|
from grotti.drivers import for_printer
|
||||||
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
|
||||||
@@ -127,7 +128,8 @@ class PrintQueueDispatcher:
|
|||||||
age = self.watcher.age_s()
|
age = self.watcher.age_s()
|
||||||
if cached and age is not None and age <= 30:
|
if cached and age is not None and age <= 30:
|
||||||
return cached
|
return cached
|
||||||
return {row["host"]: row for row in moonraker.snapshot(printers)}
|
from grotti.drivers import snapshot
|
||||||
|
return {row["host"]: row for row in snapshot(printers)}
|
||||||
|
|
||||||
def _auto_enabled(self):
|
def _auto_enabled(self):
|
||||||
return bool((self.cfg or {}).get("auto_queue", True))
|
return bool((self.cfg or {}).get("auto_queue", True))
|
||||||
@@ -459,7 +461,7 @@ class PrintQueueDispatcher:
|
|||||||
current = store.get_print_queue_row(wconn, item_id)
|
current = store.get_print_queue_row(wconn, item_id)
|
||||||
if current and (current.get("status") or "") in ("cancelled", "dismissed"):
|
if current and (current.get("status") or "") in ("cancelled", "dismissed"):
|
||||||
try:
|
try:
|
||||||
moonraker.command(host, "cancel")
|
for_printer(printer_copy).command(host, "cancel")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
@@ -1052,7 +1054,7 @@ class PrintQueueDispatcher:
|
|||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
if (row.get("status") or "") in ("cancelled", "dismissed"):
|
if (row.get("status") or "") in ("cancelled", "dismissed"):
|
||||||
try:
|
try:
|
||||||
moonraker.command(printer_copy["host"], "cancel")
|
for_printer(printer_copy).command(printer_copy["host"], "cancel")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|||||||
+33
-2
@@ -1,21 +1,52 @@
|
|||||||
"""Die Drucker: Familie aus dem Namen, die Liste selbst liegt in PostgreSQL."""
|
"""Die Drucker: Familie aus dem Namen, die Liste selbst liegt in PostgreSQL."""
|
||||||
|
|
||||||
FAMILY_LABELS = {"kobra_s1": "Kobra S1", "kobra_3": "Kobra 3"}
|
FAMILY_LABELS = {
|
||||||
|
"kobra_s1": "Kobra S1",
|
||||||
|
"kobra_3": "Kobra 3",
|
||||||
|
"kobra_4": "Kobra 4",
|
||||||
|
"anycubic_i3": "Anycubic i3",
|
||||||
|
"tenlog": "Tenlog",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vorhandene Farm bleibt Moonraker/Rinkhals. Stock und Marlin sind eigene Treiber.
|
||||||
|
FAMILY_DRIVER = {
|
||||||
|
"kobra_s1": "moonraker",
|
||||||
|
"kobra_3": "moonraker",
|
||||||
|
"kobra_4": "anycubic_lan",
|
||||||
|
"anycubic_i3": "marlin",
|
||||||
|
"tenlog": "marlin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def family_from_name(name):
|
def family_from_name(name):
|
||||||
low = name.lower()
|
low = (name or "").lower()
|
||||||
|
if "kobra 4" in low or "kobra4" in low:
|
||||||
|
return "kobra_4"
|
||||||
if "s1" in low:
|
if "s1" in low:
|
||||||
return "kobra_s1"
|
return "kobra_s1"
|
||||||
if "kobra 3" in low or "kobra3" in low:
|
if "kobra 3" in low or "kobra3" in low:
|
||||||
return "kobra_3"
|
return "kobra_3"
|
||||||
|
if "tenlog" in low:
|
||||||
|
return "tenlog"
|
||||||
|
if "i3" in low:
|
||||||
|
return "anycubic_i3"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def family_from_text(text):
|
def family_from_text(text):
|
||||||
low = (text or "").lower()
|
low = (text or "").lower()
|
||||||
|
if "kobra 4" in low or "kobra4" in low:
|
||||||
|
return "kobra_4"
|
||||||
if "kobra s1" in low:
|
if "kobra s1" in low:
|
||||||
return "kobra_s1"
|
return "kobra_s1"
|
||||||
if "kobra 3" in low or "kobra3" in low:
|
if "kobra 3" in low or "kobra3" in low:
|
||||||
return "kobra_3"
|
return "kobra_3"
|
||||||
|
if "tenlog" in low:
|
||||||
|
return "tenlog"
|
||||||
|
if "anycubic i3" in low or re_i3(low):
|
||||||
|
return "anycubic_i3"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def re_i3(low):
|
||||||
|
return " i3" in low or low.startswith("i3")
|
||||||
@@ -57,6 +57,7 @@ class SendQueue:
|
|||||||
spool_id,
|
spool_id,
|
||||||
ace_map=None,
|
ace_map=None,
|
||||||
bed_leveling=False,
|
bed_leveling=False,
|
||||||
|
marlin_mode="stream",
|
||||||
):
|
):
|
||||||
jobs_out = self.enqueue_many(
|
jobs_out = self.enqueue_many(
|
||||||
conn,
|
conn,
|
||||||
@@ -67,6 +68,7 @@ class SendQueue:
|
|||||||
spool_id,
|
spool_id,
|
||||||
{host: ace_map or []} if ace_map else {},
|
{host: ace_map or []} if ace_map else {},
|
||||||
bed_leveling=bed_leveling,
|
bed_leveling=bed_leveling,
|
||||||
|
marlin_mode=marlin_mode,
|
||||||
)
|
)
|
||||||
return jobs_out[0]
|
return jobs_out[0]
|
||||||
|
|
||||||
@@ -80,6 +82,7 @@ class SendQueue:
|
|||||||
spool_id,
|
spool_id,
|
||||||
ace_maps=None,
|
ace_maps=None,
|
||||||
bed_leveling=False,
|
bed_leveling=False,
|
||||||
|
marlin_mode="stream",
|
||||||
):
|
):
|
||||||
if not confirm:
|
if not confirm:
|
||||||
raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung")
|
raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung")
|
||||||
@@ -96,6 +99,7 @@ class SendQueue:
|
|||||||
ace_maps = ace_maps if isinstance(ace_maps, dict) else {}
|
ace_maps = ace_maps if isinstance(ace_maps, dict) else {}
|
||||||
# Eine Batch-Option für alle Drucker im Multi-Send (nicht pro Host).
|
# Eine Batch-Option für alle Drucker im Multi-Send (nicht pro Host).
|
||||||
want_level = bool(bed_leveling)
|
want_level = bool(bed_leveling)
|
||||||
|
mode = marlin_mode if marlin_mode in ("stream", "sd") else "stream"
|
||||||
created = []
|
created = []
|
||||||
for host in unique:
|
for host in unique:
|
||||||
# Mapping zuerst, damit Materialprüfung die ACE-Spulen sieht.
|
# Mapping zuerst, damit Materialprüfung die ACE-Spulen sieht.
|
||||||
@@ -120,6 +124,7 @@ class SendQueue:
|
|||||||
"override_machine": bool(override),
|
"override_machine": bool(override),
|
||||||
"ace_map": mapping,
|
"ace_map": mapping,
|
||||||
"bed_leveling": want_level,
|
"bed_leveling": want_level,
|
||||||
|
"marlin_mode": mode,
|
||||||
"result": None,
|
"result": None,
|
||||||
"error": None,
|
"error": None,
|
||||||
"created_at": time.time(),
|
"created_at": time.time(),
|
||||||
@@ -137,6 +142,7 @@ class SendQueue:
|
|||||||
"spool_id": chosen,
|
"spool_id": chosen,
|
||||||
"ace_map": mapping,
|
"ace_map": mapping,
|
||||||
"bed_leveling": want_level,
|
"bed_leveling": want_level,
|
||||||
|
"marlin_mode": mode,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
created.append(self.snapshot(job_id))
|
created.append(self.snapshot(job_id))
|
||||||
@@ -207,6 +213,7 @@ class SendQueue:
|
|||||||
prepared=True,
|
prepared=True,
|
||||||
ace_map=item.get("ace_map"),
|
ace_map=item.get("ace_map"),
|
||||||
bed_leveling=bool(item.get("bed_leveling")),
|
bed_leveling=bool(item.get("bed_leveling")),
|
||||||
|
marlin_mode=item.get("marlin_mode") or "stream",
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
done_msg = "Gestartet auf %s: %s" % (result["printer"], result["filename"])
|
done_msg = "Gestartet auf %s: %s" % (result["printer"], result["filename"])
|
||||||
|
|||||||
+200
-75
@@ -201,14 +201,23 @@ def file_folder(stored_path, source, watch_dir=None):
|
|||||||
return "" if parent in (".", "") else parent
|
return "" if parent in (".", "") else parent
|
||||||
|
|
||||||
|
|
||||||
def file_out(row, watch_dir=None):
|
def file_out(row, watch_dir=None, conn=None):
|
||||||
# stored_path bleibt intern (get_file_row / list_file_rows), nicht in der API.
|
# stored_path bleibt intern (get_file_row / list_file_rows), nicht in der API.
|
||||||
from grotti.parse import path_has_ace_trailer
|
|
||||||
|
|
||||||
stored = row.get("stored_path") or ""
|
stored = row.get("stored_path") or ""
|
||||||
ace_trailer = False
|
ace_trailer = row.get("ace_trailer")
|
||||||
if stored:
|
if ace_trailer is None and stored:
|
||||||
ace_trailer = path_has_ace_trailer(stored, kind=row.get("kind") or "", plate=row.get("plate") or "")
|
from grotti.parse import path_has_ace_trailer
|
||||||
|
|
||||||
|
ace_trailer = path_has_ace_trailer(
|
||||||
|
stored, kind=row.get("kind") or "", plate=row.get("plate") or ""
|
||||||
|
)
|
||||||
|
if conn is not None and row.get("id") is not None:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE files SET ace_trailer = %s WHERE id = %s",
|
||||||
|
(bool(ace_trailer), row["id"]),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ace_trailer = bool(ace_trailer)
|
||||||
return {
|
return {
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"project_id": row["project_id"],
|
"project_id": row["project_id"],
|
||||||
@@ -482,7 +491,7 @@ def get_project(conn, project_id, watch_dir=None):
|
|||||||
file_stats = _file_print_stats(conn, [item["id"] for item in files])
|
file_stats = _file_print_stats(conn, [item["id"] for item in files])
|
||||||
file_outs = []
|
file_outs = []
|
||||||
for item in files:
|
for item in files:
|
||||||
fo = file_out(item, watch_dir=watch_dir)
|
fo = file_out(item, watch_dir=watch_dir, conn=conn)
|
||||||
st = file_stats.get(item["id"]) or {"printed": 0, "queued": 0, "ok_once": False}
|
st = file_stats.get(item["id"]) or {"printed": 0, "queued": 0, "ok_once": False}
|
||||||
fo["printed"] = st["printed"]
|
fo["printed"] = st["printed"]
|
||||||
fo["queued"] = st["queued"]
|
fo["queued"] = st["queued"]
|
||||||
@@ -625,7 +634,7 @@ def list_files(
|
|||||||
sql += " LIMIT %s OFFSET %s"
|
sql += " LIMIT %s OFFSET %s"
|
||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
rows = conn.execute(sql, params).fetchall()
|
rows = conn.execute(sql, params).fetchall()
|
||||||
items = [file_out(row, watch_dir=watch_dir) for row in rows]
|
items = [file_out(row, watch_dir=watch_dir, conn=conn) for row in rows]
|
||||||
return {"items": items, "total": total, "limit": limit, "offset": int(offset or 0)}
|
return {"items": items, "total": total, "limit": limit, "offset": int(offset or 0)}
|
||||||
|
|
||||||
|
|
||||||
@@ -637,11 +646,11 @@ def upsert_scanned(conn, meta, force=False):
|
|||||||
statement = """
|
statement = """
|
||||||
INSERT INTO files (
|
INSERT INTO files (
|
||||||
name, stored_path, source, kind, printer_family, filaments, weight_g,
|
name, stored_path, source, kind, printer_family, filaments, weight_g,
|
||||||
estimated_s, thumb_entry, plate, size_bytes, mtime, slice_meta
|
estimated_s, thumb_entry, plate, size_bytes, mtime, slice_meta, ace_trailer
|
||||||
) VALUES (
|
) VALUES (
|
||||||
%(name)s, %(stored_path)s, 'watch', %(kind)s, %(printer_family)s, %(filaments)s,
|
%(name)s, %(stored_path)s, 'watch', %(kind)s, %(printer_family)s, %(filaments)s,
|
||||||
%(weight_g)s, %(estimated_s)s, %(thumb_entry)s, %(plate)s, %(size_bytes)s, %(mtime)s,
|
%(weight_g)s, %(estimated_s)s, %(thumb_entry)s, %(plate)s, %(size_bytes)s, %(mtime)s,
|
||||||
%(slice_meta)s
|
%(slice_meta)s, %(ace_trailer)s
|
||||||
)
|
)
|
||||||
ON CONFLICT (source, stored_path) DO UPDATE SET
|
ON CONFLICT (source, stored_path) DO UPDATE SET
|
||||||
name = EXCLUDED.name,
|
name = EXCLUDED.name,
|
||||||
@@ -654,7 +663,8 @@ def upsert_scanned(conn, meta, force=False):
|
|||||||
plate = EXCLUDED.plate,
|
plate = EXCLUDED.plate,
|
||||||
size_bytes = EXCLUDED.size_bytes,
|
size_bytes = EXCLUDED.size_bytes,
|
||||||
mtime = EXCLUDED.mtime,
|
mtime = EXCLUDED.mtime,
|
||||||
slice_meta = EXCLUDED.slice_meta
|
slice_meta = EXCLUDED.slice_meta,
|
||||||
|
ace_trailer = EXCLUDED.ace_trailer
|
||||||
"""
|
"""
|
||||||
if not force:
|
if not force:
|
||||||
statement += """
|
statement += """
|
||||||
@@ -664,6 +674,7 @@ def upsert_scanned(conn, meta, force=False):
|
|||||||
payload = dict(meta)
|
payload = dict(meta)
|
||||||
payload["filaments"] = Json(meta.get("filaments") or [])
|
payload["filaments"] = Json(meta.get("filaments") or [])
|
||||||
payload["slice_meta"] = Json(meta.get("slice_meta") or {})
|
payload["slice_meta"] = Json(meta.get("slice_meta") or {})
|
||||||
|
payload["ace_trailer"] = bool(meta.get("ace_trailer"))
|
||||||
conn.execute(statement, payload)
|
conn.execute(statement, payload)
|
||||||
|
|
||||||
|
|
||||||
@@ -672,11 +683,11 @@ def insert_upload(conn, meta):
|
|||||||
"""
|
"""
|
||||||
INSERT INTO files (
|
INSERT INTO files (
|
||||||
name, stored_path, source, kind, printer_family, filaments, weight_g,
|
name, stored_path, source, kind, printer_family, filaments, weight_g,
|
||||||
estimated_s, thumb_entry, plate, size_bytes, mtime, project_id, slice_meta
|
estimated_s, thumb_entry, plate, size_bytes, mtime, project_id, slice_meta, ace_trailer
|
||||||
) VALUES (
|
) VALUES (
|
||||||
%(name)s, %(stored_path)s, 'upload', %(kind)s, %(printer_family)s, %(filaments)s,
|
%(name)s, %(stored_path)s, 'upload', %(kind)s, %(printer_family)s, %(filaments)s,
|
||||||
%(weight_g)s, %(estimated_s)s, %(thumb_entry)s, %(plate)s, %(size_bytes)s, %(mtime)s,
|
%(weight_g)s, %(estimated_s)s, %(thumb_entry)s, %(plate)s, %(size_bytes)s, %(mtime)s,
|
||||||
%(project_id)s, %(slice_meta)s
|
%(project_id)s, %(slice_meta)s, %(ace_trailer)s
|
||||||
)
|
)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
@@ -684,9 +695,10 @@ def insert_upload(conn, meta):
|
|||||||
**meta,
|
**meta,
|
||||||
"filaments": Json(meta.get("filaments") or []),
|
"filaments": Json(meta.get("filaments") or []),
|
||||||
"slice_meta": Json(meta.get("slice_meta") or {}),
|
"slice_meta": Json(meta.get("slice_meta") or {}),
|
||||||
|
"ace_trailer": bool(meta.get("ace_trailer")),
|
||||||
},
|
},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return file_out(row)
|
return file_out(row, conn=conn)
|
||||||
|
|
||||||
|
|
||||||
def update_file(conn, file_id, fields, watch_dir=None):
|
def update_file(conn, file_id, fields, watch_dir=None):
|
||||||
@@ -702,15 +714,18 @@ def update_file(conn, file_id, fields, watch_dir=None):
|
|||||||
if "slice_meta" in fields:
|
if "slice_meta" in fields:
|
||||||
sets.append("slice_meta = %s")
|
sets.append("slice_meta = %s")
|
||||||
values.append(Json(fields.get("slice_meta") or {}))
|
values.append(Json(fields.get("slice_meta") or {}))
|
||||||
|
if "ace_trailer" in fields:
|
||||||
|
sets.append("ace_trailer = %s")
|
||||||
|
values.append(bool(fields.get("ace_trailer")))
|
||||||
if not sets:
|
if not sets:
|
||||||
row = get_file_row(conn, file_id)
|
row = get_file_row(conn, file_id)
|
||||||
return file_out(row, watch_dir=watch_dir) if row else None
|
return file_out(row, watch_dir=watch_dir, conn=conn) if row else None
|
||||||
values.append(file_id)
|
values.append(file_id)
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
|
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
|
||||||
values,
|
values,
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return file_out(row, watch_dir=watch_dir) if row else None
|
return file_out(row, watch_dir=watch_dir, conn=conn) if row else None
|
||||||
|
|
||||||
|
|
||||||
def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None, mtime=None, source=None):
|
def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None, mtime=None, source=None):
|
||||||
@@ -1023,6 +1038,70 @@ def spool_at_slot(conn, host, slot):
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def prev_spool_id_for_slot(conn, host, slot, live_run, exclude_spool_id, old_rows=None):
|
||||||
|
"""Vorige Spule für Segment 0→jetzt: Platz → ACE-Map/Send → Abschnitt → Lauf."""
|
||||||
|
try:
|
||||||
|
exclude = int(exclude_spool_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
for row in old_rows or []:
|
||||||
|
try:
|
||||||
|
sid = int(row["id"])
|
||||||
|
except (TypeError, ValueError, KeyError):
|
||||||
|
continue
|
||||||
|
if sid != exclude:
|
||||||
|
return sid
|
||||||
|
|
||||||
|
filename = (live_run or {}).get("filename") or ""
|
||||||
|
sent = latest_send(conn, host, filename) if host and filename else None
|
||||||
|
if sent:
|
||||||
|
from grotti import acm
|
||||||
|
|
||||||
|
mapping = acm.normalize_mapping(sent.get("ace_map") or [])
|
||||||
|
slot_ok = False
|
||||||
|
if slot is None:
|
||||||
|
slot_ok = not mapping
|
||||||
|
elif mapping:
|
||||||
|
try:
|
||||||
|
ams = int(slot) - 1
|
||||||
|
slot_ok = any(int(item["ams_index"]) == ams for item in mapping)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
slot_ok = False
|
||||||
|
if slot_ok:
|
||||||
|
hint = sent.get("spool_id")
|
||||||
|
try:
|
||||||
|
hint = int(hint) if hint is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
hint = None
|
||||||
|
if hint and hint != exclude:
|
||||||
|
return hint
|
||||||
|
|
||||||
|
run_id = (live_run or {}).get("id")
|
||||||
|
if run_id:
|
||||||
|
for seg in reversed(list_run_segments(conn, run_id)):
|
||||||
|
try:
|
||||||
|
seg_slot = seg.get("ace_slot")
|
||||||
|
if slot is None:
|
||||||
|
if seg_slot is not None:
|
||||||
|
continue
|
||||||
|
elif seg_slot is None or int(seg_slot) != int(slot):
|
||||||
|
continue
|
||||||
|
sid = int(seg["spool_id"])
|
||||||
|
except (TypeError, ValueError, KeyError):
|
||||||
|
continue
|
||||||
|
if sid != exclude:
|
||||||
|
return sid
|
||||||
|
|
||||||
|
hint = (live_run or {}).get("spool_id")
|
||||||
|
try:
|
||||||
|
hint = int(hint) if hint is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
hint = None
|
||||||
|
if hint and hint != exclude:
|
||||||
|
return hint
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def file_by_name(conn, filename):
|
def file_by_name(conn, filename):
|
||||||
base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1]
|
base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
if not base:
|
if not base:
|
||||||
@@ -1033,6 +1112,64 @@ def file_by_name(conn, filename):
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def find_watch_twin(conn, name, size_bytes):
|
||||||
|
"""Watch-Datei mit gleichem Namen und gleicher Größe (Orca-Doppel ohne Upload)."""
|
||||||
|
base = (name or "").replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
|
if not base:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
size = int(size_bytes or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if size <= 0:
|
||||||
|
return None
|
||||||
|
return conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM files
|
||||||
|
WHERE source = 'watch' AND name = %s AND size_bytes = %s
|
||||||
|
ORDER BY mtime DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(base, size),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def retarget_file_id(conn, old_id, new_id):
|
||||||
|
"""Verweise von einer Datei auf eine andere umhängen (vor dem Löschen)."""
|
||||||
|
if not old_id or not new_id or int(old_id) == int(new_id):
|
||||||
|
return
|
||||||
|
old_id = int(old_id)
|
||||||
|
new_id = int(new_id)
|
||||||
|
conn.execute("UPDATE print_queue SET file_id = %s WHERE file_id = %s", (new_id, old_id))
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT id, steps FROM print_queue WHERE steps IS NOT NULL AND steps::text LIKE %s",
|
||||||
|
('%%"file_id": %d%%' % old_id,),
|
||||||
|
).fetchall():
|
||||||
|
steps = row.get("steps") or []
|
||||||
|
if isinstance(steps, str):
|
||||||
|
try:
|
||||||
|
steps = json.loads(steps)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if not isinstance(steps, list):
|
||||||
|
continue
|
||||||
|
changed = False
|
||||||
|
for step in steps:
|
||||||
|
if isinstance(step, dict) and step.get("file_id") == old_id:
|
||||||
|
step["file_id"] = new_id
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE print_queue SET steps = %s WHERE id = %s",
|
||||||
|
(Json(steps), row["id"]),
|
||||||
|
)
|
||||||
|
for table in ("sends", "runs", "order_items"):
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE %s SET file_id = %%s WHERE file_id = %%s" % table,
|
||||||
|
(new_id, old_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def add_send(conn, printer_host, filename, file_id, project_id, spool_id, ace_map=None, bed_leveling=False):
|
def add_send(conn, printer_host, filename, file_id, project_id, spool_id, ace_map=None, bed_leveling=False):
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1981,8 +2118,30 @@ def find_open_history_run(conn, host, filename=""):
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def latest_run_id(conn, host, filename):
|
||||||
|
"""Neueste Lauf-ID dieser Datei auf dem Drucker."""
|
||||||
|
host = (host or "").strip()
|
||||||
|
filename = (filename or "").strip()
|
||||||
|
if not host or not filename:
|
||||||
|
return None
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM runs
|
||||||
|
WHERE printer_host = %s AND filename = %s
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(host, filename),
|
||||||
|
).fetchone()
|
||||||
|
return row["id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
def find_live_run(conn, host, filename=""):
|
def find_live_run(conn, host, filename=""):
|
||||||
"""Offener Live-Lauf für diesen Drucker (Watcher)."""
|
"""Offener Live-Lauf für diesen Drucker (Watcher).
|
||||||
|
|
||||||
|
Mit Dateiname nur derselbe Lauf oder eine noch namenlose Hülle.
|
||||||
|
Ein anderer Dateiname ist ein anderer Druck und wird nicht geliefert.
|
||||||
|
"""
|
||||||
if filename:
|
if filename:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1998,6 +2157,18 @@ def find_live_run(conn, host, filename=""):
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
if row:
|
if row:
|
||||||
return row
|
return row
|
||||||
|
return conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM runs
|
||||||
|
WHERE printer_host = %s
|
||||||
|
AND moonraker_job_id LIKE 'live:%%'
|
||||||
|
AND state IN ('in_progress', 'printing', 'paused')
|
||||||
|
AND COALESCE(filename, '') = ''
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(host,),
|
||||||
|
).fetchone()
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM runs
|
SELECT * FROM runs
|
||||||
@@ -2046,13 +2217,18 @@ def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
|
|||||||
return hist
|
return hist
|
||||||
row = find_live_run(conn, host, filename)
|
row = find_live_run(conn, host, filename)
|
||||||
if row:
|
if row:
|
||||||
if filename and row.get("filename") != filename:
|
existing_name = (row.get("filename") or "").strip()
|
||||||
|
# Anderer Dateiname ist ein anderer Druck — nicht umbenennen.
|
||||||
|
if filename and existing_name and existing_name != filename:
|
||||||
|
row = None
|
||||||
|
elif filename and row.get("filename") != filename:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE runs SET filename = %s WHERE id = %s",
|
"UPDATE runs SET filename = %s WHERE id = %s",
|
||||||
(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:
|
if row:
|
||||||
|
if spool_id and row.get("spool_id") != spool_id:
|
||||||
conn.execute("UPDATE runs SET spool_id = %s WHERE id = %s", (spool_id, row["id"]))
|
conn.execute("UPDATE runs SET spool_id = %s WHERE id = %s", (spool_id, row["id"]))
|
||||||
row = get_run_row(conn, row["id"])
|
row = get_run_row(conn, row["id"])
|
||||||
return row
|
return row
|
||||||
@@ -2174,6 +2350,10 @@ def adopt_live_run(conn, host, filename, real_run_id):
|
|||||||
real = get_run_row(conn, real_run_id)
|
real = get_run_row(conn, real_run_id)
|
||||||
if not real:
|
if not real:
|
||||||
return
|
return
|
||||||
|
live_name = (live.get("filename") or "").strip()
|
||||||
|
want = (filename or real.get("filename") or "").strip()
|
||||||
|
if live_name and want and live_name != want:
|
||||||
|
return
|
||||||
live_start = live.get("started_at")
|
live_start = live.get("started_at")
|
||||||
real_end = real.get("ended_at")
|
real_end = real.get("ended_at")
|
||||||
# Gleiche Datei, neuer Druck: Live nicht an schon beendeten History-Job hängen.
|
# Gleiche Datei, neuer Druck: Live nicht an schon beendeten History-Job hängen.
|
||||||
@@ -2387,7 +2567,7 @@ def list_run_segments(conn, run_id):
|
|||||||
FROM run_segments seg
|
FROM run_segments seg
|
||||||
JOIN spools s ON s.id = seg.spool_id
|
JOIN spools s ON s.id = seg.spool_id
|
||||||
WHERE seg.run_id = %s
|
WHERE seg.run_id = %s
|
||||||
ORDER BY seg.id
|
ORDER BY seg.mm_start, seg.id
|
||||||
""",
|
""",
|
||||||
(run_id,),
|
(run_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -2838,61 +3018,6 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", col
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def idea_out(row):
|
|
||||||
return {
|
|
||||||
"id": row["id"],
|
|
||||||
"title": row["title"],
|
|
||||||
"body": row["body"],
|
|
||||||
"status": row["status"],
|
|
||||||
"created_at": iso(row["created_at"]),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def list_ideas(conn):
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM ideas
|
|
||||||
ORDER BY CASE status WHEN 'offen' THEN 0 WHEN 'spaeter' THEN 1 ELSE 2 END, created_at DESC
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
return [idea_out(row) for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def create_idea(conn, title, body, status):
|
|
||||||
row = conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO ideas (title, body, status)
|
|
||||||
VALUES (%s, %s, %s)
|
|
||||||
RETURNING *
|
|
||||||
""",
|
|
||||||
(title, body, status),
|
|
||||||
).fetchone()
|
|
||||||
return idea_out(row)
|
|
||||||
|
|
||||||
|
|
||||||
def update_idea(conn, idea_id, fields):
|
|
||||||
sets = []
|
|
||||||
values = []
|
|
||||||
for key in ("title", "body", "status"):
|
|
||||||
if key in fields:
|
|
||||||
sets.append("%s = %%s" % key)
|
|
||||||
values.append(fields[key])
|
|
||||||
if not sets:
|
|
||||||
row = conn.execute("SELECT * FROM ideas WHERE id = %s", (idea_id,)).fetchone()
|
|
||||||
return idea_out(row) if row else None
|
|
||||||
values.append(idea_id)
|
|
||||||
row = conn.execute(
|
|
||||||
"UPDATE ideas SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
|
|
||||||
values,
|
|
||||||
).fetchone()
|
|
||||||
return idea_out(row) if row else None
|
|
||||||
|
|
||||||
|
|
||||||
def delete_idea(conn, idea_id):
|
|
||||||
row = conn.execute("DELETE FROM ideas WHERE id = %s RETURNING id", (idea_id,)).fetchone()
|
|
||||||
return bool(row)
|
|
||||||
|
|
||||||
|
|
||||||
def printer_out(row):
|
def printer_out(row):
|
||||||
family = row["family"] or ""
|
family = row["family"] or ""
|
||||||
sw = row.get("start_window") or {}
|
sw = row.get("start_window") or {}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""G-code zu einem dünnen 3D-Pfad für den Viewer."""
|
"""G-code zu einem dünnen 3D-Pfad für den Viewer."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
|
|||||||
|
|
||||||
# Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben)
|
# Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben)
|
||||||
TOOLPATH_VERSION = 8
|
TOOLPATH_VERSION = 8
|
||||||
|
# Die volle Bahn liegt kurz im Speicher. Parallel auf einem Pi mit 2 GB füllt das den Swap.
|
||||||
|
_build_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _gcode_text(path: Path) -> str:
|
def _gcode_text(path: Path) -> str:
|
||||||
@@ -111,6 +114,11 @@ def _thin_points(points, layer_breaks, max_points):
|
|||||||
|
|
||||||
|
|
||||||
def build_toolpath(path: Path, max_points=220000):
|
def build_toolpath(path: Path, max_points=220000):
|
||||||
|
with _build_lock:
|
||||||
|
return _build_toolpath(path, max_points)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_toolpath(path: Path, max_points=220000):
|
||||||
text = _gcode_text(path)
|
text = _gcode_text(path)
|
||||||
if not text:
|
if not text:
|
||||||
return {
|
return {
|
||||||
|
|||||||
+1
-1
@@ -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.6.0"
|
__version__ = "3.7.0"
|
||||||
+48
-9
@@ -6,7 +6,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from grotti import jobs, logutil, moonraker, notify, store, usage
|
from grotti import drivers, jobs, logutil, 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
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ class StateWatcher:
|
|||||||
if not printers:
|
if not printers:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return
|
return
|
||||||
lives = moonraker.snapshot(printers)
|
lives = drivers.snapshot(printers)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self.latest = lives
|
self.latest = lives
|
||||||
self._updated_at = time.monotonic()
|
self._updated_at = time.monotonic()
|
||||||
@@ -133,6 +133,14 @@ class StateWatcher:
|
|||||||
file_changed = False
|
file_changed = False
|
||||||
if prev_offline and state in ("printing", "paused"):
|
if prev_offline and state in ("printing", "paused"):
|
||||||
file_changed = self._release_replaced_run(conn, host, name, filename)
|
file_changed = self._release_replaced_run(conn, host, name, filename)
|
||||||
|
elif state == "printing" and filename and prev_state in (
|
||||||
|
"printing", "paused", "cancelled", "standby",
|
||||||
|
):
|
||||||
|
# Abbruch verpasst oder sofort neu gestartet: alte Datei nicht umbenennen.
|
||||||
|
existing = store.find_live_run(conn, host)
|
||||||
|
old_name = (existing or {}).get("filename") or ""
|
||||||
|
if old_name and old_name != filename:
|
||||||
|
file_changed = self._release_replaced_run(conn, host, name, filename)
|
||||||
starting = (
|
starting = (
|
||||||
(
|
(
|
||||||
bool(prev_state)
|
bool(prev_state)
|
||||||
@@ -256,16 +264,21 @@ class StateWatcher:
|
|||||||
store.set_bed_needs_clear(conn, host, True, file_label)
|
store.set_bed_needs_clear(conn, host, True, file_label)
|
||||||
store.abandon_live_runs(conn, host)
|
store.abandon_live_runs(conn, host)
|
||||||
store.set_printer_active_run(conn, host, None)
|
store.set_printer_active_run(conn, host, None)
|
||||||
|
emit_id = run_id or prev.get("run_id") or (dangling or {}).get("id")
|
||||||
|
label = file_label if file_label != state else ""
|
||||||
|
if emit_id and not store.get_run_row(conn, emit_id):
|
||||||
|
# History-Abgleich hat den Live-Lauf übernommen und gelöscht.
|
||||||
|
emit_id = store.latest_run_id(conn, host, label)
|
||||||
self._emit(
|
self._emit(
|
||||||
conn,
|
conn,
|
||||||
host,
|
host,
|
||||||
name,
|
name,
|
||||||
"abbruch",
|
"abbruch",
|
||||||
"%s: Druck abgebrochen (%s)" % (
|
"%s: Druck abgebrochen (%s)" % (
|
||||||
name, file_label if file_label != state else "ohne Datei",
|
name, label or "ohne Datei",
|
||||||
),
|
),
|
||||||
file_label if file_label != state else "",
|
label,
|
||||||
run_id or prev.get("run_id") or (dangling or {}).get("id"),
|
emit_id,
|
||||||
)
|
)
|
||||||
# prev-Zustand behalten, bis die Hits reichen.
|
# prev-Zustand behalten, bis die Hits reichen.
|
||||||
if hits < 3 and from_active:
|
if hits < 3 and from_active:
|
||||||
@@ -351,12 +364,21 @@ class StateWatcher:
|
|||||||
return False
|
return False
|
||||||
if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"):
|
if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"):
|
||||||
return False
|
return False
|
||||||
|
label = existing.get("filename") or "ohne Datei"
|
||||||
|
old_id = existing["id"]
|
||||||
try:
|
try:
|
||||||
jobs.sync_history(conn, self.cfg)
|
jobs.sync_history(conn, self.cfg, host=host)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("Watcher Abgleich nach Offline %s: %s", host, exc)
|
log.warning("Watcher Abgleich nach Offline %s: %s", host, exc)
|
||||||
existing = store.find_live_run(conn, host)
|
existing = store.find_live_run(conn, host)
|
||||||
if not existing:
|
if not existing:
|
||||||
|
# History hat den Live-Lauf übernommen. Ereignis an den überlebenden Lauf.
|
||||||
|
emit_id = old_id if store.get_run_row(conn, old_id) else store.latest_run_id(conn, host, label)
|
||||||
|
self._emit(
|
||||||
|
conn, host, name, "abbruch",
|
||||||
|
"%s: Druck unterbrochen (%s)" % (name, label),
|
||||||
|
label, emit_id,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"):
|
if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"):
|
||||||
return False
|
return False
|
||||||
@@ -488,7 +510,11 @@ class StateWatcher:
|
|||||||
})
|
})
|
||||||
|
|
||||||
def _ensure_start_segment(self, conn, host, filename, live, run_id):
|
def _ensure_start_segment(self, conn, host, filename, live, run_id):
|
||||||
"""Nur externe Einzelspule: ein Abschnitt. ACE-Farben ohne Wechsel → Anteil-Split."""
|
"""Startabschnitte: externe Spule oder ACE-Plätze mit eingelegter Spule.
|
||||||
|
|
||||||
|
Ohne Rollenwechsel bleibt ACE-Abzug Anteil-Split (ein Segment je Platz).
|
||||||
|
Mit Wechsel greift die mm-Aufteilung innerhalb der Farbe (0→jetzt).
|
||||||
|
"""
|
||||||
if not run_id:
|
if not run_id:
|
||||||
return
|
return
|
||||||
existing = store.list_run_segments(conn, run_id)
|
existing = store.list_run_segments(conn, run_id)
|
||||||
@@ -499,9 +525,18 @@ class StateWatcher:
|
|||||||
from grotti import acm
|
from grotti import acm
|
||||||
|
|
||||||
mapping = acm.normalize_mapping(ace) if ace else []
|
mapping = acm.normalize_mapping(ace) if ace else []
|
||||||
if mapping:
|
|
||||||
return
|
|
||||||
used_mm = usage.as_float(live.get("filament_used"))
|
used_mm = usage.as_float(live.get("filament_used"))
|
||||||
|
if mapping:
|
||||||
|
seen = set()
|
||||||
|
for item in mapping:
|
||||||
|
slot = int(item["ams_index"]) + 1
|
||||||
|
if slot in seen:
|
||||||
|
continue
|
||||||
|
seen.add(slot)
|
||||||
|
row = store.spool_at_slot(conn, host, slot)
|
||||||
|
if row:
|
||||||
|
store.open_segment(conn, run_id, row["id"], slot, used_mm)
|
||||||
|
return
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id FROM spools
|
SELECT id FROM spools
|
||||||
@@ -552,6 +587,10 @@ class StateWatcher:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
|
def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
|
||||||
|
# Eine gelöschte Lauf-ID (Live wurde gerade übernommen) darf den Tick
|
||||||
|
# nicht am Fremdschlüssel zurückrollen — sonst ist der Abbruch weg.
|
||||||
|
if run_id is not None and not store.get_run_row(conn, run_id):
|
||||||
|
run_id = None
|
||||||
notify.record(
|
notify.record(
|
||||||
conn, self.cfg, host, printer, kind, message, filename,
|
conn, self.cfg, host, printer, kind, message, filename,
|
||||||
dedupe_s=60, run_id=run_id,
|
dedupe_s=60, run_id=run_id,
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ echo '[]' > "$STAGE/usr/lib/grotti/drucker.json"
|
|||||||
cp "$ROOT/packaging/config.pi.json" "$STAGE/usr/share/grotti/config.pi.json"
|
cp "$ROOT/packaging/config.pi.json" "$STAGE/usr/share/grotti/config.pi.json"
|
||||||
cp "$ROOT/packaging/config.pi.json" "$STAGE/etc/grotti/config.json"
|
cp "$ROOT/packaging/config.pi.json" "$STAGE/etc/grotti/config.json"
|
||||||
cp "$ROOT/packaging/grotti.service" "$STAGE/lib/systemd/system/grotti.service"
|
cp "$ROOT/packaging/grotti.service" "$STAGE/lib/systemd/system/grotti.service"
|
||||||
|
cp "$ROOT/packaging/grotti-marlin.service" "$STAGE/lib/systemd/system/grotti-marlin.service"
|
||||||
|
mkdir -p "$STAGE/etc/sudoers.d"
|
||||||
|
cp "$ROOT/packaging/sudoers.d/grotti-marlin" "$STAGE/etc/sudoers.d/grotti-marlin"
|
||||||
|
chmod 440 "$STAGE/etc/sudoers.d/grotti-marlin"
|
||||||
cp "$ROOT/docs/Anleitung-Installation-Pi.md" "$STAGE/usr/share/doc/grotti/"
|
cp "$ROOT/docs/Anleitung-Installation-Pi.md" "$STAGE/usr/share/doc/grotti/"
|
||||||
cp "$ROOT/CHANGELOG.md" "$STAGE/usr/share/doc/grotti/changelog"
|
cp "$ROOT/CHANGELOG.md" "$STAGE/usr/share/doc/grotti/changelog"
|
||||||
gzip -9 -f "$STAGE/usr/share/doc/grotti/changelog"
|
gzip -9 -f "$STAGE/usr/share/doc/grotti/changelog"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"secret_key": "",
|
"secret_key": "",
|
||||||
"fqdn": "",
|
"fqdn": "",
|
||||||
"log_level": "INFO",
|
"log_level": "INFO",
|
||||||
|
"http_threads": 8,
|
||||||
"auto_queue": true,
|
"auto_queue": true,
|
||||||
"auto_retry": true,
|
"auto_retry": true,
|
||||||
"oauth": {
|
"oauth": {
|
||||||
|
|||||||
@@ -34,9 +34,15 @@ configure)
|
|||||||
_pg psql -c "GRANT ALL PRIVILEGES ON DATABASE grotti TO grotti;" >/dev/null 2>&1 || true
|
_pg psql -c "GRANT ALL PRIVILEGES ON DATABASE grotti TO grotti;" >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if getent group dialout >/dev/null 2>&1; then
|
||||||
|
usermod -aG dialout grotti || true
|
||||||
|
fi
|
||||||
|
|
||||||
if command -v systemctl >/dev/null 2>&1; then
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
systemctl daemon-reload || true
|
systemctl daemon-reload || true
|
||||||
systemctl enable grotti.service || true
|
systemctl enable grotti.service grotti-marlin.service || true
|
||||||
|
# start, nicht restart: ein laufender USB-Druck bleibt.
|
||||||
|
systemctl start grotti-marlin.service || true
|
||||||
systemctl restart grotti.service || true
|
systemctl restart grotti.service || true
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Grotti Marlin über USB
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=grotti
|
||||||
|
Group=grotti
|
||||||
|
SupplementaryGroups=dialout
|
||||||
|
WorkingDirectory=/usr/lib/grotti
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=PYTHONPATH=/usr/lib/grotti
|
||||||
|
ExecStart=/usr/lib/grotti/venv/bin/python -m grotti.marlin_daemon
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
KillMode=process
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -15,6 +15,10 @@ Environment=PYTHONUNBUFFERED=1
|
|||||||
ExecStart=/usr/lib/grotti/venv/bin/python /usr/lib/grotti/server.py
|
ExecStart=/usr/lib/grotti/venv/bin/python /usr/lib/grotti/server.py
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
# USB-Marlin liegt in grotti-marlin.service und bleibt bei einem Neustart hier stehen.
|
||||||
|
# Stopp nicht 90 s hängen: nach TimeoutStopSec folgt SIGKILL
|
||||||
|
TimeoutStopSec=20
|
||||||
|
KillMode=mixed
|
||||||
# Logs → journald (journalctl -u grotti -f)
|
# Logs → journald (journalctl -u grotti -f)
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
StandardError=journal
|
StandardError=journal
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
grotti ALL=(root) NOPASSWD: /usr/bin/systemctl start grotti-marlin.service, /bin/systemctl start grotti-marlin.service
|
||||||
@@ -6,3 +6,4 @@ cryptography==50.0.2
|
|||||||
markdown==3.11.1
|
markdown==3.11.1
|
||||||
mcp==2.0.0
|
mcp==2.0.0
|
||||||
waitress==3.0.2
|
waitress==3.0.2
|
||||||
|
pyserial==3.5
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Empfänger für Grotti-Meldungen → Issue auf Forgejo/Gitea.
|
||||||
|
|
||||||
|
Läuft getrennt von den Installationen. Der Token bleibt hier.
|
||||||
|
|
||||||
|
export FORGEJO_URL=https://git.gundel.dev
|
||||||
|
export FORGEJO_TOKEN=...
|
||||||
|
export FORGEJO_REPO=dg/grotti
|
||||||
|
python scripts/meldung_relay.py
|
||||||
|
|
||||||
|
Port über RELAY_PORT (Standard 8780). Vor Forgejo mit Reverse-Proxy unter
|
||||||
|
/hooks/grotti-meldung legen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
|
||||||
|
def env(name, default=""):
|
||||||
|
return (os.environ.get(name) or default).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def create_issue(title, body):
|
||||||
|
base = env("FORGEJO_URL", "https://git.gundel.dev").rstrip("/")
|
||||||
|
repo = env("FORGEJO_REPO", "dg/grotti")
|
||||||
|
token = env("FORGEJO_TOKEN")
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("FORGEJO_TOKEN fehlt.")
|
||||||
|
url = "%s/api/v1/repos/%s/issues" % (base, repo)
|
||||||
|
payload = json.dumps({"title": title[:120], "body": body}).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "token %s" % token,
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=20) as response:
|
||||||
|
data = json.loads(response.read().decode("utf-8"))
|
||||||
|
return data.get("html_url") or data.get("url") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def issue_body(data):
|
||||||
|
lines = [
|
||||||
|
data.get("text") or "",
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"Version: %s" % (data.get("version") or "—"),
|
||||||
|
"Seite: %s" % (data.get("page") or "—"),
|
||||||
|
"Drucker: %s" % (data.get("printer_host") or "—"),
|
||||||
|
]
|
||||||
|
err = (data.get("error_text") or "").strip()
|
||||||
|
if err:
|
||||||
|
lines.extend(["", "Fehlertext:", "```", err[:2000], "```"])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
return
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.split("?", 1)[0] in ("/", "/gesundheit", "/hooks/grotti-meldung"):
|
||||||
|
self._json(200, {"ok": True})
|
||||||
|
return
|
||||||
|
self._json(404, {"message": "Unbekannt."})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
path = self.path.split("?", 1)[0]
|
||||||
|
if path not in ("/", "/hooks/grotti-meldung"):
|
||||||
|
self._json(404, {"message": "Unbekannt."})
|
||||||
|
return
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
raw = self.rfile.read(length) if length else b"{}"
|
||||||
|
try:
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeError, json.JSONDecodeError):
|
||||||
|
self._json(400, {"message": "Kein JSON."})
|
||||||
|
return
|
||||||
|
if not isinstance(data, dict) or not (data.get("text") or "").strip():
|
||||||
|
self._json(400, {"message": "text fehlt."})
|
||||||
|
return
|
||||||
|
title = "Grotti: %s" % ((data.get("text") or "").strip().splitlines()[0][:80])
|
||||||
|
try:
|
||||||
|
url = create_issue(title, issue_body(data))
|
||||||
|
except Exception as exc:
|
||||||
|
self._json(502, {"message": str(exc)})
|
||||||
|
return
|
||||||
|
self._json(201, {"ok": True, "url": url})
|
||||||
|
|
||||||
|
def _json(self, code, payload):
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
port = int(env("RELAY_PORT", "8780") or 8780)
|
||||||
|
httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+25
-6
@@ -94,7 +94,14 @@ def load_config(path: Path = CONFIG_PATH) -> dict[str, str]:
|
|||||||
browser = str(data.get("browser") or "app").strip().lower() or "app"
|
browser = str(data.get("browser") or "app").strip().lower() or "app"
|
||||||
if browser not in ("app", "tab"):
|
if browser not in ("app", "tab"):
|
||||||
browser = "app"
|
browser = "app"
|
||||||
return {"url": url, "token": token, "browser": browser}
|
# Windows-Pfad des Slice-Ordners, wenn Grotti auf dem Pi läuft (dort anderer Pfad)
|
||||||
|
watch_local = str(data.get("watch_dir_local") or data.get("watch_dir") or "").strip()
|
||||||
|
return {
|
||||||
|
"url": url,
|
||||||
|
"token": token,
|
||||||
|
"browser": browser,
|
||||||
|
"watch_dir_local": watch_local,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_gcode_path(path: Path) -> bool:
|
def is_gcode_path(path: Path) -> bool:
|
||||||
@@ -287,6 +294,16 @@ def fetch_watch_dir(base_url: str, token: str) -> Path | None:
|
|||||||
return path if path.is_dir() else None
|
return path if path.is_dir() else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_watch_dir(config: dict) -> Path | None:
|
||||||
|
"""Lokaler Slice-Ordner: Config zuerst, sonst der Pfad von Grotti (gleiche Maschine)."""
|
||||||
|
local = str(config.get("watch_dir_local") or "").strip()
|
||||||
|
if local:
|
||||||
|
path = Path(local)
|
||||||
|
if path.is_dir():
|
||||||
|
return path
|
||||||
|
return fetch_watch_dir(config["url"], config["token"])
|
||||||
|
|
||||||
|
|
||||||
def under_watch(watch_dir: Path, target: Path) -> tuple[str, str] | None:
|
def under_watch(watch_dir: Path, target: Path) -> tuple[str, str] | None:
|
||||||
"""(folder_relativ, dateiname) wenn target unter watch_dir liegt."""
|
"""(folder_relativ, dateiname) wenn target unter watch_dir liegt."""
|
||||||
try:
|
try:
|
||||||
@@ -450,9 +467,11 @@ def open_browser(
|
|||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
|
||||||
def deliver(base_url: str, token: str, src: Path) -> int:
|
def deliver(config: dict, src: Path) -> int:
|
||||||
"""Datei in Slice-Unterordner legen oder hochladen; gibt file_id zurück."""
|
"""Datei in Slice-Unterordner legen oder hochladen; gibt file_id zurück."""
|
||||||
watch = fetch_watch_dir(base_url, token)
|
base_url = config["url"]
|
||||||
|
token = config["token"]
|
||||||
|
watch = resolve_watch_dir(config)
|
||||||
target = output_target()
|
target = output_target()
|
||||||
if watch is not None and target is not None:
|
if watch is not None and target is not None:
|
||||||
placed = under_watch(watch, target)
|
placed = under_watch(watch, target)
|
||||||
@@ -482,10 +501,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
path = find_gcode_arg(argv)
|
path = find_gcode_arg(argv)
|
||||||
write_debug(
|
write_debug(
|
||||||
argv,
|
argv,
|
||||||
"chosen=%s\nupload_as=%s\noutput=%s"
|
"chosen=%s\nupload_as=%s\noutput=%s\nwatch_local=%s"
|
||||||
% (path, upload_name_for(path), output_target() or ""),
|
% (path, upload_name_for(path), output_target() or "", config.get("watch_dir_local") or ""),
|
||||||
)
|
)
|
||||||
file_id = deliver(config["url"], config["token"], path)
|
file_id = deliver(config, path)
|
||||||
name = upload_name_for(path)
|
name = upload_name_for(path)
|
||||||
write_debug(argv, "file_id=%s\nupload_as=%s" % (file_id, name))
|
write_debug(argv, "file_id=%s\nupload_as=%s" % (file_id, name))
|
||||||
mode = str(config.get("browser") or "app").strip().lower()
|
mode = str(config.get("browser") or "app").strip().lower()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import time
|
|||||||
from waitress import serve
|
from waitress import serve
|
||||||
|
|
||||||
from grotti import db, jobs, library, logutil, store
|
from grotti import db, jobs, library, logutil, store
|
||||||
|
from grotti.marlin_supervisor import enable as enable_marlin
|
||||||
from grotti.config import load
|
from grotti.config import load
|
||||||
from web.app import create_app
|
from web.app import create_app
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ def main():
|
|||||||
watcher = app.extensions["watcher"]
|
watcher = app.extensions["watcher"]
|
||||||
dispatcher = app.extensions["print_queue"]
|
dispatcher = app.extensions["print_queue"]
|
||||||
send_queue = app.extensions["send_queue"]
|
send_queue = app.extensions["send_queue"]
|
||||||
|
enable_marlin(cfg["data_dir"])
|
||||||
watcher.start()
|
watcher.start()
|
||||||
dispatcher.start()
|
dispatcher.start()
|
||||||
if hasattr(send_queue, "start"):
|
if hasattr(send_queue, "start"):
|
||||||
@@ -102,6 +104,7 @@ def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
db.close_pool()
|
db.close_pool()
|
||||||
|
# Der Marlin-Dienst bleibt. Ein Stopp hier würde den Druck abbrechen.
|
||||||
|
|
||||||
atexit.register(shutdown)
|
atexit.register(shutdown)
|
||||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
@@ -111,7 +114,13 @@ def main():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
log.info("Grotti: http://%s:%s/", cfg["host"], cfg["port"])
|
log.info("Grotti: http://%s:%s/", cfg["host"], cfg["port"])
|
||||||
serve(app, host=cfg["host"], port=cfg["port"], threads=16, channel_timeout=120)
|
serve(
|
||||||
|
app,
|
||||||
|
host=cfg["host"],
|
||||||
|
port=cfg["port"],
|
||||||
|
threads=int(cfg.get("http_threads") or 16),
|
||||||
|
channel_timeout=120,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Treiberwahl und die LAN-Karte. Kein echter Drucker."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from grotti.drivers import for_printer
|
||||||
|
from grotti.drivers.anycubic_lan import card_from_info, slots_from_report
|
||||||
|
from grotti.moonraker import PrinterError
|
||||||
|
from grotti.printers import family_from_name
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyTests(unittest.TestCase):
|
||||||
|
def test_existing_names_stay_moonraker(self):
|
||||||
|
for name, family in (
|
||||||
|
("Kobra 3 A1", "kobra_3"),
|
||||||
|
("Kobra S1", "kobra_s1"),
|
||||||
|
):
|
||||||
|
self.assertEqual(family_from_name(name), family)
|
||||||
|
drv = for_printer({"name": name, "family": family, "host": "10.0.0.1"})
|
||||||
|
self.assertEqual(drv.transport, "moonraker")
|
||||||
|
self.assertTrue(drv.rinkhals)
|
||||||
|
self.assertFalse(drv.firmware_restart)
|
||||||
|
|
||||||
|
def test_kobra_4_is_lan(self):
|
||||||
|
self.assertEqual(family_from_name("Kobra 4"), "kobra_4")
|
||||||
|
drv = for_printer({"family": "kobra_4", "host": "10.0.0.4"})
|
||||||
|
self.assertEqual(drv.transport, "anycubic_lan")
|
||||||
|
self.assertFalse(drv.rinkhals)
|
||||||
|
|
||||||
|
def test_i3_is_marlin(self):
|
||||||
|
self.assertEqual(family_from_name("Anycubic i3 Mega"), "anycubic_i3")
|
||||||
|
drv = for_printer({"family": "anycubic_i3", "host": "usb"})
|
||||||
|
self.assertEqual(drv.transport, "marlin")
|
||||||
|
|
||||||
|
def test_tenlog_is_marlin_with_two_heads(self):
|
||||||
|
self.assertEqual(family_from_name("Tenlog"), "tenlog")
|
||||||
|
drv = for_printer({"family": "tenlog", "host": "COM5"})
|
||||||
|
self.assertEqual(drv.transport, "marlin")
|
||||||
|
self.assertFalse(drv.rinkhals)
|
||||||
|
|
||||||
|
|
||||||
|
class LanCardTests(unittest.TestCase):
|
||||||
|
def test_busy_with_file_is_printing(self):
|
||||||
|
card = card_from_info({
|
||||||
|
"state": "busy",
|
||||||
|
"temp": {"curr_nozzle_temp": 210, "curr_hotbed_temp": 60},
|
||||||
|
"project": {"filename": "teil.gcode", "progress": 40, "curr_layer": 3, "total_layers": 10},
|
||||||
|
})
|
||||||
|
self.assertEqual(card["state"], "printing")
|
||||||
|
self.assertEqual(card["filename"], "teil.gcode")
|
||||||
|
self.assertAlmostEqual(card["progress"], 0.4)
|
||||||
|
self.assertEqual(card["nozzle"], 210)
|
||||||
|
self.assertEqual(card["layer"], 3)
|
||||||
|
self.assertFalse(card["has_camera"])
|
||||||
|
self.assertIsNone(card["mesh"])
|
||||||
|
|
||||||
|
def test_free_is_standby(self):
|
||||||
|
card = card_from_info({"state": "free", "project": None})
|
||||||
|
self.assertEqual(card["state"], "standby")
|
||||||
|
self.assertEqual(card["filename"], "")
|
||||||
|
|
||||||
|
def test_slots_from_boxes(self):
|
||||||
|
slots = slots_from_report({
|
||||||
|
"multi_color_box": [{
|
||||||
|
"slots": [
|
||||||
|
{"type": "PETG"},
|
||||||
|
{"type": "PLA"},
|
||||||
|
{"type": ""},
|
||||||
|
{"type": "ABS"},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
self.assertEqual([item["type"] for item in slots], ["PETG", "PLA", "ABS"])
|
||||||
|
self.assertEqual([item["index"] for item in slots], [0, 1, 3])
|
||||||
|
|
||||||
|
|
||||||
|
class LanUploadTests(unittest.TestCase):
|
||||||
|
def test_posts_filename_and_gcode(self):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return b"{}"
|
||||||
|
|
||||||
|
def fake_urlopen(request, timeout=0):
|
||||||
|
captured["url"] = request.full_url
|
||||||
|
captured["body"] = request.data
|
||||||
|
captured["length"] = request.headers["X-file-length"]
|
||||||
|
return Response()
|
||||||
|
|
||||||
|
info = {"urls": {"fileUploadurl": "http://10.0.0.4:18910/gcode_upload?s=token"}}
|
||||||
|
drv = for_printer({"family": "kobra_4", "host": "10.0.0.4"})
|
||||||
|
with mock.patch("grotti.anycubic_lan.print_info", return_value=info), \
|
||||||
|
mock.patch("urllib.request.urlopen", fake_urlopen):
|
||||||
|
name = drv.upload("10.0.0.4", "teil.gcode", b"G1 X1")
|
||||||
|
self.assertEqual(name, "teil.gcode")
|
||||||
|
self.assertIn("/gcode_upload", captured["url"])
|
||||||
|
self.assertIn(b'name="filename"', captured["body"])
|
||||||
|
self.assertIn(b'name="gcode"', captured["body"])
|
||||||
|
self.assertIn(b"G1 X1", captured["body"])
|
||||||
|
self.assertEqual(captured["length"], "5")
|
||||||
|
|
||||||
|
def test_missing_url(self):
|
||||||
|
drv = for_printer({"family": "kobra_4"})
|
||||||
|
with mock.patch("grotti.anycubic_lan.print_info", return_value={"urls": {}}):
|
||||||
|
with self.assertRaises(PrinterError):
|
||||||
|
drv.upload("10.0.0.4", "teil.gcode", b"G1")
|
||||||
|
|
||||||
|
|
||||||
|
class MarlinStubTests(unittest.TestCase):
|
||||||
|
def test_start_says_service_missing(self):
|
||||||
|
drv = for_printer({"family": "anycubic_i3", "name": "i3", "host": "COM3"})
|
||||||
|
cards = drv.snapshot([{"name": "i3", "host": "COM3", "family": "anycubic_i3"}])
|
||||||
|
self.assertEqual(cards[0]["state"], "offline")
|
||||||
|
self.assertIn("läuft nicht", cards[0]["message"])
|
||||||
|
with self.assertRaises(PrinterError):
|
||||||
|
drv.start_print("COM3", "teil.gcode")
|
||||||
@@ -85,7 +85,20 @@ class ChooseSpoolTests(unittest.TestCase):
|
|||||||
self.assertTrue(jobs._spool_valid_for_end(spool, end))
|
self.assertTrue(jobs._spool_valid_for_end(spool, end))
|
||||||
|
|
||||||
|
|
||||||
|
class UndoDeductionTests(unittest.TestCase):
|
||||||
|
def test_reopens_last_segment(self):
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.execute.return_value.fetchall.return_value = [
|
||||||
|
{"spool_id": 7, "weight_g": 10},
|
||||||
|
]
|
||||||
|
run = {"id": 42, "deducted": True}
|
||||||
|
self.assertTrue(jobs.undo_deduction(conn, run))
|
||||||
|
sqls = [str(call.args[0]) for call in conn.execute.call_args_list]
|
||||||
|
self.assertTrue(any("UPDATE run_segments" in sql and "mm_end = NULL" in sql for sql in sqls))
|
||||||
|
|
||||||
|
|
||||||
class SteuernAliasTests(unittest.TestCase):
|
class SteuernAliasTests(unittest.TestCase):
|
||||||
|
|
||||||
@patch("grotti.moonraker.gcode_script", return_value={"ok": True})
|
@patch("grotti.moonraker.gcode_script", return_value={"ok": True})
|
||||||
def test_value_alias_for_temp(self, gcode):
|
def test_value_alias_for_temp(self, gcode):
|
||||||
moonraker.steuern("10.0.0.1", "nozzle", {"value": 210})
|
moonraker.steuern("10.0.0.1", "nozzle", {"value": 210})
|
||||||
@@ -106,6 +119,8 @@ class HostValidationTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(validate_printer_host("10.10.1.60"), "10.10.1.60")
|
self.assertEqual(validate_printer_host("10.10.1.60"), "10.10.1.60")
|
||||||
self.assertEqual(validate_printer_host("kobra.local:80"), "kobra.local:80")
|
self.assertEqual(validate_printer_host("kobra.local:80"), "kobra.local:80")
|
||||||
|
self.assertEqual(validate_printer_host("COM5@250000"), "COM5@250000")
|
||||||
|
self.assertEqual(validate_printer_host("/dev/ttyUSB0"), "/dev/ttyUSB0")
|
||||||
with self.assertRaises(GrottiError):
|
with self.assertRaises(GrottiError):
|
||||||
validate_printer_host("http://evil")
|
validate_printer_host("http://evil")
|
||||||
with self.assertRaises(GrottiError):
|
with self.assertRaises(GrottiError):
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Marlin-Dienst mit einer Leitung aus dem Test. Kein USB-Gerät."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from grotti.drivers.marlin import MarlinDriver
|
||||||
|
from grotti.marlin_daemon import serve
|
||||||
|
from grotti.marlin_link import MarlinService, gcode_lines, sd_filename, split_port
|
||||||
|
from grotti.moonraker import PrinterError
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptedPort:
|
||||||
|
def __init__(self):
|
||||||
|
self.written = []
|
||||||
|
self._queue = []
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
text = data.decode().strip()
|
||||||
|
self.written.append(text)
|
||||||
|
if text.startswith("M27"):
|
||||||
|
self._queue.append(b"Done printing file\n")
|
||||||
|
self._queue.append(b"ok\n")
|
||||||
|
elif text.startswith("M105"):
|
||||||
|
self._queue.append(
|
||||||
|
b"ok T:200.0 /210.0 T0:200.0 /210.0 T1:190.0 /180.0 B:60.0 /60.0\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._queue.append(b"ok\n")
|
||||||
|
|
||||||
|
def readline(self):
|
||||||
|
if self._queue:
|
||||||
|
return self._queue.pop(0)
|
||||||
|
return b""
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
GCODE = b"; Kommentar\nT0\nM109 S210\nT1\nM109 S180\nG1 X1\n"
|
||||||
|
|
||||||
|
|
||||||
|
class MarlinTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.port = ScriptedPort()
|
||||||
|
self.service = MarlinService(open_port=lambda device, baud: self.port)
|
||||||
|
self.httpd = serve(0, self.service)
|
||||||
|
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
base = "http://127.0.0.1:%s" % self.httpd.server_address[1]
|
||||||
|
self.drv = MarlinDriver(base=base)
|
||||||
|
self.printer = {
|
||||||
|
"name": "Tenlog",
|
||||||
|
"host": "COM5@250000",
|
||||||
|
"family": "tenlog",
|
||||||
|
"family_label": "Tenlog",
|
||||||
|
}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.httpd.shutdown()
|
||||||
|
self.httpd.server_close()
|
||||||
|
|
||||||
|
def test_split_and_short_name(self):
|
||||||
|
self.assertEqual(split_port("COM5@250000"), ("COM5", 250000))
|
||||||
|
self.assertEqual(sd_filename("teil drucken.gcode"), "TEILDRUC.GCO")
|
||||||
|
self.assertEqual(gcode_lines(GCODE), ["T0", "M109 S210", "T1", "M109 S180", "G1 X1"])
|
||||||
|
|
||||||
|
def test_two_heads_and_stream(self):
|
||||||
|
card = self.drv.snapshot([self.printer])[0]
|
||||||
|
self.assertTrue(card["ok"])
|
||||||
|
self.assertEqual(card["state"], "standby")
|
||||||
|
self.assertEqual(card["slots"], [])
|
||||||
|
self.assertEqual(len(card["tools"]), 2)
|
||||||
|
self.assertEqual(card["tools"][1]["temp"], 190.0)
|
||||||
|
self.assertEqual(card["host"], "COM5@250000")
|
||||||
|
stored = self.drv.upload("COM5@250000", "teil.gcode", GCODE)
|
||||||
|
self.drv.start_print("COM5@250000", stored, mode="stream")
|
||||||
|
self.service.session("COM5")._thread.join(3)
|
||||||
|
sent = self.port.written
|
||||||
|
self.assertIn("T0", sent)
|
||||||
|
self.assertIn("T1", sent)
|
||||||
|
self.assertNotIn("M28 teil.gcode", sent)
|
||||||
|
status = self.service.session("COM5").status()
|
||||||
|
self.assertEqual(status["state"], "complete")
|
||||||
|
self.assertEqual(status["progress"], 1)
|
||||||
|
|
||||||
|
def test_sd_writes_then_starts(self):
|
||||||
|
self.drv.attach(self.printer)
|
||||||
|
stored = self.drv.upload("COM5", "teil.gcode", GCODE)
|
||||||
|
shown = self.drv.start_print("COM5@250000", stored, mode="sd")
|
||||||
|
self.assertEqual(shown, "TEIL.GCO")
|
||||||
|
self.service.session("COM5")._thread.join(5)
|
||||||
|
sent = self.port.written
|
||||||
|
self.assertIn("M28 TEIL.GCO", sent)
|
||||||
|
self.assertIn("M29", sent)
|
||||||
|
self.assertIn("M23 TEIL.GCO", sent)
|
||||||
|
self.assertIn("M24", sent)
|
||||||
|
self.assertLess(sent.index("M28 TEIL.GCO"), sent.index("T0"))
|
||||||
|
self.assertLess(sent.index("M29"), sent.index("M24"))
|
||||||
|
|
||||||
|
def test_pause_and_cancel_two_heaters(self):
|
||||||
|
self.drv.attach(self.printer)
|
||||||
|
session = self.service.session("COM5")
|
||||||
|
session.state = "printing"
|
||||||
|
session.mode = "sd"
|
||||||
|
self.drv.command("COM5", "pause")
|
||||||
|
self.assertIn("M25", self.port.written)
|
||||||
|
self.assertEqual(session.state, "paused")
|
||||||
|
session.cancel()
|
||||||
|
self.assertIn("M104 T0 S0", self.port.written)
|
||||||
|
self.assertIn("M104 T1 S0", self.port.written)
|
||||||
|
self.assertIn("M140 S0", self.port.written)
|
||||||
|
self.assertEqual(session.state, "standby")
|
||||||
|
|
||||||
|
def test_without_service_stays_offline(self):
|
||||||
|
drv = MarlinDriver(base="http://127.0.0.1:9")
|
||||||
|
card = drv.snapshot([{"name": "i3", "host": "COM9", "family": "anycubic_i3"}])[0]
|
||||||
|
self.assertFalse(card["ok"])
|
||||||
|
self.assertEqual(card["state"], "offline")
|
||||||
|
with self.assertRaises(PrinterError):
|
||||||
|
drv.start_print("COM9", "a.gcode")
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Marlin-Dienst starten, ohne ihn zu beenden. Kein echtes USB-Gerät."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from grotti import marlin_supervisor as sup
|
||||||
|
|
||||||
|
|
||||||
|
class SupervisorTests(unittest.TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
sup.disable()
|
||||||
|
|
||||||
|
def test_running_service_is_not_started_again(self):
|
||||||
|
starts = []
|
||||||
|
service = sup.MarlinSupervisor(
|
||||||
|
Path("."),
|
||||||
|
healthy=lambda: True,
|
||||||
|
start=lambda folder: starts.append(folder),
|
||||||
|
wait_s=0.1,
|
||||||
|
)
|
||||||
|
result = service.ensure()
|
||||||
|
self.assertTrue(result["running"])
|
||||||
|
self.assertFalse(result["started"])
|
||||||
|
self.assertEqual(starts, [])
|
||||||
|
|
||||||
|
def test_starts_once_when_down(self):
|
||||||
|
state = {"up": False}
|
||||||
|
starts = []
|
||||||
|
|
||||||
|
def healthy():
|
||||||
|
return state["up"]
|
||||||
|
|
||||||
|
def start(folder):
|
||||||
|
starts.append(folder)
|
||||||
|
state["up"] = True
|
||||||
|
|
||||||
|
service = sup.MarlinSupervisor(Path("."), healthy=healthy, start=start, wait_s=0.2)
|
||||||
|
first = service.ensure()
|
||||||
|
second = service.ensure()
|
||||||
|
self.assertTrue(first["started"])
|
||||||
|
self.assertEqual(len(starts), 1)
|
||||||
|
self.assertFalse(second["started"])
|
||||||
|
self.assertTrue(second["running"])
|
||||||
|
|
||||||
|
def test_failed_start_does_not_repeat_immediately(self):
|
||||||
|
starts = []
|
||||||
|
service = sup.MarlinSupervisor(
|
||||||
|
Path("."),
|
||||||
|
healthy=lambda: False,
|
||||||
|
start=lambda folder: starts.append(1),
|
||||||
|
wait_s=0.05,
|
||||||
|
)
|
||||||
|
service.ensure()
|
||||||
|
service.ensure()
|
||||||
|
self.assertEqual(len(starts), 1)
|
||||||
|
|
||||||
|
def test_parallel_ensure_starts_once(self):
|
||||||
|
gate = threading.Event()
|
||||||
|
starts = []
|
||||||
|
|
||||||
|
def start(folder):
|
||||||
|
starts.append(1)
|
||||||
|
gate.wait(1)
|
||||||
|
|
||||||
|
service = sup.MarlinSupervisor(
|
||||||
|
Path("."),
|
||||||
|
healthy=lambda: False,
|
||||||
|
start=start,
|
||||||
|
wait_s=0.05,
|
||||||
|
)
|
||||||
|
threads = [threading.Thread(target=service.ensure) for _ in range(4)]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
gate.set()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join(2)
|
||||||
|
self.assertEqual(len(starts), 1)
|
||||||
|
|
||||||
|
def test_spawn_is_detached_and_runs_the_module(self):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
class Proc:
|
||||||
|
pid = 42
|
||||||
|
|
||||||
|
def popen(**kwargs):
|
||||||
|
seen.update(kwargs)
|
||||||
|
if kwargs.get("creationflags", 0) & sup._CREATE_BREAKAWAY:
|
||||||
|
raise OSError("breakaway")
|
||||||
|
return Proc()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
pid = sup.spawn_detached(folder, popen=popen)
|
||||||
|
self.assertEqual(pid, 42)
|
||||||
|
self.assertEqual(seen["args"][1:], ["-m", "grotti.marlin_daemon"])
|
||||||
|
self.assertIs(seen["stdin"], sup.subprocess.DEVNULL)
|
||||||
|
if sup.os.name == "nt":
|
||||||
|
flags = seen["creationflags"]
|
||||||
|
self.assertTrue(flags & sup._DETACHED)
|
||||||
|
self.assertFalse(flags & sup._CREATE_BREAKAWAY)
|
||||||
|
else:
|
||||||
|
self.assertTrue(seen["start_new_session"])
|
||||||
|
|
||||||
|
def test_enable_reuses_a_live_daemon(self):
|
||||||
|
sup.enable(Path("."))
|
||||||
|
sup._supervisor.healthy = lambda: True
|
||||||
|
sup._supervisor.start = lambda folder: (_ for _ in ()).throw(AssertionError("gestartet"))
|
||||||
|
result = sup.ensure_if_enabled()
|
||||||
|
self.assertTrue(result["running"])
|
||||||
|
self.assertFalse(result["started"])
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Problem-Meldungen: Relay und Antwort, ohne echten Forgejo."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from grotti import meldung
|
||||||
|
|
||||||
|
|
||||||
|
class RelayTests(unittest.TestCase):
|
||||||
|
def test_relay_ok(self):
|
||||||
|
cfg = {"_raw": {"meldung_relay": "http://127.0.0.1/hooks/grotti-meldung"}}
|
||||||
|
|
||||||
|
class Resp:
|
||||||
|
def read(self):
|
||||||
|
return b'{"url":"https://git.example/issues/1"}'
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
with mock.patch("urllib.request.urlopen", return_value=Resp()) as opener:
|
||||||
|
ok, message = meldung.send_relay(cfg, {"text": "kaputt", "version": "1"})
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertIn("issues/1", message)
|
||||||
|
request = opener.call_args[0][0]
|
||||||
|
body = json.loads(request.data.decode("utf-8"))
|
||||||
|
self.assertEqual(body["text"], "kaputt")
|
||||||
|
self.assertNotIn("username", body)
|
||||||
|
|
||||||
|
def test_relay_down(self):
|
||||||
|
cfg = {"_raw": {"meldung_relay": "http://127.0.0.1:9/hooks"}}
|
||||||
|
with mock.patch("urllib.request.urlopen", side_effect=URLError("down")):
|
||||||
|
ok, message = meldung.send_relay(cfg, {"text": "x"})
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertIn("nicht erreichbar", message)
|
||||||
|
|
||||||
|
def test_relay_http_error(self):
|
||||||
|
cfg = {"_raw": {"meldung_relay": "http://example/hooks"}}
|
||||||
|
|
||||||
|
class Fake(HTTPError):
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return b"nope"
|
||||||
|
|
||||||
|
code = 502
|
||||||
|
|
||||||
|
with mock.patch("urllib.request.urlopen", side_effect=Fake()):
|
||||||
|
ok, message = meldung.send_relay(cfg, {"text": "x"})
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertIn("502", message)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -155,12 +155,31 @@ class OrcaSendenHelpersTest(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = Path(tmp) / "orca-senden.json"
|
path = Path(tmp) / "orca-senden.json"
|
||||||
path.write_text(
|
path.write_text(
|
||||||
json.dumps({"url": "http://127.0.0.1:8770/", "token": "grt_abc"}),
|
json.dumps({
|
||||||
|
"url": "http://127.0.0.1:8770/",
|
||||||
|
"token": "grt_abc",
|
||||||
|
"watch_dir_local": "D:/Slice",
|
||||||
|
}),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
cfg = orca.load_config(path)
|
cfg = orca.load_config(path)
|
||||||
self.assertEqual(cfg["url"], "http://127.0.0.1:8770")
|
self.assertEqual(cfg["url"], "http://127.0.0.1:8770")
|
||||||
self.assertEqual(cfg["token"], "grt_abc")
|
self.assertEqual(cfg["token"], "grt_abc")
|
||||||
|
self.assertEqual(cfg["watch_dir_local"], "D:/Slice")
|
||||||
|
|
||||||
|
def test_resolve_watch_dir_prefers_local(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
local = Path(tmp) / "slice"
|
||||||
|
local.mkdir()
|
||||||
|
cfg = {
|
||||||
|
"url": "http://127.0.0.1:8770",
|
||||||
|
"token": "grt_x",
|
||||||
|
"watch_dir_local": str(local),
|
||||||
|
}
|
||||||
|
with patch.object(orca, "fetch_watch_dir") as fetch:
|
||||||
|
resolved = orca.resolve_watch_dir(cfg)
|
||||||
|
self.assertEqual(resolved, local)
|
||||||
|
fetch.assert_not_called()
|
||||||
|
|
||||||
def test_load_config_missing(self):
|
def test_load_config_missing(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""ACE-Startabschnitte und Spulenwechsel 0→jetzt."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from grotti import store
|
||||||
|
from grotti.watcher import StateWatcher
|
||||||
|
|
||||||
|
|
||||||
|
class PrevSpoolForSlotTests(unittest.TestCase):
|
||||||
|
def test_prefers_old_at_slot(self):
|
||||||
|
conn = mock.Mock()
|
||||||
|
live = {"id": 1, "filename": "a.gcode", "spool_id": 99}
|
||||||
|
got = store.prev_spool_id_for_slot(
|
||||||
|
conn, "h", 4, live, 21, old_rows=[{"id": 18}],
|
||||||
|
)
|
||||||
|
self.assertEqual(got, 18)
|
||||||
|
|
||||||
|
def test_ace_map_send_when_old_empty(self):
|
||||||
|
conn = mock.Mock()
|
||||||
|
live = {"id": 33805, "filename": "x.gcode", "spool_id": None}
|
||||||
|
with mock.patch(
|
||||||
|
"grotti.store.latest_send",
|
||||||
|
return_value={
|
||||||
|
"ace_map": [{"paint_index": 0, "ams_index": 3}],
|
||||||
|
"spool_id": 18,
|
||||||
|
},
|
||||||
|
):
|
||||||
|
got = store.prev_spool_id_for_slot(conn, "h", 4, live, 21, old_rows=[])
|
||||||
|
self.assertEqual(got, 18)
|
||||||
|
|
||||||
|
def test_last_segment_on_slot(self):
|
||||||
|
conn = mock.Mock()
|
||||||
|
live = {"id": 7, "filename": "x.gcode", "spool_id": None}
|
||||||
|
with mock.patch("grotti.store.latest_send", return_value={"ace_map": [], "spool_id": None}), \
|
||||||
|
mock.patch(
|
||||||
|
"grotti.store.list_run_segments",
|
||||||
|
return_value=[
|
||||||
|
{"spool_id": 10, "ace_slot": 2, "mm_start": 0, "mm_end": 100},
|
||||||
|
{"spool_id": 18, "ace_slot": 4, "mm_start": 0, "mm_end": 500},
|
||||||
|
],
|
||||||
|
):
|
||||||
|
got = store.prev_spool_id_for_slot(conn, "h", 4, live, 21, old_rows=[])
|
||||||
|
self.assertEqual(got, 18)
|
||||||
|
|
||||||
|
|
||||||
|
class EnsureStartSegmentAceTests(unittest.TestCase):
|
||||||
|
def test_opens_mapped_slots_with_spool(self):
|
||||||
|
watcher = StateWatcher("postgresql://x", {})
|
||||||
|
conn = mock.Mock()
|
||||||
|
live = {"filament_used": 0}
|
||||||
|
with mock.patch("grotti.watcher.store.list_run_segments", return_value=[]), \
|
||||||
|
mock.patch(
|
||||||
|
"grotti.watcher.store.latest_send",
|
||||||
|
return_value={"ace_map": [{"paint_index": 0, "ams_index": 3}]},
|
||||||
|
), \
|
||||||
|
mock.patch(
|
||||||
|
"grotti.watcher.store.spool_at_slot",
|
||||||
|
return_value={"id": 18},
|
||||||
|
) as at_slot, \
|
||||||
|
mock.patch("grotti.watcher.store.open_segment") as open_seg:
|
||||||
|
watcher._ensure_start_segment(conn, "h", "a.gcode", live, 33805)
|
||||||
|
at_slot.assert_called_once_with(conn, "h", 4)
|
||||||
|
open_seg.assert_called_once_with(conn, 33805, 18, 4, 0.0)
|
||||||
|
|
||||||
|
def test_ace_without_spool_opens_nothing(self):
|
||||||
|
watcher = StateWatcher("postgresql://x", {})
|
||||||
|
conn = mock.Mock()
|
||||||
|
with mock.patch("grotti.watcher.store.list_run_segments", return_value=[]), \
|
||||||
|
mock.patch(
|
||||||
|
"grotti.watcher.store.latest_send",
|
||||||
|
return_value={"ace_map": [{"paint_index": 0, "ams_index": 3}]},
|
||||||
|
), \
|
||||||
|
mock.patch("grotti.watcher.store.spool_at_slot", return_value=None), \
|
||||||
|
mock.patch("grotti.watcher.store.open_segment") as open_seg:
|
||||||
|
watcher._ensure_start_segment(conn, "h", "a.gcode", {"filament_used": 0}, 1)
|
||||||
|
open_seg.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class SpoolLoadFallbackSegmentsTests(unittest.TestCase):
|
||||||
|
"""ACE ohne Segment + Einlegen ohne old → 0→jetzt + neuer offener Abschnitt."""
|
||||||
|
|
||||||
|
def test_creates_closed_and_open_segment(self):
|
||||||
|
conn = mock.Mock()
|
||||||
|
live = {"id": 33805, "filename": "x.gcode", "spool_id": None, "state": "in_progress"}
|
||||||
|
opened = []
|
||||||
|
closed = []
|
||||||
|
|
||||||
|
def open_segment(_c, run_id, spool_id, ace_slot, mm_start):
|
||||||
|
opened.append((run_id, spool_id, ace_slot, mm_start))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def close_open_segments(_c, run_id, ace_slot, mm_end):
|
||||||
|
closed.append((run_id, ace_slot, mm_end))
|
||||||
|
# Erster Aufruf: nichts offen; zweiter: nach open_segment
|
||||||
|
return 1 if opened else 0
|
||||||
|
|
||||||
|
with mock.patch("grotti.store.close_open_segments", side_effect=close_open_segments), \
|
||||||
|
mock.patch("grotti.store.open_segment", side_effect=open_segment), \
|
||||||
|
mock.patch(
|
||||||
|
"grotti.store.prev_spool_id_for_slot",
|
||||||
|
return_value=18,
|
||||||
|
):
|
||||||
|
# Logik wie in spool_load (Auszug)
|
||||||
|
slot = 4
|
||||||
|
spool_id = 21
|
||||||
|
mm_now = 5291.0
|
||||||
|
old = []
|
||||||
|
closed_n = store.close_open_segments(conn, live["id"], slot, mm_now)
|
||||||
|
if not closed_n and old:
|
||||||
|
pass
|
||||||
|
elif not closed_n and mm_now > 0:
|
||||||
|
prev_id = store.prev_spool_id_for_slot(
|
||||||
|
conn, "h", slot, live, spool_id, old_rows=old,
|
||||||
|
)
|
||||||
|
if prev_id:
|
||||||
|
store.open_segment(conn, live["id"], prev_id, slot, 0)
|
||||||
|
store.close_open_segments(conn, live["id"], slot, mm_now)
|
||||||
|
store.open_segment(conn, live["id"], spool_id, slot, mm_now)
|
||||||
|
|
||||||
|
self.assertEqual(opened[0], (33805, 18, 4, 0))
|
||||||
|
self.assertEqual(closed[-1], (33805, 4, 5291.0))
|
||||||
|
self.assertEqual(opened[-1], (33805, 21, 4, 5291.0))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -137,6 +137,69 @@ class WatcherOfflineTests(unittest.TestCase):
|
|||||||
kinds = [call.args[3] for call in emit.call_args_list]
|
kinds = [call.args[3] for call in emit.call_args_list]
|
||||||
self.assertEqual(kinds, ["abbruch", "offline", "start"])
|
self.assertEqual(kinds, ["abbruch", "offline", "start"])
|
||||||
|
|
||||||
|
def test_other_file_without_offline_releases_the_old_run(self):
|
||||||
|
live = {
|
||||||
|
"host": HOST,
|
||||||
|
"name": "Kobra S1",
|
||||||
|
"state": "printing",
|
||||||
|
"ok": True,
|
||||||
|
"filename": "andere.gcode",
|
||||||
|
"weight": 0,
|
||||||
|
}
|
||||||
|
old = {"id": 42, "filename": FILE}
|
||||||
|
new = {"id": 99, "filename": "andere.gcode"}
|
||||||
|
seen = {"n": 0}
|
||||||
|
|
||||||
|
def live_run(_conn, _host, filename=""):
|
||||||
|
seen["n"] += 1
|
||||||
|
if seen["n"] <= 3:
|
||||||
|
return old
|
||||||
|
return None
|
||||||
|
|
||||||
|
with mock.patch.object(self.watcher, "_emit") as emit, \
|
||||||
|
mock.patch.object(self.watcher, "_track_print", return_value=new), \
|
||||||
|
mock.patch.object(self.watcher, "_ensure_start_segment"), \
|
||||||
|
mock.patch.object(self.watcher, "_check_spool", return_value=False), \
|
||||||
|
mock.patch("grotti.watcher.store.find_live_run", side_effect=live_run), \
|
||||||
|
mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \
|
||||||
|
mock.patch("grotti.watcher.jobs.sync_history") as sync:
|
||||||
|
self.watcher._one(mock.Mock(), live)
|
||||||
|
sync.assert_called_once()
|
||||||
|
abandon.assert_called()
|
||||||
|
self.assertIn("abbruch", [call.args[3] for call in emit.call_args_list])
|
||||||
|
self.assertIn("start", [call.args[3] for call in emit.call_args_list])
|
||||||
|
|
||||||
|
def test_cancel_event_survives_deleted_live_run(self):
|
||||||
|
self.watcher._cancel_hits[HOST] = 2
|
||||||
|
live = {
|
||||||
|
"host": HOST,
|
||||||
|
"name": "Kobra S1",
|
||||||
|
"state": "cancelled",
|
||||||
|
"ok": True,
|
||||||
|
"filename": FILE,
|
||||||
|
}
|
||||||
|
dangling = {"id": 42, "filename": FILE, "state": "in_progress"}
|
||||||
|
|
||||||
|
def row(_conn, run_id):
|
||||||
|
if run_id == 77:
|
||||||
|
return {"id": 77, "filename": FILE}
|
||||||
|
return None
|
||||||
|
|
||||||
|
with mock.patch("grotti.watcher.store.find_live_run", side_effect=[dangling, None]), \
|
||||||
|
mock.patch("grotti.watcher.store.find_open_history_run", return_value=None), \
|
||||||
|
mock.patch("grotti.watcher.store.get_run_row", side_effect=row), \
|
||||||
|
mock.patch("grotti.watcher.store.latest_run_id", return_value=77), \
|
||||||
|
mock.patch("grotti.watcher.store.abandon_live_runs"), \
|
||||||
|
mock.patch("grotti.watcher.store.set_bed_needs_clear"), \
|
||||||
|
mock.patch("grotti.watcher.store.set_printer_active_run"), \
|
||||||
|
mock.patch("grotti.watcher.jobs.sync_history") as sync, \
|
||||||
|
mock.patch("grotti.watcher.notify.record") as record:
|
||||||
|
self.watcher._one(mock.Mock(), live)
|
||||||
|
sync.assert_called_once()
|
||||||
|
record.assert_called()
|
||||||
|
self.assertEqual(record.call_args.kwargs["run_id"], 77)
|
||||||
|
self.assertEqual(record.call_args.args[4], "abbruch")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
+87
-52
@@ -40,10 +40,13 @@ DOCS_DIR = TOOL_ROOT / "docs"
|
|||||||
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
|
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
|
||||||
VERDICTS = {"", "gut", "ausschuss"}
|
VERDICTS = {"", "gut", "ausschuss"}
|
||||||
VERDICT_REASONS = sorted(grotti_verdict.VERDICT_REASONS)
|
VERDICT_REASONS = sorted(grotti_verdict.VERDICT_REASONS)
|
||||||
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
|
|
||||||
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
|
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
|
||||||
UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
|
UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
|
||||||
HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$")
|
HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$")
|
||||||
|
SERIAL_RE = re.compile(
|
||||||
|
r"^(?:COM\d+|/dev/tty(?:USB|ACM)\d+|/dev/serial/by-id/[A-Za-z0-9._\-]+)(?:@\d{3,7})?$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
TOKEN_QUERY_PATHS = (
|
TOKEN_QUERY_PATHS = (
|
||||||
"/api/v1/kamera",
|
"/api/v1/kamera",
|
||||||
"/api/v1/kamera/stream",
|
"/api/v1/kamera/stream",
|
||||||
@@ -59,13 +62,11 @@ STEER_ACTIONS = {
|
|||||||
|
|
||||||
def validate_printer_host(host):
|
def validate_printer_host(host):
|
||||||
host = (host or "").strip()
|
host = (host or "").strip()
|
||||||
if not host or not HOST_RE.fullmatch(host):
|
if not host or ".." in host or not (HOST_RE.fullmatch(host) or SERIAL_RE.fullmatch(host)):
|
||||||
raise GrottiError(
|
raise GrottiError(
|
||||||
"Host nur als Hostname oder IP, optional mit Port (z. B. 10.10.1.60).",
|
"Host als IP, Hostname, COM-Port (COM5 oder COM5@250000) oder /dev/tty…",
|
||||||
"host",
|
"host",
|
||||||
)
|
)
|
||||||
if ".." in host:
|
|
||||||
raise GrottiError("Ungültiger Host.", "host")
|
|
||||||
return host
|
return host
|
||||||
|
|
||||||
|
|
||||||
@@ -732,6 +733,7 @@ def create_app(cfg):
|
|||||||
token="",
|
token="",
|
||||||
spool=spool,
|
spool=spool,
|
||||||
qr_url=qr_url,
|
qr_url=qr_url,
|
||||||
|
etikett=cfg.get("etikett") or grotti_config._etikett({}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/protokoll")
|
@app.get("/protokoll")
|
||||||
@@ -792,6 +794,7 @@ def create_app(cfg):
|
|||||||
sheets=sheets,
|
sheets=sheets,
|
||||||
spools=spools,
|
spools=spools,
|
||||||
target_g=target_g,
|
target_g=target_g,
|
||||||
|
etikett=cfg.get("etikett") or grotti_config._etikett({}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/ideen")
|
@app.get("/ideen")
|
||||||
@@ -879,6 +882,7 @@ def create_app(cfg):
|
|||||||
auto_retry = data.get("auto_retry") if "auto_retry" in data else None
|
auto_retry = data.get("auto_retry") if "auto_retry" in data else None
|
||||||
fqdn = data.get("fqdn") if "fqdn" in data else None
|
fqdn = data.get("fqdn") if "fqdn" in data else None
|
||||||
watch_dir = data.get("watch_dir") if "watch_dir" in data else None
|
watch_dir = data.get("watch_dir") if "watch_dir" in data else None
|
||||||
|
etikett = data.get("etikett") if isinstance(data.get("etikett"), dict) else None
|
||||||
return jsonify(grotti_config.save_settings(
|
return jsonify(grotti_config.save_settings(
|
||||||
cfg,
|
cfg,
|
||||||
energy=energy,
|
energy=energy,
|
||||||
@@ -889,6 +893,7 @@ def create_app(cfg):
|
|||||||
auto_retry=auto_retry,
|
auto_retry=auto_retry,
|
||||||
fqdn=fqdn,
|
fqdn=fqdn,
|
||||||
watch_dir=watch_dir,
|
watch_dir=watch_dir,
|
||||||
|
etikett=etikett,
|
||||||
))
|
))
|
||||||
|
|
||||||
@app.post("/api/v1/telegram/test")
|
@app.post("/api/v1/telegram/test")
|
||||||
@@ -931,8 +936,12 @@ def create_app(cfg):
|
|||||||
|
|
||||||
by_id = {row["host"]: row for row in rows}
|
by_id = {row["host"]: row for row in rows}
|
||||||
if request.args.get("live", "1") == "0":
|
if request.args.get("live", "1") == "0":
|
||||||
|
from grotti.drivers import for_printer
|
||||||
for row in rows:
|
for row in rows:
|
||||||
row["mainsail"] = "http://%s/" % row["host"]
|
if for_printer(row).transport == "moonraker":
|
||||||
|
row["mainsail"] = "http://%s/" % row["host"]
|
||||||
|
else:
|
||||||
|
row["mainsail"] = ""
|
||||||
attach(row)
|
attach(row)
|
||||||
return jsonify(rows)
|
return jsonify(rows)
|
||||||
# Eine Live-Quelle: Watcher. Frischer als 60 s, sonst einmal selbst abfragen.
|
# Eine Live-Quelle: Watcher. Frischer als 60 s, sonst einmal selbst abfragen.
|
||||||
@@ -940,7 +949,8 @@ def create_app(cfg):
|
|||||||
cached = watcher.by_host()
|
cached = watcher.by_host()
|
||||||
age = watcher.age_s()
|
age = watcher.age_s()
|
||||||
if not cached or age is None or age > 60:
|
if not cached or age is None or age > 60:
|
||||||
lives = moonraker.snapshot(rows)
|
from grotti.drivers import snapshot
|
||||||
|
lives = snapshot(rows)
|
||||||
else:
|
else:
|
||||||
lives = []
|
lives = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@@ -955,7 +965,11 @@ def create_app(cfg):
|
|||||||
item = dict(row)
|
item = dict(row)
|
||||||
item["ok"] = False
|
item["ok"] = False
|
||||||
item["state"] = "offline"
|
item["state"] = "offline"
|
||||||
item["mainsail"] = "http://%s/" % row["host"]
|
from grotti.drivers import for_printer as _for_printer
|
||||||
|
if _for_printer(row).transport == "moonraker":
|
||||||
|
item["mainsail"] = "http://%s/" % row["host"]
|
||||||
|
else:
|
||||||
|
item["mainsail"] = ""
|
||||||
lives.append(item)
|
lives.append(item)
|
||||||
for live in lives:
|
for live in lives:
|
||||||
meta = by_id.get(live.get("host") or "") or {}
|
meta = by_id.get(live.get("host") or "") or {}
|
||||||
@@ -963,6 +977,53 @@ def create_app(cfg):
|
|||||||
attach(live)
|
attach(live)
|
||||||
return jsonify(lives)
|
return jsonify(lives)
|
||||||
|
|
||||||
|
@app.get("/api/v1/marlin-dienst")
|
||||||
|
def marlin_dienst():
|
||||||
|
from grotti.marlin_supervisor import current_status, ensure_if_enabled
|
||||||
|
|
||||||
|
ensure_if_enabled()
|
||||||
|
return jsonify(current_status())
|
||||||
|
|
||||||
|
@app.post("/api/v1/meldungen")
|
||||||
|
def meldung_create():
|
||||||
|
from grotti import meldung as grotti_meldung
|
||||||
|
|
||||||
|
require_role("operator")
|
||||||
|
data = body()
|
||||||
|
try:
|
||||||
|
row = grotti_meldung.create(
|
||||||
|
conn(),
|
||||||
|
cfg,
|
||||||
|
text=data.get("text") or "",
|
||||||
|
page=data.get("page") or "",
|
||||||
|
printer_host=data.get("printer_host") or "",
|
||||||
|
error_text=data.get("error_text") or "",
|
||||||
|
user=g.user,
|
||||||
|
)
|
||||||
|
conn().commit()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise GrottiError(str(exc), "meldung") from exc
|
||||||
|
return jsonify(row), 201
|
||||||
|
|
||||||
|
@app.get("/api/v1/meldungen")
|
||||||
|
def meldung_list():
|
||||||
|
from grotti import meldung as grotti_meldung
|
||||||
|
|
||||||
|
require_role("admin")
|
||||||
|
status = (request.args.get("status") or "").strip() or None
|
||||||
|
return jsonify(grotti_meldung.list_rows(conn(), status=status))
|
||||||
|
|
||||||
|
@app.post("/api/v1/meldungen/<int:meldung_id>/erledigt")
|
||||||
|
def meldung_close(meldung_id):
|
||||||
|
from grotti import meldung as grotti_meldung
|
||||||
|
|
||||||
|
require_role("admin")
|
||||||
|
row = grotti_meldung.close(conn(), meldung_id)
|
||||||
|
if not row:
|
||||||
|
raise GrottiError("Die Meldung fehlt.", "meldung", 404)
|
||||||
|
conn().commit()
|
||||||
|
return jsonify(row)
|
||||||
|
|
||||||
@app.post("/api/v1/drucker")
|
@app.post("/api/v1/drucker")
|
||||||
def printer_create():
|
def printer_create():
|
||||||
data = body()
|
data = body()
|
||||||
@@ -1555,6 +1616,7 @@ def create_app(cfg):
|
|||||||
optional_id(data.get("spool_id")),
|
optional_id(data.get("spool_id")),
|
||||||
ace_maps,
|
ace_maps,
|
||||||
bed_leveling=bool(data.get("bed_leveling")),
|
bed_leveling=bool(data.get("bed_leveling")),
|
||||||
|
marlin_mode=(data.get("marlin_mode") or "stream"),
|
||||||
)
|
)
|
||||||
return jsonify({"jobs": created, "count": len(created), "mode": "now"}), 202
|
return jsonify({"jobs": created, "count": len(created), "mode": "now"}), 202
|
||||||
|
|
||||||
@@ -1973,8 +2035,13 @@ def create_app(cfg):
|
|||||||
# laufenden Druck auf dem Host best-effort stoppen
|
# laufenden Druck auf dem Host best-effort stoppen
|
||||||
if was_running and row.get("host"):
|
if was_running and row.get("host"):
|
||||||
try:
|
try:
|
||||||
moonraker.command(row["host"], "abbrechen")
|
from grotti.drivers import for_printer
|
||||||
stopped = ", Druck gestoppt"
|
printer = store.printer_by_host(conn(), row["host"])
|
||||||
|
if printer:
|
||||||
|
for_printer(printer).command(row["host"], "abbrechen")
|
||||||
|
stopped = ", Druck gestoppt"
|
||||||
|
else:
|
||||||
|
stopped = ", Druck nicht gestoppt"
|
||||||
except Exception:
|
except Exception:
|
||||||
stopped = ", Druck nicht gestoppt"
|
stopped = ", Druck nicht gestoppt"
|
||||||
store.add_event(
|
store.add_event(
|
||||||
@@ -2142,47 +2209,6 @@ def create_app(cfg):
|
|||||||
def color_list_api():
|
def color_list_api():
|
||||||
return jsonify(color_catalog())
|
return jsonify(color_catalog())
|
||||||
|
|
||||||
@app.get("/api/v1/ideen")
|
|
||||||
def idea_list():
|
|
||||||
return jsonify(store.list_ideas(conn()))
|
|
||||||
|
|
||||||
@app.post("/api/v1/ideen")
|
|
||||||
def idea_create():
|
|
||||||
data = body()
|
|
||||||
title = (data.get("title") or "").strip()
|
|
||||||
if not title:
|
|
||||||
raise GrottiError("Die Idee braucht einen Titel.", "titel")
|
|
||||||
status = data.get("status") or "offen"
|
|
||||||
if status not in IDEA_STATUS:
|
|
||||||
raise GrottiError("Unbekannter Status.", "status")
|
|
||||||
return jsonify(store.create_idea(conn(), title, (data.get("body") or "").strip(), status)), 201
|
|
||||||
|
|
||||||
@app.patch("/api/v1/ideen/<int:idea_id>")
|
|
||||||
def idea_patch(idea_id):
|
|
||||||
data = body()
|
|
||||||
fields = {}
|
|
||||||
if "title" in data:
|
|
||||||
title = (data.get("title") or "").strip()
|
|
||||||
if not title:
|
|
||||||
raise GrottiError("Die Idee braucht einen Titel.", "titel")
|
|
||||||
fields["title"] = title
|
|
||||||
if "body" in data:
|
|
||||||
fields["body"] = (data.get("body") or "").strip()
|
|
||||||
if "status" in data:
|
|
||||||
if data.get("status") not in IDEA_STATUS:
|
|
||||||
raise GrottiError("Unbekannter Status.", "status")
|
|
||||||
fields["status"] = data.get("status")
|
|
||||||
row = store.update_idea(conn(), idea_id, fields)
|
|
||||||
if not row:
|
|
||||||
raise GrottiError("Die Idee fehlt.", "idee", 404)
|
|
||||||
return jsonify(row)
|
|
||||||
|
|
||||||
@app.delete("/api/v1/ideen/<int:idea_id>")
|
|
||||||
def idea_delete(idea_id):
|
|
||||||
if not store.delete_idea(conn(), idea_id):
|
|
||||||
raise GrottiError("Die Idee fehlt.", "idee", 404)
|
|
||||||
return "", 204
|
|
||||||
|
|
||||||
@app.get("/api/v1/kunden")
|
@app.get("/api/v1/kunden")
|
||||||
def kunden_list():
|
def kunden_list():
|
||||||
return jsonify(grotti_orders.list_customers(conn()))
|
return jsonify(grotti_orders.list_customers(conn()))
|
||||||
@@ -2485,6 +2511,15 @@ def create_app(cfg):
|
|||||||
for row in old:
|
for row in old:
|
||||||
store.open_segment(conn(), live_run["id"], row["id"], slot, 0)
|
store.open_segment(conn(), live_run["id"], row["id"], slot, 0)
|
||||||
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
||||||
|
elif not closed and mm_now > 0:
|
||||||
|
# ACE ohne Startabschnitt oder alte Rolle schon im Regal —
|
||||||
|
# slot-genau: Platz / ACE-Map / letzter Abschnitt / Lauf-Hinweis.
|
||||||
|
prev_id = store.prev_spool_id_for_slot(
|
||||||
|
conn(), host, slot, live_run, spool_id, old_rows=old,
|
||||||
|
)
|
||||||
|
if prev_id:
|
||||||
|
store.open_segment(conn(), live_run["id"], prev_id, slot, 0)
|
||||||
|
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
||||||
for row in old:
|
for row in old:
|
||||||
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
|
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
|
||||||
store.add_event(
|
store.add_event(
|
||||||
@@ -2717,7 +2752,7 @@ def create_app(cfg):
|
|||||||
"samples": samples,
|
"samples": samples,
|
||||||
"labels": store.list_run_labels(conn(), run_id),
|
"labels": store.list_run_labels(conn(), run_id),
|
||||||
"events": store.list_run_events(conn(), run_id),
|
"events": store.list_run_events(conn(), run_id),
|
||||||
"file": store.file_out(file_row, watch_dir=cfg.get("watch_dir")) if file_row else None,
|
"file": store.file_out(file_row, watch_dir=cfg.get("watch_dir"), conn=conn()) if file_row else None,
|
||||||
"project": project,
|
"project": project,
|
||||||
"energy": energy,
|
"energy": energy,
|
||||||
})
|
})
|
||||||
|
|||||||
+46
-14
@@ -64,8 +64,8 @@
|
|||||||
--st-error-soft: rgba(248, 113, 113, 0.14);
|
--st-error-soft: rgba(248, 113, 113, 0.14);
|
||||||
--st-offline: #71717a;
|
--st-offline: #71717a;
|
||||||
--st-offline-soft: rgba(255, 255, 255, 0.04);
|
--st-offline-soft: rgba(255, 255, 255, 0.04);
|
||||||
--st-waiting: #f97316;
|
--st-waiting: #f59e0b;
|
||||||
--st-waiting-soft: rgba(249, 115, 22, 0.14);
|
--st-waiting-soft: rgba(245, 158, 11, 0.16);
|
||||||
--st-idle: #a1a1aa;
|
--st-idle: #a1a1aa;
|
||||||
--st-idle-soft: rgba(255, 255, 255, 0.05);
|
--st-idle-soft: rgba(255, 255, 255, 0.05);
|
||||||
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
|
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||||
@@ -105,8 +105,8 @@ html[data-theme="light"] {
|
|||||||
--st-error-soft: rgba(220, 38, 38, 0.1);
|
--st-error-soft: rgba(220, 38, 38, 0.1);
|
||||||
--st-offline: #73737c;
|
--st-offline: #73737c;
|
||||||
--st-offline-soft: rgba(0, 0, 0, 0.04);
|
--st-offline-soft: rgba(0, 0, 0, 0.04);
|
||||||
--st-waiting: #ea580c;
|
--st-waiting: #b45309;
|
||||||
--st-waiting-soft: rgba(234, 88, 12, 0.1);
|
--st-waiting-soft: rgba(180, 83, 9, 0.12);
|
||||||
--st-idle: #5c5c66;
|
--st-idle: #5c5c66;
|
||||||
--st-idle-soft: rgba(0, 0, 0, 0.04);
|
--st-idle-soft: rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
@@ -124,14 +124,14 @@ body {
|
|||||||
font: 14.5px/1.45 var(--font);
|
font: 14.5px/1.45 var(--font);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background:
|
background:
|
||||||
radial-gradient(900px 420px at 0% 0%, rgba(249, 115, 22, 0.07), transparent 55%),
|
radial-gradient(1100px 520px at 0% 0%, rgba(249, 115, 22, 0.14), transparent 58%),
|
||||||
var(--bg);
|
var(--bg);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
font-feature-settings: "cv11", "ss01";
|
font-feature-settings: "cv11", "ss01";
|
||||||
}
|
}
|
||||||
html[data-theme="light"] body {
|
html[data-theme="light"] body {
|
||||||
background:
|
background:
|
||||||
radial-gradient(900px 420px at 0% 0%, rgba(234, 88, 12, 0.06), transparent 55%),
|
radial-gradient(1100px 520px at 0% 0%, rgba(234, 88, 12, 0.11), transparent 58%),
|
||||||
var(--bg);
|
var(--bg);
|
||||||
}
|
}
|
||||||
.tabular, .num, .hero-main, .hero-side, .stat b, .chip, .count, .run-line-text, time {
|
.tabular, .num, .hero-main, .hero-side, .stat b, .chip, .count, .run-line-text, time {
|
||||||
@@ -298,6 +298,8 @@ html.shelf .hero-main,
|
|||||||
html.shelf [data-role="hero"],
|
html.shelf [data-role="hero"],
|
||||||
body.shelf .hero-main,
|
body.shelf .hero-main,
|
||||||
body.shelf [data-role="hero"] { font-size: 1.35rem; }
|
body.shelf [data-role="hero"] { font-size: 1.35rem; }
|
||||||
|
html.shelf article.machine.printing .hero-main,
|
||||||
|
body.shelf article.machine.printing .hero-main { font-size: 2.4rem; }
|
||||||
.mobile-nav .nav-shelf { display: none; }
|
.mobile-nav .nav-shelf { display: none; }
|
||||||
|
|
||||||
@media (max-height: 900px) {
|
@media (max-height: 900px) {
|
||||||
@@ -1015,7 +1017,7 @@ article.machine {
|
|||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 40%), var(--card);
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 40%), var(--card);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), var(--shadow);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1023,6 +1025,9 @@ article.machine {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.3s var(--ease), box-shadow 0.3s var(--ease), transform 0.2s var(--ease);
|
transition: border-color 0.3s var(--ease), box-shadow 0.3s var(--ease), transform 0.2s var(--ease);
|
||||||
}
|
}
|
||||||
|
html[data-theme="light"] article.machine {
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), var(--shadow);
|
||||||
|
}
|
||||||
article.machine::before {
|
article.machine::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1032,12 +1037,31 @@ article.machine::before {
|
|||||||
}
|
}
|
||||||
article.machine:hover {
|
article.machine:hover {
|
||||||
border-color: var(--line-strong);
|
border-color: var(--line-strong);
|
||||||
box-shadow: var(--shadow), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), var(--shadow), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
article.machine.printing { border-color: rgba(61, 206, 124, 0.35); box-shadow: var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.06), 0 18px 40px -24px rgba(61, 206, 124, 0.45); }
|
html[data-theme="light"] article.machine:hover {
|
||||||
article.machine.printing::before { background: linear-gradient(90deg, transparent, var(--st-printing), transparent); opacity: 0.7; }
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.95), var(--shadow), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||||
article.machine.printing:hover { border-color: rgba(61, 206, 124, 0.55); box-shadow: var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.12), 0 18px 40px -20px rgba(61, 206, 124, 0.55); }
|
}
|
||||||
|
article.machine.printing {
|
||||||
|
border-color: rgba(61, 206, 124, 0.35);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.06), 0 18px 40px -24px rgba(61, 206, 124, 0.45);
|
||||||
|
}
|
||||||
|
article.machine.printing::before {
|
||||||
|
background: linear-gradient(90deg, transparent, var(--st-printing), transparent);
|
||||||
|
opacity: 0.7;
|
||||||
|
animation: print-strip-breathe 2.8s var(--ease) infinite;
|
||||||
|
}
|
||||||
|
article.machine.printing:hover {
|
||||||
|
border-color: rgba(61, 206, 124, 0.55);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.12), 0 18px 40px -20px rgba(61, 206, 124, 0.55);
|
||||||
|
}
|
||||||
|
article.machine.printing .hero-main {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
article.machine.paused { border-color: rgba(251, 191, 36, 0.35); }
|
article.machine.paused { border-color: rgba(251, 191, 36, 0.35); }
|
||||||
article.machine.paused:hover { border-color: rgba(251, 191, 36, 0.55); }
|
article.machine.paused:hover { border-color: rgba(251, 191, 36, 0.55); }
|
||||||
article.machine.heating { border-color: rgba(251, 191, 36, 0.28); }
|
article.machine.heating { border-color: rgba(251, 191, 36, 0.28); }
|
||||||
@@ -1047,6 +1071,10 @@ article.machine.error::before { background: var(--st-error); opacity: 0.7; }
|
|||||||
article.machine.error:hover { border-color: rgba(248, 113, 113, 0.6); }
|
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; }
|
||||||
|
@keyframes print-strip-breathe {
|
||||||
|
0%, 100% { opacity: 0.45; }
|
||||||
|
50% { opacity: 0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
.machine-head {
|
.machine-head {
|
||||||
padding: 0.85rem 0.95rem 0.3rem;
|
padding: 0.85rem 0.95rem 0.3rem;
|
||||||
@@ -1480,7 +1508,7 @@ td select { width: auto; min-width: 8.5rem; padding: 0.3rem 0.45rem; min-height:
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse at 20% 0%, rgba(61, 206, 124, 0.12), transparent 45%),
|
radial-gradient(ellipse at 20% 0%, rgba(249, 115, 22, 0.16), transparent 50%),
|
||||||
var(--bg);
|
var(--bg);
|
||||||
}
|
}
|
||||||
.auth-card {
|
.auth-card {
|
||||||
@@ -1489,6 +1517,10 @@ td select { width: auto; min-width: 8.5rem; padding: 0.3rem 0.45rem; min-height:
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 1.4rem 1.35rem 1.5rem;
|
padding: 1.4rem 1.35rem 1.5rem;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), var(--shadow);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .auth-card {
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), var(--shadow);
|
||||||
}
|
}
|
||||||
.auth-card h1 { margin: 0 0 0.35rem; font-size: 1.45rem; }
|
.auth-card h1 { margin: 0 0 0.35rem; font-size: 1.45rem; }
|
||||||
.auth-brand {
|
.auth-brand {
|
||||||
@@ -2189,7 +2221,7 @@ body.desk-on main {
|
|||||||
.chip.complete { --c: var(--st-done); --cs: var(--st-done-soft); }
|
.chip.complete { --c: var(--st-done); --cs: var(--st-done-soft); }
|
||||||
.chip.error { --c: var(--st-error); --cs: var(--st-error-soft); }
|
.chip.error { --c: var(--st-error); --cs: var(--st-error-soft); }
|
||||||
.chip.offline { --c: var(--st-offline); --cs: var(--st-offline-soft); }
|
.chip.offline { --c: var(--st-offline); --cs: var(--st-offline-soft); }
|
||||||
.chip.waiting { --c: var(--st-waiting); --cs: transparent; box-shadow: inset 0 0 0 1px rgba(249, 115, 22, 0.45); }
|
.chip.waiting { --c: var(--st-waiting); --cs: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--st-waiting) 45%, transparent); }
|
||||||
.chip.cancelled { --c: var(--muted); --cs: var(--st-idle-soft); }
|
.chip.cancelled { --c: var(--muted); --cs: var(--st-idle-soft); }
|
||||||
.chip.sm { font-size: 0.6rem; padding: 0.12rem 0.45rem 0.12rem 0.38rem; gap: 0.3rem; }
|
.chip.sm { font-size: 0.6rem; padding: 0.12rem 0.45rem 0.12rem 0.38rem; gap: 0.3rem; }
|
||||||
.chip.sm::before { width: 0.36rem; height: 0.36rem; }
|
.chip.sm::before { width: 0.36rem; height: 0.36rem; }
|
||||||
@@ -2329,7 +2361,7 @@ body.desk-on main {
|
|||||||
.strip.stopped .strip-actions button:not(.ghost) { background: var(--accent); color: var(--accent-ink); }
|
.strip.stopped .strip-actions button:not(.ghost) { background: var(--accent); color: var(--accent-ink); }
|
||||||
.strip.stopped .strip-actions button.ghost { color: #fecaca; border-color: rgba(248, 113, 113, 0.4); }
|
.strip.stopped .strip-actions button.ghost { color: #fecaca; border-color: rgba(248, 113, 113, 0.4); }
|
||||||
.waiting-note { font-size: var(--t-xs); color: var(--muted); display: flex; align-items: center; gap: 0.35rem; }
|
.waiting-note { font-size: var(--t-xs); color: var(--muted); display: flex; align-items: center; gap: 0.35rem; }
|
||||||
.waiting-note b { color: var(--accent); }
|
.waiting-note b { color: var(--st-waiting); }
|
||||||
.desk-next {
|
.desk-next {
|
||||||
margin-top: 0.65rem;
|
margin-top: 0.65rem;
|
||||||
padding: 0.55rem 0.6rem;
|
padding: 0.55rem 0.6rem;
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{# Gemeinsames Druck-CSS für Spulen- und Lauf-Etiketten. Maße aus Einstellungen. #}
|
||||||
|
{% set w = etikett.width_mm if etikett is defined else 70 %}
|
||||||
|
{% set h = etikett.height_mm if etikett is defined else 40 %}
|
||||||
|
{% set qr = etikett.qr_mm if etikett is defined else 20 %}
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
|
||||||
|
color: #111;
|
||||||
|
background: #f3f3f3;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
.toolbar a, .toolbar button {
|
||||||
|
color: #111;
|
||||||
|
background: #f97316;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 650;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
|
||||||
|
.sheet {
|
||||||
|
width: {{ w }}mm;
|
||||||
|
min-height: {{ h }}mm;
|
||||||
|
margin: 1.2rem auto;
|
||||||
|
padding: 3.5mm;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr {{ qr }}mm;
|
||||||
|
gap: 2.5mm;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.meta { min-width: 0; }
|
||||||
|
.meta h1 {
|
||||||
|
margin: 0 0 2mm;
|
||||||
|
font-size: 4.2mm;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
|
||||||
|
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
.dots { display: inline-flex; vertical-align: middle; margin-right: 1.5mm; }
|
||||||
|
.dot {
|
||||||
|
width: 3.2mm;
|
||||||
|
height: 3.2mm;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 0.2mm solid rgba(0,0,0,0.25);
|
||||||
|
margin-left: -1mm;
|
||||||
|
}
|
||||||
|
.dot:first-child { margin-left: 0; }
|
||||||
|
.qr-wrap {
|
||||||
|
width: {{ qr }}mm;
|
||||||
|
height: {{ qr }}mm;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.qr-wrap canvas,
|
||||||
|
.qr-wrap img {
|
||||||
|
display: block;
|
||||||
|
width: {{ qr }}mm !important;
|
||||||
|
height: {{ qr }}mm !important;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
@page {
|
||||||
|
size: {{ w }}mm {{ h }}mm;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
body { background: #fff; }
|
||||||
|
.toolbar { display: none; }
|
||||||
|
.sheet {
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+199
-58
@@ -32,6 +32,9 @@
|
|||||||
<option value="">Alle Maschinen</option>
|
<option value="">Alle Maschinen</option>
|
||||||
<option value="kobra_3">Kobra 3</option>
|
<option value="kobra_3">Kobra 3</option>
|
||||||
<option value="kobra_s1">Kobra S1</option>
|
<option value="kobra_s1">Kobra S1</option>
|
||||||
|
<option value="kobra_4">Kobra 4</option>
|
||||||
|
<option value="anycubic_i3">Anycubic i3</option>
|
||||||
|
<option value="tenlog">Tenlog</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="file-filter-project">
|
<select id="file-filter-project">
|
||||||
<option value="">Alle Projekte</option>
|
<option value="">Alle Projekte</option>
|
||||||
@@ -116,7 +119,15 @@
|
|||||||
<div class="opt-body">
|
<div class="opt-body">
|
||||||
<div id="loaded-spools" class="loaded-spools"></div>
|
<div id="loaded-spools" class="loaded-spools"></div>
|
||||||
<p class="tiny">Verbrauch geht von der Rolle am Drucker. Wechsel unter <a href="/material">Material</a>.</p>
|
<p class="tiny">Verbrauch geht von der Rolle am Drucker. Wechsel unter <a href="/material">Material</a>.</p>
|
||||||
<label class="check"><input id="send-bed-level" type="checkbox"> Bettnetz vor dem Druck messen</label>
|
<label class="check" id="send-bed-level-wrap"><input id="send-bed-level" type="checkbox"> Bettnetz vor dem Druck messen</label>
|
||||||
|
<label class="check" id="send-marlin-wrap" hidden>
|
||||||
|
Start über USB
|
||||||
|
<select id="send-marlin-mode">
|
||||||
|
<option value="stream">Strom (Zeile für Zeile)</option>
|
||||||
|
<option value="sd">SD-Karte</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p class="tiny" id="send-marlin-hint" hidden>„Wenn frei“ startet immer als Strom.</p>
|
||||||
<label class="check"><input id="send-override" type="checkbox"> Auch senden, wenn die Maschine nicht passt</label>
|
<label class="check"><input id="send-override" type="checkbox"> Auch senden, wenn die Maschine nicht passt</label>
|
||||||
<label for="send-project">Projekt</label>
|
<label for="send-project">Projekt</label>
|
||||||
<select id="send-project"><option value="">keins</option></select>
|
<select id="send-project"><option value="">keins</option></select>
|
||||||
@@ -168,7 +179,12 @@
|
|||||||
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=10"></script>
|
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=10"></script>
|
||||||
<script src="{{ url_for('static', filename='sweep-viewer.js') }}?v=1"></script>
|
<script src="{{ url_for('static', filename='sweep-viewer.js') }}?v=1"></script>
|
||||||
<script>
|
<script>
|
||||||
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
|
const families = {
|
||||||
|
kobra_s1: "Kobra S1", kobra_3: "Kobra 3", kobra_4: "Kobra 4",
|
||||||
|
anycubic_i3: "Anycubic i3", tenlog: "Tenlog",
|
||||||
|
};
|
||||||
|
const MOONRAKER_FAMILIES = new Set(["kobra_3", "kobra_s1"]);
|
||||||
|
const MARLIN_FAMILIES = new Set(["anycubic_i3", "tenlog"]);
|
||||||
const ABLAUF_KEY = "grotti-ablauf-v2";
|
const ABLAUF_KEY = "grotti-ablauf-v2";
|
||||||
const SEND_KEY = "grotti-send-v1";
|
const SEND_KEY = "grotti-send-v1";
|
||||||
const LIB_KEY = "grotti-lib-v1";
|
const LIB_KEY = "grotti-lib-v1";
|
||||||
@@ -359,40 +375,8 @@ function spoolWarnText() {
|
|||||||
if (!file || need <= 0) return "";
|
if (!file || need <= 0) return "";
|
||||||
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 = fileTypesOf(file);
|
||||||
const fileProfiles = ((file.filaments || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean));
|
const fileProfiles = fileProfilesOf(file);
|
||||||
const PROFILE_VARIANTS = ["meta", "matte", "matt", "silk", "plus", "hf", "cf"];
|
|
||||||
function profileHasVariant(want) {
|
|
||||||
return PROFILE_VARIANTS.some((v) => new RegExp("\\b" + v + "\\b", "i").test(want || ""));
|
|
||||||
}
|
|
||||||
function profileBaseType(want) {
|
|
||||||
const m = String(want || "").toLowerCase().match(/\b(pla|petg|abs|asa|tpu|pa|pc|hips|pva)\b/);
|
|
||||||
return m ? m[1] : "";
|
|
||||||
}
|
|
||||||
function profileOk(spoolRows) {
|
|
||||||
if (!fileProfiles.length) return true;
|
|
||||||
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
|
|
||||||
const types = spoolRows.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
|
|
||||||
if (!names.length && !types.length) return true;
|
|
||||||
return fileProfiles.every((want) => {
|
|
||||||
if (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 !PROFILE_VARIANTS.includes(first);
|
|
||||||
})) return true;
|
|
||||||
// Basis-Profil (nur „… PLA“): Typ reicht, Marke egal — keine Meta/Matte-Spule
|
|
||||||
if (profileHasVariant(want)) return false;
|
|
||||||
const kind = profileBaseType(want);
|
|
||||||
if (!kind) return true;
|
|
||||||
return spoolRows.some((s) => {
|
|
||||||
const t = (s.material_type || "").toLowerCase();
|
|
||||||
const n = (s.name || "").toLowerCase();
|
|
||||||
return t === kind && !profileHasVariant(n);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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] || [];
|
||||||
@@ -414,7 +398,7 @@ function spoolWarnText() {
|
|||||||
});
|
});
|
||||||
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 (!profileOk(mappedSpools)) {
|
if (!profileOk(file, mappedSpools)) {
|
||||||
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
|
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
|
||||||
}
|
}
|
||||||
if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
|
if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
|
||||||
@@ -431,7 +415,7 @@ 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)) {
|
if (!profileOk(file, external)) {
|
||||||
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
|
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);
|
||||||
@@ -986,13 +970,116 @@ function familyMismatch(p, file) {
|
|||||||
return !!(file && file.printer_family && p.family && file.printer_family !== p.family);
|
return !!(file && file.printer_family && p.family && file.printer_family !== p.family);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTargets() {
|
const PROFILE_VARIANTS = ["meta", "matte", "matt", "silk", "plus", "hf", "cf"];
|
||||||
const box = document.getElementById("targets");
|
|
||||||
|
function fileProfilesOf(file) {
|
||||||
|
return ((file && file.filaments) || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileTypesOf(file) {
|
||||||
|
return ((file && file.filaments) || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileHasVariant(want) {
|
||||||
|
return PROFILE_VARIANTS.some((v) => new RegExp("\\b" + v + "\\b", "i").test(want || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileBaseType(want) {
|
||||||
|
const m = String(want || "").toLowerCase().match(/\b(pla|petg|abs|asa|tpu|pa|pc|hips|pva)\b/);
|
||||||
|
return m ? m[1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileOk(file, spoolRows) {
|
||||||
|
const fileProfiles = fileProfilesOf(file);
|
||||||
|
if (!fileProfiles.length) return true;
|
||||||
|
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
|
||||||
|
const types = spoolRows.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
|
||||||
|
if (!names.length && !types.length) return true;
|
||||||
|
return fileProfiles.every((want) => {
|
||||||
|
if (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 !PROFILE_VARIANTS.includes(first);
|
||||||
|
})) return true;
|
||||||
|
if (profileHasVariant(want)) return false;
|
||||||
|
const kind = profileBaseType(want);
|
||||||
|
if (!kind) return true;
|
||||||
|
return spoolRows.some((s) => {
|
||||||
|
const t = (s.material_type || "").toLowerCase();
|
||||||
|
const n = (s.name || "").toLowerCase();
|
||||||
|
return t === kind && !profileHasVariant(n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function materialFitsHost(file, host) {
|
||||||
|
const profiles = fileProfilesOf(file);
|
||||||
|
const types = fileTypesOf(file);
|
||||||
|
if (!profiles.length && !types.length) return true;
|
||||||
|
const onHost = spoolsOnHost(host);
|
||||||
|
if (!onHost.length) return false;
|
||||||
|
if (profiles.length) return profileOk(file, onHost);
|
||||||
|
const spoolTypes = onHost.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
|
||||||
|
if (!spoolTypes.length) return false;
|
||||||
|
return types.some((t) => spoolTypes.includes(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
function printerFitsFile(p, file, override) {
|
||||||
|
if (!p || !file) return false;
|
||||||
|
if (sel.tab === "ablauf" && p.family !== "kobra_3") return false;
|
||||||
|
if (familyMismatch(p, file) && !override) return false;
|
||||||
|
if (!materialFitsHost(file, p.host)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printerPickScore(p, file) {
|
||||||
|
let score = 0;
|
||||||
|
if (canStartNow(p)) score += 100;
|
||||||
|
else if (isFree(p)) score += 50;
|
||||||
|
else if (p.needs_clear) score += 20;
|
||||||
|
if (spoolsOnHost(p.host).length) score += 10;
|
||||||
|
if (file && file.printer_family && p.family === file.printer_family) score += 5;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestHostsForFile(file) {
|
||||||
|
if (!file) return new Set();
|
||||||
|
const override = !!(document.getElementById("send-override") || {}).checked;
|
||||||
|
const kept = [...sel.hosts].filter((host) => {
|
||||||
|
const p = printers.find((x) => x.host === host);
|
||||||
|
return printerFitsFile(p, file, override);
|
||||||
|
});
|
||||||
|
if (kept.length) return new Set(kept);
|
||||||
|
const candidates = printers.filter((p) => printerFitsFile(p, file, override));
|
||||||
|
candidates.sort((a, b) => printerPickScore(b, file) - printerPickScore(a, file)
|
||||||
|
|| String(a.name || "").localeCompare(String(b.name || ""), "de"));
|
||||||
|
if (!candidates.length) return new Set();
|
||||||
|
return new Set([candidates[0].host]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function noPrinterReason(file) {
|
||||||
|
if (!file || !printers.length) return "";
|
||||||
|
const override = !!(document.getElementById("send-override") || {}).checked;
|
||||||
|
const familyOk = printers.filter((p) => {
|
||||||
|
if (sel.tab === "ablauf" && p.family !== "kobra_3") return false;
|
||||||
|
return !familyMismatch(p, file) || override;
|
||||||
|
});
|
||||||
|
if (!familyOk.length) {
|
||||||
|
const want = families[file.printer_family] || file.printer_family || "diese Maschine";
|
||||||
|
return "Kein Drucker für " + want + ".";
|
||||||
|
}
|
||||||
|
if (familyOk.some((p) => materialFitsHost(file, p.host))) return "";
|
||||||
|
const need = fileProfilesOf(file).join("/") || fileTypesOf(file).join("/") || "Material";
|
||||||
|
return "Keine passende Rolle am Drucker (" + need + "). Unter Material zuweisen.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetsHtml() {
|
||||||
const file = selectedFile();
|
const file = selectedFile();
|
||||||
const override = document.getElementById("send-override").checked;
|
const override = document.getElementById("send-override").checked;
|
||||||
const nextMode = sel.tab === "send" && sel.mode === "next";
|
const nextMode = sel.tab === "send" && sel.mode === "next";
|
||||||
box.classList.toggle("dimmed", nextMode);
|
return printers.map((p) => {
|
||||||
box.innerHTML = printers.map((p) => {
|
|
||||||
const s = targetState(p);
|
const s = targetState(p);
|
||||||
const mismatch = familyMismatch(p, file);
|
const mismatch = familyMismatch(p, file);
|
||||||
const notK3 = sel.tab === "ablauf" && p.family !== "kobra_3";
|
const notK3 = sel.tab === "ablauf" && p.family !== "kobra_3";
|
||||||
@@ -1005,6 +1092,17 @@ function renderTargets() {
|
|||||||
<span class="row2 ${s.bad || off ? "bad" : ""}">${escapeHtml(why)}</span>
|
<span class="row2 ${s.bad || off ? "bad" : ""}">${escapeHtml(why)}</span>
|
||||||
</button>`;
|
</button>`;
|
||||||
}).join("") || '<div class="picked-empty">' + icon("printer") + '<span>Noch kein Drucker.</span><span class="tiny"><a href="/einstellungen">In Einstellungen anlegen</a></span></div>';
|
}).join("") || '<div class="picked-empty">' + icon("printer") + '<span>Noch kein Drucker.</span><span class="tiny"><a href="/einstellungen">In Einstellungen anlegen</a></span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTargets() {
|
||||||
|
const box = document.getElementById("targets");
|
||||||
|
const nextMode = sel.tab === "send" && sel.mode === "next";
|
||||||
|
box.classList.toggle("dimmed", nextMode);
|
||||||
|
const html = targetsHtml();
|
||||||
|
if (box._html !== html) {
|
||||||
|
box._html = html;
|
||||||
|
box.innerHTML = html;
|
||||||
|
}
|
||||||
document.getElementById("step-target").classList.toggle("ok", nextMode || selectedHosts().length > 0);
|
document.getElementById("step-target").classList.toggle("ok", nextMode || selectedHosts().length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1060,7 +1158,12 @@ 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 nach Sorte." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
|
let targetNote = "";
|
||||||
|
if (sel.mode === "next" && sel.tab === "send") targetNote = "Ziel wählt Grotti nach Sorte.";
|
||||||
|
else if (chosen.length > 1) targetNote = "Je Drucker ein eigener Auftrag.";
|
||||||
|
else if (file && !chosen.length && sel.mode === "this") targetNote = noPrinterReason(file);
|
||||||
|
targetHint.textContent = targetNote;
|
||||||
|
targetHint.classList.toggle("bad", !!(file && !chosen.length && sel.mode === "this" && targetNote));
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAblaufButton() {
|
function updateAblaufButton() {
|
||||||
@@ -1082,12 +1185,33 @@ function updateAblaufButton() {
|
|||||||
hint.classList.toggle("bad", !!problem);
|
hint.classList.toggle("bad", !!problem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncSendOptions() {
|
||||||
|
const chosen = selectedPrinters();
|
||||||
|
const moonraker = chosen.some((p) => MOONRAKER_FAMILIES.has(p.family));
|
||||||
|
const marlin = chosen.some((p) => MARLIN_FAMILIES.has(p.family));
|
||||||
|
const bedWrap = document.getElementById("send-bed-level-wrap");
|
||||||
|
const marlinWrap = document.getElementById("send-marlin-wrap");
|
||||||
|
const marlinHint = document.getElementById("send-marlin-hint");
|
||||||
|
if (bedWrap) {
|
||||||
|
bedWrap.hidden = chosen.length > 0 && !moonraker;
|
||||||
|
if (bedWrap.hidden) document.getElementById("send-bed-level").checked = false;
|
||||||
|
}
|
||||||
|
if (marlinWrap) marlinWrap.hidden = !marlin;
|
||||||
|
if (marlinHint) marlinHint.hidden = !marlin;
|
||||||
|
}
|
||||||
|
|
||||||
function updateOptionsSummary() {
|
function updateOptionsSummary() {
|
||||||
|
syncSendOptions();
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const hosts = selectedHosts();
|
const hosts = selectedHosts();
|
||||||
const loaded = hosts.reduce((n, h) => n + spoolsOnHost(h).length, 0);
|
const loaded = hosts.reduce((n, h) => n + spoolsOnHost(h).length, 0);
|
||||||
if (loaded) parts.push(loaded + (loaded === 1 ? " Spule" : " Spulen"));
|
if (loaded) parts.push(loaded + (loaded === 1 ? " Spule" : " Spulen"));
|
||||||
if (document.getElementById("send-bed-level").checked) parts.push("Bettnetz");
|
if (!document.getElementById("send-bed-level-wrap")?.hidden && document.getElementById("send-bed-level").checked) {
|
||||||
|
parts.push("Bettnetz");
|
||||||
|
}
|
||||||
|
if (!document.getElementById("send-marlin-wrap")?.hidden) {
|
||||||
|
parts.push(document.getElementById("send-marlin-mode").value === "sd" ? "SD-Karte" : "Strom");
|
||||||
|
}
|
||||||
if (document.getElementById("send-override").checked) parts.push("Maschine egal");
|
if (document.getElementById("send-override").checked) parts.push("Maschine egal");
|
||||||
const project = projects.find((p) => String(p.id) === document.getElementById("send-project")?.value);
|
const project = projects.find((p) => String(p.id) === document.getElementById("send-project")?.value);
|
||||||
if (project) parts.push(project.name);
|
if (project) parts.push(project.name);
|
||||||
@@ -1116,7 +1240,9 @@ function refreshFlow() {
|
|||||||
updateAblaufButton();
|
updateAblaufButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFile(id) {
|
function selectFile(id, opts) {
|
||||||
|
const keepHosts = !!(opts && opts.keepHosts);
|
||||||
|
const fromUrlHost = !!(opts && opts.fromUrlHost);
|
||||||
sel.fileId = id;
|
sel.fileId = id;
|
||||||
const file = selectedFile();
|
const file = selectedFile();
|
||||||
if (file && file.project_id && document.getElementById("send-project")) {
|
if (file && file.project_id && document.getElementById("send-project")) {
|
||||||
@@ -1127,6 +1253,10 @@ function selectFile(id) {
|
|||||||
saveAblaufMemory();
|
saveAblaufMemory();
|
||||||
if (sel.tab === "ablauf") renderAblauf();
|
if (sel.tab === "ablauf") renderAblauf();
|
||||||
}
|
}
|
||||||
|
if (file && !keepHosts && !fromUrlHost && !(sel.tab === "send" && sel.mode === "next")) {
|
||||||
|
sel.hosts = suggestHostsForFile(file);
|
||||||
|
saveSendMemory();
|
||||||
|
}
|
||||||
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();
|
||||||
scheduleToolpath(file, 0, 0);
|
scheduleToolpath(file, 0, 0);
|
||||||
@@ -1170,12 +1300,17 @@ async function load() {
|
|||||||
fillProjectFilter();
|
fillProjectFilter();
|
||||||
fillMaterialFilter();
|
fillMaterialFilter();
|
||||||
fillFolderFilter();
|
fillFolderFilter();
|
||||||
const deepProject = new URLSearchParams(location.search).get("project");
|
const deepParams = new URLSearchParams(location.search);
|
||||||
|
const deepProject = deepParams.get("project");
|
||||||
if (deepProject) {
|
if (deepProject) {
|
||||||
const pf = document.getElementById("file-filter-project");
|
const pf = document.getElementById("file-filter-project");
|
||||||
if (pf && [...pf.options].some((o) => o.value === deepProject)) pf.value = deepProject;
|
if (pf && [...pf.options].some((o) => o.value === deepProject)) pf.value = deepProject;
|
||||||
}
|
}
|
||||||
releaseRootForProject();
|
releaseRootForProject();
|
||||||
|
// Beim Laden: gemerkten Drucker nur behalten, wenn er zur Datei passt — außer Deep-Link ?host=
|
||||||
|
if (selectedFile() && !deepParams.get("host") && !(sel.tab === "send" && sel.mode === "next")) {
|
||||||
|
sel.hosts = suggestHostsForFile(selectedFile());
|
||||||
|
}
|
||||||
renderLibrary();
|
renderLibrary();
|
||||||
renderTabs();
|
renderTabs();
|
||||||
refreshFlow();
|
refreshFlow();
|
||||||
@@ -1323,18 +1458,22 @@ async function watchJobs(ids) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function postSenden(hosts, whenFree) {
|
async function postSenden(hosts, whenFree) {
|
||||||
return api("/senden", {
|
syncSendOptions();
|
||||||
method: "POST",
|
const marlinOn = !document.getElementById("send-marlin-wrap")?.hidden;
|
||||||
json: {
|
const payload = {
|
||||||
file_id: Number(sel.fileId),
|
file_id: Number(sel.fileId),
|
||||||
hosts,
|
hosts,
|
||||||
confirm: true,
|
confirm: true,
|
||||||
override_machine: document.getElementById("send-override").checked,
|
override_machine: document.getElementById("send-override").checked,
|
||||||
bed_leveling: document.getElementById("send-bed-level").checked,
|
bed_leveling: !document.getElementById("send-bed-level-wrap")?.hidden
|
||||||
ace_maps: collectAceMaps(),
|
&& document.getElementById("send-bed-level").checked,
|
||||||
when_free: whenFree,
|
ace_maps: collectAceMaps(),
|
||||||
},
|
when_free: whenFree,
|
||||||
});
|
};
|
||||||
|
if (marlinOn && !whenFree) {
|
||||||
|
payload.marlin_mode = document.getElementById("send-marlin-mode").value || "stream";
|
||||||
|
}
|
||||||
|
return api("/senden", { method: "POST", json: payload });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSend() {
|
async function doSend() {
|
||||||
@@ -1760,6 +1899,7 @@ document.querySelector(".tabs").addEventListener("click", (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("send-bed-level").addEventListener("change", updateOptionsSummary);
|
document.getElementById("send-bed-level").addEventListener("change", updateOptionsSummary);
|
||||||
|
document.getElementById("send-marlin-mode")?.addEventListener("change", updateOptionsSummary);
|
||||||
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);
|
||||||
|
|
||||||
@@ -1863,6 +2003,7 @@ document.getElementById("rescan").addEventListener("click", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.onQueueChanged = refreshPrinters;
|
window.onQueueChanged = refreshPrinters;
|
||||||
|
window.onDruckerLive = refreshPrinters;
|
||||||
loadSendMemory();
|
loadSendMemory();
|
||||||
loadAblaufMemory();
|
loadAblaufMemory();
|
||||||
load().catch((error) => toast(error.message || String(error), "bad"));
|
load().catch((error) => toast(error.message || String(error), "bad"));
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
<a href="#sec-auto"><svg class="ico"><use href="#i-zap"/></svg>Automatik</a>
|
<a href="#sec-auto"><svg class="ico"><use href="#i-zap"/></svg>Automatik</a>
|
||||||
<a href="#sec-slice" class="admin-only"><svg class="ico"><use href="#i-folder"/></svg>Slice</a>
|
<a href="#sec-slice" class="admin-only"><svg class="ico"><use href="#i-folder"/></svg>Slice</a>
|
||||||
<a href="#sec-fqdn" class="admin-only"><svg class="ico"><use href="#i-link"/></svg>Adresse</a>
|
<a href="#sec-fqdn" class="admin-only"><svg class="ico"><use href="#i-link"/></svg>Adresse</a>
|
||||||
|
<a href="#sec-etikett" class="admin-only"><svg class="ico"><use href="#i-tag"/></svg>Etiketten</a>
|
||||||
<a href="#sec-drucker" class="admin-only"><svg class="ico"><use href="#i-printer"/></svg>Drucker</a>
|
<a href="#sec-drucker" class="admin-only"><svg class="ico"><use href="#i-printer"/></svg>Drucker</a>
|
||||||
|
<a href="#sec-meldungen" class="admin-only"><svg class="ico"><use href="#i-alert"/></svg>Meldungen</a>
|
||||||
<a href="#sec-telegram" class="admin-only"><svg class="ico"><use href="#i-message"/></svg>Telegram</a>
|
<a href="#sec-telegram" class="admin-only"><svg class="ico"><use href="#i-message"/></svg>Telegram</a>
|
||||||
<a href="#sec-ntfy" class="admin-only"><svg class="ico"><use href="#i-bell"/></svg>ntfy</a>
|
<a href="#sec-ntfy" class="admin-only"><svg class="ico"><use href="#i-bell"/></svg>ntfy</a>
|
||||||
<a href="#sec-backup" class="admin-only"><svg class="ico"><use href="#i-hard-drive"/></svg>Backup</a>
|
<a href="#sec-backup" class="admin-only"><svg class="ico"><use href="#i-hard-drive"/></svg>Backup</a>
|
||||||
@@ -100,9 +102,34 @@
|
|||||||
<p id="fqdn-note"></p>
|
<p id="fqdn-note"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card admin-only" id="sec-etikett">
|
||||||
|
<h2>Etiketten</h2>
|
||||||
|
<p class="hint">Papierformat für Spulen- und Drucketiketten (Browser-Druck). Standard 70×40 mm. Der QR schrumpft auf flacheren Rollen mit.</p>
|
||||||
|
<div class="field" style="max-width:16rem">
|
||||||
|
<label for="etikett-preset">Voreinstellung</label>
|
||||||
|
<select id="etikett-preset">
|
||||||
|
<option value="">Eigene Maße</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="grid-2" style="max-width:20rem">
|
||||||
|
<div class="field">
|
||||||
|
<label for="etikett-w">Breite (mm)</label>
|
||||||
|
<input id="etikett-w" type="number" min="20" max="200" step="0.1" value="70">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="etikett-h">Höhe (mm)</label>
|
||||||
|
<input id="etikett-h" type="number" min="15" max="200" step="0.1" value="40">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions"><button type="button" id="etikett-save" hidden>Speichern</button></div>
|
||||||
|
<p id="etikett-note"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card admin-only" id="sec-drucker">
|
<section class="card admin-only" id="sec-drucker">
|
||||||
<h2>Drucker</h2>
|
<h2>Drucker</h2>
|
||||||
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom). Tippen öffnet den Dialog.</p>
|
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom). Tippen öffnet den Dialog.</p>
|
||||||
|
<p class="hint">USB (Anycubic i3, Tenlog): Schnittstelle statt IP, zum Beispiel <code>COM5</code> oder <code>COM5@250000</code>. Grotti startet den USB-Dienst dazu selbst und lässt ihn bei einem Neustart weiterlaufen.</p>
|
||||||
|
<p class="hint" id="marlin-line" hidden></p>
|
||||||
<div id="printer-list" class="printer-edit-cards"></div>
|
<div id="printer-list" class="printer-edit-cards"></div>
|
||||||
<form id="printer-add" class="grid-2 section-gap">
|
<form id="printer-add" class="grid-2 section-gap">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -110,7 +137,7 @@
|
|||||||
<input id="add-name" required placeholder="z. B. Kobra 3 Werkstatt">
|
<input id="add-name" required placeholder="z. B. Kobra 3 Werkstatt">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="add-host">Host / IP</label>
|
<label for="add-host" id="add-host-label">Host / IP</label>
|
||||||
<input id="add-host" required placeholder="10.10.1.60">
|
<input id="add-host" required placeholder="10.10.1.60">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -119,6 +146,9 @@
|
|||||||
<option value="">automatisch</option>
|
<option value="">automatisch</option>
|
||||||
<option value="kobra_3">Kobra 3</option>
|
<option value="kobra_3">Kobra 3</option>
|
||||||
<option value="kobra_s1">Kobra S1</option>
|
<option value="kobra_s1">Kobra S1</option>
|
||||||
|
<option value="kobra_4">Kobra 4</option>
|
||||||
|
<option value="anycubic_i3">Anycubic i3</option>
|
||||||
|
<option value="tenlog">Tenlog</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions" style="align-self:end"><button type="submit">Hinzufügen</button></div>
|
<div class="actions" style="align-self:end"><button type="submit">Hinzufügen</button></div>
|
||||||
@@ -126,6 +156,13 @@
|
|||||||
<p id="printer-note"></p>
|
<p id="printer-note"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card admin-only" id="sec-meldungen">
|
||||||
|
<h2>Problem-Meldungen</h2>
|
||||||
|
<p class="hint">Kopien von „Problem melden“. Offene Einträge hier erledigen. Issues landen beim Entwickler über den Empfänger auf git.gundel.dev.</p>
|
||||||
|
<div id="meldung-list" class="printer-edit-cards"></div>
|
||||||
|
<p id="meldung-admin-note" class="tiny"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card admin-only" id="sec-telegram">
|
<section class="card admin-only" id="sec-telegram">
|
||||||
<h2>Telegram</h2>
|
<h2>Telegram</h2>
|
||||||
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
|
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
|
||||||
@@ -272,11 +309,14 @@
|
|||||||
<input type="hidden" id="pe-id">
|
<input type="hidden" id="pe-id">
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<div class="field"><label for="pe-name">Name</label><input id="pe-name" required></div>
|
<div class="field"><label for="pe-name">Name</label><input id="pe-name" required></div>
|
||||||
<div class="field"><label for="pe-host">Host / IP</label><input id="pe-host" required></div>
|
<div class="field"><label for="pe-host" id="pe-host-label">Host / IP</label><input id="pe-host" required placeholder="10.10.1.60"></div>
|
||||||
<div class="field"><label for="pe-family">Familie</label>
|
<div class="field"><label for="pe-family">Familie</label>
|
||||||
<select id="pe-family">
|
<select id="pe-family">
|
||||||
<option value="kobra_3">Kobra 3</option>
|
<option value="kobra_3">Kobra 3</option>
|
||||||
<option value="kobra_s1">Kobra S1</option>
|
<option value="kobra_s1">Kobra S1</option>
|
||||||
|
<option value="kobra_4">Kobra 4</option>
|
||||||
|
<option value="anycubic_i3">Anycubic i3</option>
|
||||||
|
<option value="tenlog">Tenlog</option>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,7 +339,18 @@
|
|||||||
{% block script %}
|
{% block script %}
|
||||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
||||||
<script>
|
<script>
|
||||||
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
|
const families = {
|
||||||
|
kobra_3: "Kobra 3", kobra_s1: "Kobra S1", kobra_4: "Kobra 4",
|
||||||
|
anycubic_i3: "Anycubic i3", tenlog: "Tenlog", "": "—",
|
||||||
|
};
|
||||||
|
const USB_FAMILIES = new Set(["anycubic_i3", "tenlog"]);
|
||||||
|
|
||||||
|
function syncHostField(family, labelEl, inputEl, nameHint) {
|
||||||
|
const usb = USB_FAMILIES.has(family)
|
||||||
|
|| (!family && /tenlog|i3/i.test(nameHint || ""));
|
||||||
|
if (labelEl) labelEl.textContent = usb ? "Schnittstelle" : "Host / IP";
|
||||||
|
if (inputEl) inputEl.placeholder = usb ? "COM5 oder COM5@250000" : "10.10.1.60";
|
||||||
|
}
|
||||||
const isAdmin = document.querySelector('meta[name="user-role"]')?.content === "admin";
|
const isAdmin = document.querySelector('meta[name="user-role"]')?.content === "admin";
|
||||||
document.querySelectorAll(".admin-only").forEach((el) => { el.hidden = !isAdmin; });
|
document.querySelectorAll(".admin-only").forEach((el) => { el.hidden = !isAdmin; });
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
@@ -333,6 +384,7 @@ function openPrinterDialog(row) {
|
|||||||
document.getElementById("pe-name").value = row.name || "";
|
document.getElementById("pe-name").value = row.name || "";
|
||||||
document.getElementById("pe-host").value = row.host || "";
|
document.getElementById("pe-host").value = row.host || "";
|
||||||
document.getElementById("pe-family").value = row.family || "";
|
document.getElementById("pe-family").value = row.family || "";
|
||||||
|
syncHostField(row.family || "", document.getElementById("pe-host-label"), document.getElementById("pe-host"), row.name || "");
|
||||||
document.getElementById("pe-locked").checked = !!row.locked;
|
document.getElementById("pe-locked").checked = !!row.locked;
|
||||||
document.getElementById("pe-win-on").checked = win.enabled;
|
document.getElementById("pe-win-on").checked = win.enabled;
|
||||||
document.getElementById("pe-win-from").value = win.from;
|
document.getElementById("pe-win-from").value = win.from;
|
||||||
@@ -455,6 +507,62 @@ function renderTokens(rows) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMeldungen() {
|
||||||
|
const root = document.getElementById("meldung-list");
|
||||||
|
const note = document.getElementById("meldung-admin-note");
|
||||||
|
if (!root || !isAdmin) return;
|
||||||
|
try {
|
||||||
|
const rows = await api("/meldungen?status=offen");
|
||||||
|
if (!rows.length) {
|
||||||
|
root.innerHTML = '<p class="tiny">Keine offenen Meldungen.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.innerHTML = rows.map((row) => `
|
||||||
|
<article class="printer-edit-card">
|
||||||
|
<div class="grow">
|
||||||
|
<strong>#${row.id}</strong>
|
||||||
|
<span class="tiny">${escapeHtml((row.created_at || "").replace("T", " ").slice(0, 16))} · ${escapeHtml(row.username || "—")} · ${escapeHtml(row.page || "")}</span>
|
||||||
|
<p>${escapeHtml(row.text || "")}</p>
|
||||||
|
<p class="tiny">${row.relay_ok ? "Beim Entwickler angekommen" : ("Lokal: " + escapeHtml(row.relay_message || "Empfänger fehlte"))}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ghost" data-meldung-done="${row.id}">Erledigt</button>
|
||||||
|
</article>`).join("");
|
||||||
|
root.querySelectorAll("[data-meldung-done]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
await api("/meldungen/" + button.dataset.meldungDone + "/erledigt", { method: "POST", json: {} });
|
||||||
|
loadMeldungen();
|
||||||
|
} catch (error) {
|
||||||
|
if (note) note.textContent = error.message || String(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (note) note.textContent = error.message || String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMarlin(printers) {
|
||||||
|
const line = document.getElementById("marlin-line");
|
||||||
|
if (!line) return;
|
||||||
|
const usb = (printers || []).some((row) => row.family === "tenlog" || row.family === "anycubic_i3");
|
||||||
|
if (!usb) {
|
||||||
|
line.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const status = await api("/marlin-dienst");
|
||||||
|
line.hidden = false;
|
||||||
|
line.textContent = status.running
|
||||||
|
? "USB-Dienst läuft."
|
||||||
|
: (status.message || "USB-Dienst ist aus.");
|
||||||
|
} catch (err) {
|
||||||
|
line.hidden = false;
|
||||||
|
line.textContent = "USB-Dienst: " + (err.message || "keine Antwort");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
if (!isAdmin) return;
|
if (!isAdmin) return;
|
||||||
renderUsers(await api("/users"));
|
renderUsers(await api("/users"));
|
||||||
@@ -469,7 +577,11 @@ async function load() {
|
|||||||
api("/einstellungen"),
|
api("/einstellungen"),
|
||||||
api("/drucker?live=0"),
|
api("/drucker?live=0"),
|
||||||
]);
|
]);
|
||||||
if (isAdmin) renderPrinters(printers);
|
if (isAdmin) {
|
||||||
|
renderPrinters(printers);
|
||||||
|
loadMarlin(printers);
|
||||||
|
loadMeldungen();
|
||||||
|
}
|
||||||
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
|
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
|
||||||
const ar = document.getElementById("auto-retry");
|
const ar = document.getElementById("auto-retry");
|
||||||
if (ar) ar.checked = settings.auto_retry !== false;
|
if (ar) ar.checked = settings.auto_retry !== false;
|
||||||
@@ -480,6 +592,7 @@ async function load() {
|
|||||||
}
|
}
|
||||||
const fqdn = document.getElementById("fqdn");
|
const fqdn = document.getElementById("fqdn");
|
||||||
if (fqdn) fqdn.value = settings.fqdn || "";
|
if (fqdn) fqdn.value = settings.fqdn || "";
|
||||||
|
fillEtikett(settings.etikett || {});
|
||||||
const tgEnabled = document.getElementById("tg-enabled");
|
const tgEnabled = document.getElementById("tg-enabled");
|
||||||
if (tgEnabled) {
|
if (tgEnabled) {
|
||||||
tgEnabled.checked = !!(settings.telegram && settings.telegram.enabled);
|
tgEnabled.checked = !!(settings.telegram && settings.telegram.enabled);
|
||||||
@@ -643,6 +756,109 @@ document.getElementById("fqdn-save").addEventListener("click", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let etikettPresets = [];
|
||||||
|
|
||||||
|
function fillEtikett(etikett) {
|
||||||
|
const w = document.getElementById("etikett-w");
|
||||||
|
const h = document.getElementById("etikett-h");
|
||||||
|
const preset = document.getElementById("etikett-preset");
|
||||||
|
if (!w || !h || !preset) return;
|
||||||
|
etikettPresets = Array.isArray(etikett.presets) ? etikett.presets : etikettPresets;
|
||||||
|
const width = Number(etikett.width_mm) || 70;
|
||||||
|
const height = Number(etikett.height_mm) || 40;
|
||||||
|
w.value = String(width);
|
||||||
|
h.value = String(height);
|
||||||
|
const current = preset.value;
|
||||||
|
preset.innerHTML = '<option value="">Eigene Maße</option>';
|
||||||
|
etikettPresets.forEach((row) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = row.id;
|
||||||
|
option.textContent = row.label;
|
||||||
|
option.dataset.w = String(row.width_mm);
|
||||||
|
option.dataset.h = String(row.height_mm);
|
||||||
|
preset.appendChild(option);
|
||||||
|
});
|
||||||
|
const hit = etikettPresets.find(
|
||||||
|
(row) => Number(row.width_mm) === width && Number(row.height_mm) === height
|
||||||
|
);
|
||||||
|
preset.value = hit ? hit.id : (current && [...preset.options].some((o) => o.value === current) ? current : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncEtikettPresetFromFields() {
|
||||||
|
const preset = document.getElementById("etikett-preset");
|
||||||
|
const width = Number(document.getElementById("etikett-w").value);
|
||||||
|
const height = Number(document.getElementById("etikett-h").value);
|
||||||
|
const hit = etikettPresets.find(
|
||||||
|
(row) => Number(row.width_mm) === width && Number(row.height_mm) === height
|
||||||
|
);
|
||||||
|
if (preset) preset.value = hit ? hit.id : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("etikett-preset")?.addEventListener("change", () => {
|
||||||
|
const preset = document.getElementById("etikett-preset");
|
||||||
|
const option = preset.selectedOptions[0];
|
||||||
|
if (!option || !option.value) return;
|
||||||
|
document.getElementById("etikett-w").value = option.dataset.w || "70";
|
||||||
|
document.getElementById("etikett-h").value = option.dataset.h || "40";
|
||||||
|
document.getElementById("etikett-save")?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
["etikett-w", "etikett-h"].forEach((id) => {
|
||||||
|
document.getElementById(id)?.addEventListener("change", () => {
|
||||||
|
syncEtikettPresetFromFields();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("etikett-save").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const out = await api("/einstellungen", {
|
||||||
|
method: "PATCH",
|
||||||
|
json: {
|
||||||
|
etikett: {
|
||||||
|
width_mm: Number(document.getElementById("etikett-w").value) || 70,
|
||||||
|
height_mm: Number(document.getElementById("etikett-h").value) || 40,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fillEtikett(out.etikett || {});
|
||||||
|
const e = out.etikett || {};
|
||||||
|
showNote(
|
||||||
|
"etikett-note",
|
||||||
|
null,
|
||||||
|
"Gespeichert — " + (e.width_mm || 70) + " × " + (e.height_mm || 40) + " mm (QR " + (e.qr_mm || 20) + " mm)."
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
showNote("etikett-note", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function syncAddHost() {
|
||||||
|
syncHostField(
|
||||||
|
document.getElementById("add-family").value,
|
||||||
|
document.getElementById("add-host-label"),
|
||||||
|
document.getElementById("add-host"),
|
||||||
|
document.getElementById("add-name").value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
document.getElementById("add-family")?.addEventListener("change", syncAddHost);
|
||||||
|
document.getElementById("add-name")?.addEventListener("input", syncAddHost);
|
||||||
|
document.getElementById("pe-family")?.addEventListener("change", () => {
|
||||||
|
syncHostField(
|
||||||
|
document.getElementById("pe-family").value,
|
||||||
|
document.getElementById("pe-host-label"),
|
||||||
|
document.getElementById("pe-host"),
|
||||||
|
document.getElementById("pe-name").value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
document.getElementById("pe-name")?.addEventListener("input", () => {
|
||||||
|
syncHostField(
|
||||||
|
document.getElementById("pe-family").value,
|
||||||
|
document.getElementById("pe-host-label"),
|
||||||
|
document.getElementById("pe-host"),
|
||||||
|
document.getElementById("pe-name").value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("printer-add").addEventListener("submit", async (event) => {
|
document.getElementById("printer-add").addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
try {
|
try {
|
||||||
@@ -976,6 +1192,9 @@ document.getElementById("auto-retry")?.addEventListener("change", wireAutosave("
|
|||||||
document.getElementById(id)?.addEventListener("change", wireAutosave("watch-save", "watch-note"));
|
document.getElementById(id)?.addEventListener("change", wireAutosave("watch-save", "watch-note"));
|
||||||
});
|
});
|
||||||
document.getElementById("fqdn")?.addEventListener("change", wireAutosave("fqdn-save", "fqdn-note"));
|
document.getElementById("fqdn")?.addEventListener("change", wireAutosave("fqdn-save", "fqdn-note"));
|
||||||
|
["etikett-w", "etikett-h"].forEach((id) => {
|
||||||
|
document.getElementById(id)?.addEventListener("change", wireAutosave("etikett-save", "etikett-note"));
|
||||||
|
});
|
||||||
["tg-enabled", "tg-token", "tg-chat", "al-fertig", "al-fehler", "al-offline", "al-queue", "al-ablauf", "al-start"].forEach((id) => {
|
["tg-enabled", "tg-token", "tg-chat", "al-fertig", "al-fehler", "al-offline", "al-queue", "al-ablauf", "al-start"].forEach((id) => {
|
||||||
document.getElementById(id)?.addEventListener("change", wireAutosave("tg-save", "tg-note"));
|
document.getElementById(id)?.addEventListener("change", wireAutosave("tg-save", "tg-note"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,90 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
<style>
|
{% include "_etikett_style.html" %}
|
||||||
:root { color-scheme: light; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
|
|
||||||
color: #111;
|
|
||||||
background: #f3f3f3;
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
background: #1a1a1a;
|
|
||||||
color: #eee;
|
|
||||||
}
|
|
||||||
.toolbar a, .toolbar button {
|
|
||||||
color: #111;
|
|
||||||
background: #f97316;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.4rem 0.85rem;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 650;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
|
|
||||||
.sheet {
|
|
||||||
width: 70mm;
|
|
||||||
min-height: 40mm;
|
|
||||||
margin: 1.2rem auto;
|
|
||||||
padding: 3.5mm;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 20mm;
|
|
||||||
gap: 2.5mm;
|
|
||||||
align-items: center;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.meta { min-width: 0; }
|
|
||||||
.meta h1 {
|
|
||||||
margin: 0 0 2mm;
|
|
||||||
font-size: 4.2mm;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
|
|
||||||
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
||||||
.dots { display: inline-flex; vertical-align: middle; margin-right: 1.5mm; }
|
|
||||||
.dot {
|
|
||||||
width: 3.2mm;
|
|
||||||
height: 3.2mm;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 0.2mm solid rgba(0,0,0,0.25);
|
|
||||||
margin-left: -1mm;
|
|
||||||
}
|
|
||||||
.dot:first-child { margin-left: 0; }
|
|
||||||
.qr-wrap {
|
|
||||||
width: 20mm;
|
|
||||||
height: 20mm;
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.qr-wrap canvas {
|
|
||||||
display: block;
|
|
||||||
width: 20mm !important;
|
|
||||||
height: 20mm !important;
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
@media print {
|
|
||||||
body { background: #fff; }
|
|
||||||
.toolbar { display: none; }
|
|
||||||
.sheet {
|
|
||||||
margin: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
page-break-inside: avoid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
@@ -120,14 +37,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
||||||
<script>
|
<script>
|
||||||
|
const qrMm = {{ (etikett.qr_mm if etikett is defined else 20)|tojson }};
|
||||||
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
|
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
|
||||||
width: 80,
|
width: 80,
|
||||||
margin: 1,
|
margin: 1,
|
||||||
color: { dark: "#111111", light: "#ffffff" }
|
color: { dark: "#111111", light: "#ffffff" }
|
||||||
}, () => {
|
}, () => {
|
||||||
const node = document.getElementById("qr");
|
const node = document.getElementById("qr");
|
||||||
node.style.width = "20mm";
|
node.style.width = qrMm + "mm";
|
||||||
node.style.height = "20mm";
|
node.style.height = qrMm + "mm";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -4,83 +4,10 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
|
{% include "_etikett_style.html" %}
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: light; }
|
.sheet { page-break-after: always; }
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
|
|
||||||
color: #111;
|
|
||||||
background: #f3f3f3;
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
background: #1a1a1a;
|
|
||||||
color: #eee;
|
|
||||||
}
|
|
||||||
.toolbar a, .toolbar button {
|
|
||||||
color: #111;
|
|
||||||
background: #f97316;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.4rem 0.85rem;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 650;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
|
|
||||||
.sheet {
|
|
||||||
width: 70mm;
|
|
||||||
min-height: 40mm;
|
|
||||||
margin: 1.2rem auto;
|
|
||||||
padding: 3.5mm;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 20mm;
|
|
||||||
gap: 2.5mm;
|
|
||||||
align-items: center;
|
|
||||||
overflow: hidden;
|
|
||||||
page-break-after: always;
|
|
||||||
}
|
|
||||||
.sheet:last-child { page-break-after: auto; }
|
.sheet:last-child { page-break-after: auto; }
|
||||||
.meta { min-width: 0; }
|
|
||||||
.meta h1 {
|
|
||||||
margin: 0 0 2mm;
|
|
||||||
font-size: 4.2mm;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
|
|
||||||
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
||||||
.qr-wrap {
|
|
||||||
width: 20mm;
|
|
||||||
height: 20mm;
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.qr-wrap canvas,
|
|
||||||
.qr-wrap img {
|
|
||||||
display: block;
|
|
||||||
width: 20mm !important;
|
|
||||||
height: 20mm !important;
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
@media print {
|
|
||||||
body { background: #fff; }
|
|
||||||
.toolbar { display: none; }
|
|
||||||
.sheet {
|
|
||||||
margin: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
page-break-inside: avoid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -111,14 +38,15 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
||||||
<script>
|
<script>
|
||||||
|
const qrMm = {{ (etikett.qr_mm if etikett is defined else 20)|tojson }};
|
||||||
document.querySelectorAll("canvas.qr").forEach((node) => {
|
document.querySelectorAll("canvas.qr").forEach((node) => {
|
||||||
QRCode.toCanvas(node, node.dataset.url, {
|
QRCode.toCanvas(node, node.dataset.url, {
|
||||||
width: 80,
|
width: 80,
|
||||||
margin: 1,
|
margin: 1,
|
||||||
color: { dark: "#111111", light: "#ffffff" }
|
color: { dark: "#111111", light: "#ffffff" }
|
||||||
}, () => {
|
}, () => {
|
||||||
node.style.width = "20mm";
|
node.style.width = qrMm + "mm";
|
||||||
node.style.height = "20mm";
|
node.style.height = qrMm + "mm";
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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=73">
|
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=75">
|
||||||
<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>
|
||||||
@@ -107,6 +107,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-tools">
|
<div class="topbar-tools">
|
||||||
|
<button type="button" class="icon-btn" id="meldung-open" title="Problem melden" aria-label="Problem melden"><svg class="ico"><use href="#i-alert"/></svg></button>
|
||||||
<a class="icon-btn {{ 'on' if page == 'einstellungen' else '' }}" href="/einstellungen" title="Einstellungen" aria-label="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg></a>
|
<a class="icon-btn {{ 'on' if page == 'einstellungen' else '' }}" href="/einstellungen" title="Einstellungen" aria-label="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg></a>
|
||||||
<a class="icon-btn {{ 'on' if page == 'doku' else '' }}" href="/doku" title="Doku" aria-label="Doku"><svg class="ico"><use href="#i-book"/></svg></a>
|
<a class="icon-btn {{ 'on' if page == 'doku' else '' }}" href="/doku" title="Doku" aria-label="Doku"><svg class="ico"><use href="#i-book"/></svg></a>
|
||||||
{% if current_user %}
|
{% if current_user %}
|
||||||
@@ -163,6 +164,22 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
|
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<dialog id="meldung-dlg" class="abraeumen-dlg">
|
||||||
|
<form method="dialog" id="meldung-form" class="abraeumen-form">
|
||||||
|
<h2>Problem melden</h2>
|
||||||
|
<p class="tiny">Kurz beschreiben. Grotti hängt Version und Seite an und speichert eine Kopie hier. Zusätzlich geht die Meldung an den Entwickler.</p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="meldung-text">Was stimmt nicht?</label>
|
||||||
|
<textarea id="meldung-text" rows="5" required maxlength="4000" placeholder="z. B. Start auf dem Tenlog bricht ab…"></textarea>
|
||||||
|
</div>
|
||||||
|
<p class="tiny" id="meldung-error-hint" hidden></p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn-primary" id="meldung-send">Senden</button>
|
||||||
|
<button type="button" class="ghost" id="meldung-cancel">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
<p id="meldung-note" class="tiny"></p>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
<script src="{{ url_for('static', filename='api.js') }}?v=33"></script>
|
<script src="{{ url_for('static', filename='api.js') }}?v=33"></script>
|
||||||
<script src="{{ url_for('static', filename='ui.js') }}?v=6"></script>
|
<script src="{{ url_for('static', filename='ui.js') }}?v=6"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -172,6 +189,80 @@ async function doLogout() {
|
|||||||
}
|
}
|
||||||
document.getElementById("logout-btn")?.addEventListener("click", doLogout);
|
document.getElementById("logout-btn")?.addEventListener("click", doLogout);
|
||||||
document.getElementById("logout-btn-m")?.addEventListener("click", doLogout);
|
document.getElementById("logout-btn-m")?.addEventListener("click", doLogout);
|
||||||
|
|
||||||
|
(function meldungUi() {
|
||||||
|
const dlg = document.getElementById("meldung-dlg");
|
||||||
|
const form = document.getElementById("meldung-form");
|
||||||
|
const text = document.getElementById("meldung-text");
|
||||||
|
const errHint = document.getElementById("meldung-error-hint");
|
||||||
|
const note = document.getElementById("meldung-note");
|
||||||
|
if (!dlg || !form) return;
|
||||||
|
let lastError = "";
|
||||||
|
|
||||||
|
function openMeldung(prefill) {
|
||||||
|
note.textContent = "";
|
||||||
|
note.className = "tiny";
|
||||||
|
if (prefill) text.value = prefill;
|
||||||
|
if (lastError) {
|
||||||
|
errHint.hidden = false;
|
||||||
|
errHint.textContent = "Letzter Fehler: " + lastError.slice(0, 240);
|
||||||
|
} else {
|
||||||
|
errHint.hidden = true;
|
||||||
|
errHint.textContent = "";
|
||||||
|
}
|
||||||
|
dlg.showModal();
|
||||||
|
text.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openMeldung = openMeldung;
|
||||||
|
window.rememberUiError = (message) => {
|
||||||
|
lastError = String(message || "").slice(0, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("meldung-open")?.addEventListener("click", () => openMeldung(""));
|
||||||
|
document.getElementById("meldung-cancel")?.addEventListener("click", () => dlg.close());
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const button = document.getElementById("meldung-send");
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const desk = document.querySelector("#desk[data-host]:not([hidden])");
|
||||||
|
const host = desk?.dataset?.host || "";
|
||||||
|
const row = await api("/meldungen", {
|
||||||
|
method: "POST",
|
||||||
|
json: {
|
||||||
|
text: text.value,
|
||||||
|
page: location.pathname + location.search + location.hash,
|
||||||
|
printer_host: host,
|
||||||
|
error_text: lastError,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (row.relay_ok) {
|
||||||
|
toast("Meldung gespeichert und an den Entwickler geschickt.", "good");
|
||||||
|
} else {
|
||||||
|
toast("Lokal gespeichert. Beim Entwickler nicht angekommen: " + (row.relay_message || ""), "bad");
|
||||||
|
}
|
||||||
|
text.value = "";
|
||||||
|
lastError = "";
|
||||||
|
dlg.close();
|
||||||
|
} catch (error) {
|
||||||
|
note.textContent = error.message || String(error);
|
||||||
|
note.className = "tiny msg";
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("error", (event) => {
|
||||||
|
const message = event?.message || String(event?.error || "");
|
||||||
|
if (message) lastError = message.slice(0, 2000);
|
||||||
|
});
|
||||||
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
const reason = event?.reason;
|
||||||
|
const message = reason && reason.message ? reason.message : String(reason || "");
|
||||||
|
if (message) lastError = message.slice(0, 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
(function userMenuClose() {
|
(function userMenuClose() {
|
||||||
const menu = document.getElementById("user-menu");
|
const menu = document.getElementById("user-menu");
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
@@ -624,8 +715,8 @@ refreshJetzt();
|
|||||||
aufgaben: () => refreshJetzt(),
|
aufgaben: () => refreshJetzt(),
|
||||||
drucker: () => {
|
drucker: () => {
|
||||||
refreshJetzt();
|
refreshJetzt();
|
||||||
|
// Nur onDruckerLive — sonst zeichnen Wand und Dateien doppelt.
|
||||||
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
|
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
|
||||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<meta name="csrf" content="{{ csrf }}">
|
<meta name="csrf" content="{{ csrf }}">
|
||||||
<title>Anmelden · Grotti</title>
|
<title>Anmelden · Grotti</title>
|
||||||
<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="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=62">
|
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=75">
|
||||||
</head>
|
</head>
|
||||||
<body class="auth-body">
|
<body class="auth-body">
|
||||||
<main class="auth-card">
|
<main class="auth-card">
|
||||||
|
|||||||
@@ -270,12 +270,58 @@ function dots(colors) {
|
|||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
function slotLabel(slot) {
|
function printerFamily(host) {
|
||||||
|
return (printers.find((p) => p.host === host) || {}).family || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotLabel(slot, host) {
|
||||||
if (!slot) return "";
|
if (!slot) return "";
|
||||||
|
const family = printerFamily(host);
|
||||||
|
if (family === "tenlog") return "Kopf " + slot;
|
||||||
|
if (family === "anycubic_i3") return "am Drucker";
|
||||||
if (slot <= 4) return "ACE 1 · " + slot;
|
if (slot <= 4) return "ACE 1 · " + slot;
|
||||||
return "ACE 2 · " + (slot - 4);
|
return "ACE 2 · " + (slot - 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fillSlotOptions(host, keep) {
|
||||||
|
const select = document.getElementById("spool-slot");
|
||||||
|
const label = document.querySelector('label[for="spool-slot"]');
|
||||||
|
const current = keep != null ? String(keep) : select.value;
|
||||||
|
const family = printerFamily(host);
|
||||||
|
let options = [['', 'nicht im ACE']];
|
||||||
|
let tip = "ACE 1 = Plätze 1–4, ACE 2 = 5–8. Leer = externe Spule am Drucker. Ohne Drucker = Regal.";
|
||||||
|
if (!host) {
|
||||||
|
options = [['', 'Regal — kein Platz']];
|
||||||
|
tip = "Ohne Drucker liegt die Rolle im Regal.";
|
||||||
|
} else if (family === "tenlog") {
|
||||||
|
options = [['', 'kein Kopf'], ['1', 'Kopf 1'], ['2', 'Kopf 2']];
|
||||||
|
tip = "Tenlog: Kopf 1 oder Kopf 2. Leer = Rolle am Drucker ohne festen Kopf.";
|
||||||
|
} else if (family === "anycubic_i3") {
|
||||||
|
options = [['', 'am Drucker']];
|
||||||
|
tip = "Anycubic i3 hat keinen ACE — die Rolle liegt am Drucker.";
|
||||||
|
} else if (["kobra_3", "kobra_s1", "kobra_4"].includes(family)) {
|
||||||
|
options = [
|
||||||
|
['', 'nicht im ACE'],
|
||||||
|
['1', 'ACE 1 · Platz 1'], ['2', 'ACE 1 · Platz 2'], ['3', 'ACE 1 · Platz 3'], ['4', 'ACE 1 · Platz 4'],
|
||||||
|
['5', 'ACE 2 · Platz 1'], ['6', 'ACE 2 · Platz 2'], ['7', 'ACE 2 · Platz 3'], ['8', 'ACE 2 · Platz 4'],
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
options = [['', 'am Drucker']];
|
||||||
|
tip = "Platz nur bei ACE-Druckern.";
|
||||||
|
}
|
||||||
|
select.innerHTML = options.map(([value, text]) =>
|
||||||
|
`<option value="${escapeHtml(value)}">${escapeHtml(text)}</option>`
|
||||||
|
).join("");
|
||||||
|
if ([...select.options].some((o) => o.value === current)) select.value = current;
|
||||||
|
else select.value = "";
|
||||||
|
if (label) {
|
||||||
|
const tipNode = label.querySelector(".tip");
|
||||||
|
if (tipNode) tipNode.title = tip;
|
||||||
|
const title = family === "tenlog" ? "Kopf" : family === "anycubic_i3" ? "Lage" : "ACE-Platz";
|
||||||
|
label.childNodes[0].textContent = title + " ";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function printerName(host) {
|
function printerName(host) {
|
||||||
return (printers.find((p) => p.host === host) || {}).name || host || "";
|
return (printers.find((p) => p.host === host) || {}).name || host || "";
|
||||||
}
|
}
|
||||||
@@ -283,7 +329,7 @@ function printerName(host) {
|
|||||||
function ortLabel(spool) {
|
function ortLabel(spool) {
|
||||||
if (!spool.printer_host) return "Regal";
|
if (!spool.printer_host) return "Regal";
|
||||||
const name = printerName(spool.printer_host);
|
const name = printerName(spool.printer_host);
|
||||||
if (spool.ace_slot) return name + " · " + slotLabel(spool.ace_slot);
|
if (spool.ace_slot) return name + " · " + slotLabel(spool.ace_slot, spool.printer_host);
|
||||||
return name + " · extern";
|
return name + " · extern";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,7 +602,7 @@ function fill(spool) {
|
|||||||
document.getElementById("spool-chargen").value = spool ? (spool.chargen_nr || spool.code || "") : "";
|
document.getElementById("spool-chargen").value = spool ? (spool.chargen_nr || spool.code || "") : "";
|
||||||
document.getElementById("spool-hersteller").value = spool ? (spool.hersteller_charge || "") : "";
|
document.getElementById("spool-hersteller").value = spool ? (spool.hersteller_charge || "") : "";
|
||||||
document.getElementById("spool-printer").value = spool ? spool.printer_host : "";
|
document.getElementById("spool-printer").value = spool ? spool.printer_host : "";
|
||||||
document.getElementById("spool-slot").value = spool && spool.ace_slot ? String(spool.ace_slot) : "";
|
fillSlotOptions(spool ? spool.printer_host : "", spool && spool.ace_slot ? String(spool.ace_slot) : "");
|
||||||
document.getElementById("spool-price").value = spool ? spool.price_per_kg : 0;
|
document.getElementById("spool-price").value = spool ? spool.price_per_kg : 0;
|
||||||
document.getElementById("spool-left").value = spool
|
document.getElementById("spool-left").value = spool
|
||||||
? Math.round((Number(spool.remaining_g) || 0) * 10) / 10
|
? Math.round((Number(spool.remaining_g) || 0) * 10) / 10
|
||||||
@@ -676,6 +722,7 @@ function fillSelects() {
|
|||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
select.value = current;
|
select.value = current;
|
||||||
|
fillSlotOptions(select.value);
|
||||||
|
|
||||||
const materialSelect = document.getElementById("spool-material");
|
const materialSelect = document.getElementById("spool-material");
|
||||||
const currentMaterial = materialSelect.value;
|
const currentMaterial = materialSelect.value;
|
||||||
@@ -905,6 +952,7 @@ document.getElementById("sorten-close").addEventListener("click", closeSortenPan
|
|||||||
|
|
||||||
document.getElementById("spool-material").addEventListener("change", () => applyMaterial(selectedMaterial(), true));
|
document.getElementById("spool-material").addEventListener("change", () => applyMaterial(selectedMaterial(), true));
|
||||||
document.getElementById("spool-printer").addEventListener("change", () => {
|
document.getElementById("spool-printer").addEventListener("change", () => {
|
||||||
|
fillSlotOptions(document.getElementById("spool-printer").value);
|
||||||
const material = selectedMaterial();
|
const material = selectedMaterial();
|
||||||
if (material) document.getElementById("spool-vol").value = volumeOf(material);
|
if (material) document.getElementById("spool-vol").value = volumeOf(material);
|
||||||
showHint();
|
showHint();
|
||||||
@@ -1134,8 +1182,7 @@ load().then(() => {
|
|||||||
document.getElementById("spool-new")?.click?.();
|
document.getElementById("spool-new")?.click?.();
|
||||||
const hostField = document.getElementById("spool-printer");
|
const hostField = document.getElementById("spool-printer");
|
||||||
if (hostField) hostField.value = hostHint;
|
if (hostField) hostField.value = hostHint;
|
||||||
const slotField = document.getElementById("spool-slot");
|
fillSlotOptions(hostHint, slotHint ? String(slotHint) : "");
|
||||||
if (slotField && slotHint) slotField.value = String(slotHint);
|
|
||||||
document.getElementById("spool-name")?.scrollIntoView({ behavior: "smooth", block: "center" });
|
document.getElementById("spool-name")?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
toast("Drucker und Platz vorausgefüllt — Rolle wählen und speichern.");
|
toast("Drucker und Platz vorausgefüllt — Rolle wählen und speichern.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,11 +81,26 @@ function shortRunName(filename) {
|
|||||||
return name || (filename || "").split("/").pop() || "";
|
return name || (filename || "").split("/").pop() || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
let lastRunsKey = "";
|
||||||
|
|
||||||
|
function runsKey(rows) {
|
||||||
|
return (rows || []).map((run) => [
|
||||||
|
run.id, run.state, run.duration_s, run.pause_s, run.weight_used_g,
|
||||||
|
run.verdict, run.spool_id, run.merge_with_id, run.chargen_nr,
|
||||||
|
].join(":")).join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(force) {
|
||||||
fillPrinterFilter();
|
fillPrinterFilter();
|
||||||
const body = document.getElementById("run-rows");
|
const body = document.getElementById("run-rows");
|
||||||
body.innerHTML = "";
|
|
||||||
const shown = filteredRuns();
|
const shown = filteredRuns();
|
||||||
|
const key = runsKey(shown) + "|" + runPage + "|"
|
||||||
|
+ (document.getElementById("run-filter-printer")?.value || "")
|
||||||
|
+ "|" + (document.getElementById("run-filter-verdict")?.value || "")
|
||||||
|
+ "|" + (document.getElementById("run-filter-spool")?.value || "");
|
||||||
|
if (!force && key === lastRunsKey && body.children.length) return;
|
||||||
|
lastRunsKey = key;
|
||||||
|
body.innerHTML = "";
|
||||||
if (!runs.length) {
|
if (!runs.length) {
|
||||||
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("list") + '<p>Noch kein Lauf.</p><p class="tiny">„Läufe holen“ liest die Drucker aus.</p></div></td></tr>';
|
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("list") + '<p>Noch kein Lauf.</p><p class="tiny">„Läufe holen“ liest die Drucker aus.</p></div></td></tr>';
|
||||||
renderPager(document.getElementById("run-pager"), null, () => {});
|
renderPager(document.getElementById("run-pager"), null, () => {});
|
||||||
@@ -324,13 +339,13 @@ function render() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load(resetPage) {
|
||||||
[runs, spools] = await Promise.all([api("/laeufe?limit=500"), api("/spulen")]);
|
[runs, spools] = await Promise.all([api("/laeufe?limit=500"), api("/spulen")]);
|
||||||
runPage = 1;
|
if (resetPage !== false) runPage = 1;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
window.onDruckerLive = () => { load().catch(() => {}); };
|
window.onDruckerLive = () => { load(false).catch(() => {}); };
|
||||||
window.onQueueChanged = () => { load().catch(() => {}); };
|
window.onQueueChanged = () => { load(false).catch(() => {}); };
|
||||||
|
|
||||||
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
|
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
|
||||||
document.getElementById(id).addEventListener("change", () => { runPage = 1; render(); });
|
document.getElementById(id).addEventListener("change", () => { runPage = 1; render(); });
|
||||||
|
|||||||
+21
-8
@@ -266,7 +266,13 @@ function tempbarHtml(label, value, target, scale, iconName) {
|
|||||||
|
|
||||||
function tempbarsHtml(printer, state) {
|
function tempbarsHtml(printer, state) {
|
||||||
if (state === "offline") return "";
|
if (state === "offline") return "";
|
||||||
const nozzle = tempbarHtml("Düse", printer.nozzle, printer.nozzle_target, 300, "nozzle");
|
const tools = Array.isArray(printer.tools) ? printer.tools : [];
|
||||||
|
const nozzle = tools.length > 1
|
||||||
|
? tools.map((tool) => tempbarHtml(
|
||||||
|
tool.name || ("Kopf " + (Number(tool.index) + 1)),
|
||||||
|
tool.temp, tool.target, 300, "nozzle",
|
||||||
|
)).join("")
|
||||||
|
: tempbarHtml("Düse", printer.nozzle, printer.nozzle_target, 300, "nozzle");
|
||||||
const bed = tempbarHtml("Bett", printer.bed, printer.bed_target, 110, "bed");
|
const bed = tempbarHtml("Bett", printer.bed, printer.bed_target, 110, "bed");
|
||||||
const chamber = tempbarHtml("Bauraum", printer.chamber, printer.chamber_target, 80, "chamber");
|
const chamber = tempbarHtml("Bauraum", printer.chamber, printer.chamber_target, 80, "chamber");
|
||||||
if (!nozzle && !bed && !chamber) return "";
|
if (!nozzle && !bed && !chamber) return "";
|
||||||
@@ -443,8 +449,10 @@ function spoolHex(spool) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function aceSlotLabel(slot) {
|
function aceSlotLabel(slot, family) {
|
||||||
const n = Number(slot);
|
const n = Number(slot);
|
||||||
|
if (family === "tenlog") return "Kopf " + n;
|
||||||
|
if (family === "anycubic_i3") return "am Drucker";
|
||||||
if (n <= 4) return "ACE 1 · " + n;
|
if (n <= 4) return "ACE 1 · " + n;
|
||||||
return "ACE 2 · " + (n - 4);
|
return "ACE 2 · " + (n - 4);
|
||||||
}
|
}
|
||||||
@@ -489,7 +497,7 @@ function aceSteerHtml(printer) {
|
|||||||
const kind = spool.material_type || "—";
|
const kind = spool.material_type || "—";
|
||||||
bits.push(`<div class="steer-slot-row">
|
bits.push(`<div class="steer-slot-row">
|
||||||
<span class="dot" style="background:${escapeHtml(hex)}"></span>
|
<span class="dot" style="background:${escapeHtml(hex)}"></span>
|
||||||
<span class="grow"><b>${escapeHtml(aceSlotLabel(slot))}</b> · ${escapeHtml(kind)} · ${escapeHtml(spool.name || "")}</span>
|
<span class="grow"><b>${escapeHtml(aceSlotLabel(slot, printer.family))}</b> · ${escapeHtml(kind)} · ${escapeHtml(spool.name || "")}</span>
|
||||||
<button type="button" class="ghost" data-steer-go="slot_setzen" data-ace-slot="${slot}">an den Drucker</button>
|
<button type="button" class="ghost" data-steer-go="slot_setzen" data-ace-slot="${slot}">an den Drucker</button>
|
||||||
</div>`);
|
</div>`);
|
||||||
});
|
});
|
||||||
@@ -499,6 +507,7 @@ function aceSteerHtml(printer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function steerPanelHtml(printer) {
|
function steerPanelHtml(printer) {
|
||||||
|
if (printer.family === "tenlog" || printer.family === "anycubic_i3") return "";
|
||||||
const n = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
|
const n = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
|
||||||
const b = printer.bed_target != null ? Math.round(printer.bed_target) : "";
|
const b = printer.bed_target != null ? Math.round(printer.bed_target) : "";
|
||||||
const sp = typeof printer.speed_factor === "number" ? Math.round(printer.speed_factor * 100) : 100;
|
const sp = typeof printer.speed_factor === "number" ? Math.round(printer.speed_factor * 100) : 100;
|
||||||
@@ -1580,7 +1589,7 @@ function sortPrinters(printers) {
|
|||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (!libraryFiles.length) {
|
if (!libraryFiles.length) {
|
||||||
try { libraryFiles = await api("/dateien"); } catch (error) { libraryFiles = []; }
|
api("/dateien").then((rows) => { libraryFiles = rows || []; }).catch(() => {});
|
||||||
}
|
}
|
||||||
if (!wallSpools.length || deskHost) {
|
if (!wallSpools.length || deskHost) {
|
||||||
try { wallSpools = await api("/spulen"); } catch (error) { /* behalten */ }
|
try { wallSpools = await api("/spulen"); } catch (error) { /* behalten */ }
|
||||||
@@ -1590,8 +1599,8 @@ async function refresh() {
|
|||||||
printerCache.clear();
|
printerCache.clear();
|
||||||
for (const printer of all) printerCache.set(printer.host, printer);
|
for (const printer of all) printerCache.set(printer.host, printer);
|
||||||
deskHostsOrder = printers.map((p) => p.host);
|
deskHostsOrder = printers.map((p) => p.host);
|
||||||
document.getElementById("summary").innerHTML = summaryText(all) || "keine Drucker";
|
setHtml(document.getElementById("summary"), summaryText(all) || "keine Drucker");
|
||||||
document.getElementById("when").textContent = new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
setText(document.getElementById("when"), new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }));
|
||||||
|
|
||||||
if (!all.length) {
|
if (!all.length) {
|
||||||
lastFleetKey = "";
|
lastFleetKey = "";
|
||||||
@@ -1614,7 +1623,6 @@ async function refresh() {
|
|||||||
bucket.appendChild(article);
|
bucket.appendChild(article);
|
||||||
} else {
|
} else {
|
||||||
patchCard(article, printer);
|
patchCard(article, printer);
|
||||||
if (article.parentElement !== bucket) bucket.appendChild(article);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [host, article] of [...cardNodes.entries()]) {
|
for (const [host, article] of [...cardNodes.entries()]) {
|
||||||
@@ -1628,9 +1636,14 @@ async function refresh() {
|
|||||||
? document.querySelector("#cards [data-fleet]")
|
? document.querySelector("#cards [data-fleet]")
|
||||||
: document.querySelector(`#cards [data-fleet="${CSS.escape(section.label)}"]`);
|
: document.querySelector(`#cards [data-fleet="${CSS.escape(section.label)}"]`);
|
||||||
if (!bucket) continue;
|
if (!bucket) continue;
|
||||||
|
let marker = bucket.firstChild;
|
||||||
for (const printer of section.printers) {
|
for (const printer of section.printers) {
|
||||||
const article = cardNodes.get(printer.host);
|
const article = cardNodes.get(printer.host);
|
||||||
if (article) bucket.appendChild(article);
|
if (!article) continue;
|
||||||
|
if (article !== marker) {
|
||||||
|
bucket.insertBefore(article, marker);
|
||||||
|
}
|
||||||
|
marker = article.nextSibling;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user