Grotti 3.5.0: Pagination, Logging, Deb-Paket, Makro-Doku, PLA-Match-Fix.
This commit is contained in:
1 parent
4ffe1dcfc6
commit
f1f79c2ce2
38 files changed
+1062
-75
No files matched your search
@@ -10,3 +10,8 @@ slice/*
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
_tmp_*
|
_tmp_*
|
||||||
|
%s
|
||||||
|
|
||||||
|
# gebaute .deb (Skript legt sie hier ab)
|
||||||
|
packaging/dist/*
|
||||||
|
!packaging/dist/.gitkeep
|
||||||
+17
-1
@@ -4,9 +4,24 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.5.0] – 2026-10-19
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Doku:** interne Makro-Referenz `docs/internal/Makros-Kobra.md` (Nicht-tun, Vanilla-Klipper-Alltag, ACE, LeviQ/Wipe für S1 und K3).
|
||||||
|
- **Pagination:** Dateien, Protokoll, Material, Aufträge, Warteschlange, Aktivität, Projekte — max. 40 Zeilen pro Seite (Zurück/Weiter).
|
||||||
|
- **Logging:** `log_level` in der Config; Kernpfade schreiben nach stdout (unter systemd → `journalctl -u grotti`).
|
||||||
|
- **Debian-Paket:** `packaging/build-deb.sh` baut `.deb` mit venv, systemd-Unit und PostgreSQL-`postinst` (Peer-Auth). Anleitung: `docs/Anleitung-Installation-Pi.md`.
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|
||||||
- **README:** Banner, Badges, Feature-Übersicht und Community-Screenshots oben; Setup/API darunter.
|
- **Config:** `GROTTI_CONFIG` bzw. `/etc/grotti/config.json` für Paket-Install; sonst Repo-`config.json`.
|
||||||
|
- **README:** Banner, Badges, Feature-Übersicht und Community-Screenshots oben; Setup/API darunter; Pi-`.deb`-Hinweis.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Queue „braucht Eryone PLA Lite“ trotz PLA-Spule:** Basis-Profil (ohne Meta/Matte/Silk) matcht nach Typ; Slice-Hex-Farbe (`#26A69A`) blockiert Lagerfarbe „Grau“ nicht mehr.
|
||||||
|
- **Dateien Info-Popup:** Details-Popup wird nicht mehr vom Bibliothek-Scroll abgeschnitten; Position fixed mit Flip nach oben, besserer Kontrast.
|
||||||
|
|
||||||
## [3.4.0] – 2026-10-18
|
## [3.4.0] – 2026-10-18
|
||||||
|
|
||||||
@@ -16,6 +31,7 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
|||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|
||||||
|
- **Queue „druckt schon“ bei Folgejobs:** Einfach-Druck bleibt `running` bis Moonraker fertig — nicht mehr sofort `done` nach Start (sonst startete der nächste Job zu früh).
|
||||||
- **Bedarf „443 g fehlen“ trotz voller Spulen:** Slice-Farbe `#26A69A` matchte nicht Lagerfarbe „Grau“ → Rest 0. Bei Hex-Farbe zählt jetzt der Rest gleicher Sorte; archivierte Spulen nicht.
|
- **Bedarf „443 g fehlen“ trotz voller Spulen:** Slice-Farbe `#26A69A` matchte nicht Lagerfarbe „Grau“ → Rest 0. Bei Hex-Farbe zählt jetzt der Rest gleicher Sorte; archivierte Spulen nicht.
|
||||||
- **Falsches „Material nachlegen“ bei manueller Pause:** Anycubic meldet `active_filament.empty=true` auch ohne Runout (`tool`/`gate` = −1). Heuristik nur noch bei Meldung/`reason_for_pause`/10107, aktivem Tool + differenziertem `gate_status`, oder G-code-Store.
|
- **Falsches „Material nachlegen“ bei manueller Pause:** Anycubic meldet `active_filament.empty=true` auch ohne Runout (`tool`/`gate` = −1). Heuristik nur noch bei Meldung/`reason_for_pause`/10107, aktivem Tool + differenziertem `gate_status`, oder G-code-Store.
|
||||||
- **gklib 10111 nach Einfarbig-Upload:** Orca-Zeile `; filament_colour_type` wird beim Senden entfernt (nur Einfarbig; Multicolor unangetastet).
|
- **gklib 10111 nach Einfarbig-Upload:** Orca-Zeile `; filament_colour_type` wird beim Senden entfernt (nur Einfarbig; Multicolor unangetastet).
|
||||||
|
|||||||
@@ -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.4.0-e85d04?style=flat-square" alt="Version 3.4.0"></a>
|
<a href="https://git.gundel.dev/dg/grotti"><img src="https://img.shields.io/badge/Version-3.5.0-e85d04?style=flat-square" alt="Version 3.5.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">
|
||||||
@@ -80,6 +80,18 @@ copy config.example.json config.json
|
|||||||
|
|
||||||
Dann <http://127.0.0.1:8770/>. Bedienung unter **Doku** in der App (`docs/Anleitung-*.md`).
|
Dann <http://127.0.0.1:8770/>. Bedienung unter **Doku** in der App (`docs/Anleitung-*.md`).
|
||||||
|
|
||||||
|
### Raspberry Pi (`.deb`)
|
||||||
|
|
||||||
|
Auf dem Pi (arm64 / Bookworm) bauen und installieren — PostgreSQL und systemd-Dienst werden mit eingerichtet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packaging/build-deb.sh
|
||||||
|
sudo apt install ./packaging/dist/grotti_*_arm64.deb
|
||||||
|
journalctl -u grotti -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Details: [docs/Anleitung-Installation-Pi.md](docs/Anleitung-Installation-Pi.md). Öffentliches apt-Repo folgt später.
|
||||||
|
|
||||||
`config.json` und `data/` bleiben lokal (nicht im Git). Drucker zuerst in `drucker.json`, danach unter Einstellungen.
|
`config.json` und `data/` bleiben lokal (nicht im Git). Drucker zuerst in `drucker.json`, danach unter Einstellungen.
|
||||||
|
|
||||||
| Schlüssel | Bedeutung |
|
| Schlüssel | Bedeutung |
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"token": "",
|
"token": "",
|
||||||
"secret_key": "",
|
"secret_key": "",
|
||||||
"fqdn": "",
|
"fqdn": "",
|
||||||
|
"log_level": "INFO",
|
||||||
"auto_queue": true,
|
"auto_queue": true,
|
||||||
"auto_retry": true,
|
"auto_retry": true,
|
||||||
"oauth": {
|
"oauth": {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Installation auf dem Raspberry Pi
|
||||||
|
|
||||||
|
Stand **18.10.2026**. Phase 1: Installation per `.deb`-Datei. Ein öffentliches apt-Repository kommt später.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Raspberry Pi OS **Bookworm** (64-Bit / arm64), Pi 4 oder 5 empfohlen
|
||||||
|
- Netz zu den Druckern (Moonraker/Rinkhals)
|
||||||
|
- ca. 1 GB freier Speicher für Paket + PostgreSQL
|
||||||
|
|
||||||
|
## Paket bauen (auf dem Pi oder arm64-Host)
|
||||||
|
|
||||||
|
Im geklonten Repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y python3-venv rsync dpkg-dev
|
||||||
|
bash packaging/build-deb.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergebnis: `packaging/dist/grotti_<version>_arm64.deb`
|
||||||
|
|
||||||
|
## Installieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ./packaging/dist/grotti_*_arm64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
Das zieht **PostgreSQL** mit, legt Benutzer `grotti`, Datenbank `grotti` und den Dienst an.
|
||||||
|
|
||||||
|
- Oberfläche: `http://<pi-ip>:8770/`
|
||||||
|
- Config: `/etc/grotti/config.json` (wird bei Updates nicht überschrieben)
|
||||||
|
- Daten: `/var/lib/grotti/`
|
||||||
|
- Slice-Ordner: `/var/lib/grotti/slice` (unter Einstellungen änderbar)
|
||||||
|
|
||||||
|
Beim ersten Aufruf Admin-Konto anlegen (wie gewohnt).
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Logs gehen nach **journald**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u grotti -f
|
||||||
|
journalctl -u grotti --since "1h ago"
|
||||||
|
journalctl -u grotti -n 200
|
||||||
|
```
|
||||||
|
|
||||||
|
Log-Level in `/etc/grotti/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"log_level": "INFO"
|
||||||
|
```
|
||||||
|
|
||||||
|
Werte: `DEBUG`, `INFO`, `WARNING`, `ERROR`. Danach:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart grotti
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dienst
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status grotti
|
||||||
|
sudo systemctl restart grotti
|
||||||
|
sudo systemctl stop grotti
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update
|
||||||
|
|
||||||
|
Neue `.deb` bauen bzw. holen, dann erneut:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ./packaging/dist/grotti_*_arm64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
Config und `/var/lib/grotti` bleiben erhalten.
|
||||||
|
|
||||||
|
## Deinstallieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt remove grotti # Dienst weg, Daten bleiben
|
||||||
|
sudo apt purge grotti # zusätzlich /etc/grotti
|
||||||
|
# optional Datenbank:
|
||||||
|
# sudo -u postgres dropdb grotti
|
||||||
|
# sudo -u postgres dropuser grotti
|
||||||
|
# sudo rm -rf /var/lib/grotti
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Standard lauscht auf `0.0.0.0:8770` (LAN). Für Internet: Reverse Proxy mit TLS.
|
||||||
|
- Drucker unter **Einstellungen** anlegen (kein vorgefülltes `drucker.json` nötig).
|
||||||
|
- Öffentliches `apt install grotti` ohne Datei: kommt mit dem Repo-Server (Phase 2).
|
||||||
@@ -139,3 +139,8 @@ Ohne Map: Rinkhals füllt oft Identity T0→Platz0 … — deshalb Grotti bei ei
|
|||||||
- Quellcode von **gkapi** (Task-State-Machine, exakte 10101-Bedingungen) ist nicht öffentlich.
|
- Quellcode von **gkapi** (Task-State-Machine, exakte 10101-Bedingungen) ist nicht öffentlich.
|
||||||
- Deadlock in **FIRMWARE_RESTART** ist beobachtet und in Rinkhals abgefangen, Root Cause im Binary nicht freigelegt.
|
- Deadlock in **FIRMWARE_RESTART** ist beobachtet und in Rinkhals abgefangen, Root Cause im Binary nicht freigelegt.
|
||||||
- Kobra X / K4-Stack ist ein anderes System (C++-Klipper), nicht GoKlipper — für unsere Drucker irrelevant.
|
- Kobra X / K4-Stack ist ein anderes System (C++-Klipper), nicht GoKlipper — für unsere Drucker irrelevant.
|
||||||
|
|
||||||
|
## Siehe auch
|
||||||
|
|
||||||
|
- [Makros-Kobra](Makros-Kobra.md) — Nicht-tun, Vanilla-Alltag, ACE, LeviQ/Wipe je Druckertyp
|
||||||
|
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# Makros und G-code: Kobra S1 und Kobra 3
|
||||||
|
|
||||||
|
Stand **19.10.2026**. Interne Referenz — nicht unter `/doku`. Messwerte und Bettnetz-Story: `D:\Projekte\3DP\FDM\Wissen\Drucker\` (`Kobra S1.md`, `Kobra 3.md`). Stack: [Anycubic-Stack](Anycubic-Stack.md).
|
||||||
|
|
||||||
|
Quellen: `printer/gcode/help` (11.10. auf den Maschinen), GoKlipper/`extras_ace.go`, Rinkhals-RE `GOKLIPPER_ACE_GCODE_COMMANDS.md`, Grotti-Policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht tun
|
||||||
|
|
||||||
|
| Nicht | Warum | Stattdessen |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `FIRMWARE_RESTART` / `RESTART` | gklib Deadlock bis Power-Cycle (Rinkhals #32) | Display-Reboot oder Strom; hängenden Job: **Fehler löschen** (`SDCARD_RESET_FILE`) |
|
||||||
|
| `LEVIQ` / `LEVIQ2` aus Mainsail | Heizung + volle Nivellierfolge, fährt unerwartet | Am Display Auto-Level; Einzelschritte nur bewusst |
|
||||||
|
| `SAVE_CONFIG` aus Mainsail | schreibt u. a. Z-Offset mit | Dauerhaft nur gezielt / nach Display-Nivellieren |
|
||||||
|
| Nur Moonraker-Cancel | Anycubic-Task bleibt → Start-Code **10101** | Cancel + MQTT-stop (Grotti `abbrechen`) |
|
||||||
|
| Community-`ACE_SET_SLOT` / manuelles `G9111` als Grotti-Pfad | Grotti mappt über `.acm` + MQTT | Map in der App; Slice-`G9111` im Start-G-code belassen |
|
||||||
|
| `minimum_cruise_ratio` auf **0** (S1) | Moonraker hängt auf dieser Firmware | bei **0,05** belassen |
|
||||||
|
| `G28 Y` mit Teilen auf dem Bett (K3) | Sensorless-Home nach **vorn** über die Teile | Bett leeren; Abräumen ohne `G28 Z` |
|
||||||
|
| Blind `FEED` / `FEED_FILAMENT` ohne Unwind | Blockade bis Neustart (RE-Doku) | Unload/Unwind-Sequenz einhalten |
|
||||||
|
| `SET_KINEMATIC_POSITION` mit erfundener Z nach Stepper-aus | Portal sackt → Null daneben, Probe „No trigger“ oder Crash | echte letzte XYZ; oder Bett leer + `G28` |
|
||||||
|
| Freie G-code-Konsole in Grotti für Experimente | Policy | nur bekannte, bestätigte Scripts |
|
||||||
|
| Abräumen-Makro in `printer.cfg` pflegen | Rinkhals schreibt Config neu | Grotti schickt Scripts per Moonraker |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vanilla-Klipper-Alltag (beide Typen)
|
||||||
|
|
||||||
|
Befehl → was er macht. Anycubic-Hinweis nur wo nötig.
|
||||||
|
|
||||||
|
### Homes und Bewegung
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `G28` | Homed die genannten Achsen (ohne Argument: alle). **K3:** Y-Home fährt nach vorn — Bett muss leer sein. |
|
||||||
|
| `G0` / `G1` | Lineare Bewegung / Extrusion. Absolute/relative Modi wie üblich (`G90`/`G91`, `M82`/`M83`). |
|
||||||
|
| `M400` | Wartet, bis alle Moves fertig sind. |
|
||||||
|
| `SET_VELOCITY_LIMIT` | Limits für Speed/Accel zur Laufzeit. |
|
||||||
|
| `SET_KINEMATIC_POSITION` | Setzt die interne Position ohne zu fahren. Nach `M84`/Leerlauf riskant — siehe „Nicht tun“. |
|
||||||
|
| `SET_IDLE_TIMEOUT` | Idle-Zeit bis Stepper aus. Abräumen hält Idle bewusst länger. |
|
||||||
|
| `M84` / `M18` | Stepper aus → Achsen unhomed. Slice-Ende K3 macht das oft; Abräumen entfernt es beim Upload. |
|
||||||
|
|
||||||
|
### Heizen
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `M104` / `M109` | Düse Soll / warten bis erreicht. |
|
||||||
|
| `M140` / `M190` | Bett Soll / warten. |
|
||||||
|
| `TURN_OFF_HEATERS` | Düse und Bett aus. |
|
||||||
|
| `PID_CALIBRATE` | PID für Extruder oder Bett kalibrieren. Danach oft `SAVE_CONFIG` — auf diesen Maschinen vorsichtig. |
|
||||||
|
| `M106` / `M107` | Teilkühlung an / aus. |
|
||||||
|
|
||||||
|
### Drucklauf
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `PAUSE` / `RESUME` / `CANCEL_PRINT` | Rinkhals-Makros um Pause/Weiter/Abbruch. Moonraker-Cancel allein räumt den Anycubic-Task nicht. |
|
||||||
|
| `SDCARD_RESET_FILE` | Löscht hängenden virtual-sdcard-/Druckstatus („Fehler löschen“ in Grotti). |
|
||||||
|
| `PRINT_STATS` | Status des laufenden/letzten Jobs (Objekt auch über Moonraker). |
|
||||||
|
|
||||||
|
### Bettnetz und Probe
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `BED_MESH_CALIBRATE` | Tastet das Netz und schaltet es aktiv. |
|
||||||
|
| `BED_MESH_OUTPUT` | Gibt die Punkte aus, ohne zu fahren. |
|
||||||
|
| `BED_MESH_MAP` | Schreibt das interpolierte Netz in die Konsole. |
|
||||||
|
| `BED_MESH_CLEAR` | Schaltet die Korrektur aus. |
|
||||||
|
| `BED_MESH_OFFSET` | Verschiebt das Netz in X/Y. |
|
||||||
|
| `BED_MESH_PROFILE` | `LOAD` / `SAVE` / `REMOVE` für Profile. Dauerhaft erst mit `SAVE_CONFIG`. |
|
||||||
|
| `PROBE` | Ein Tastpunkt an der aktuellen Position. |
|
||||||
|
| `PROBE_ACCURACY` | Mehrfach tasten, Streuung ausgeben. |
|
||||||
|
| `PROBE_CALIBRATE` | Sensor kalibrieren (nicht der Z-Regler am Display). |
|
||||||
|
| `Z_OFFSET_APPLY_PROBE` | Laufenden G-code-Offset in `probe.z_offset` schreiben. |
|
||||||
|
| `SET_GCODE_OFFSET` | Offset nur für den aktuellen Auftrag. |
|
||||||
|
| `SAVE_CONFIG` | Schreibt Config dauerhaft — **nicht** blind aus Mainsail. |
|
||||||
|
|
||||||
|
### Diagnose
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `QUERY_ENDSTOPS` / `M119` | Endstop-Zustände. |
|
||||||
|
| `GET_POSITION` / `M114` | Toolhead-/Stepper-Position. |
|
||||||
|
| `QUERY_ADC` | ADC-Werte (z. B. Thermistoren). |
|
||||||
|
| `DUMP_TMC` | TMC-Register (Stepper nennen). |
|
||||||
|
|
||||||
|
### Slice-Start Anycubic
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `G9111` | Vorheizen aus dem Slicer: typisch `bedTemp=…` und `extruderTemp=…`. Steht im Maschinen-Start-G-code. Grotti ersetzt das nicht und startet auch nicht darüber. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ACE (gemeinsam)
|
||||||
|
|
||||||
|
Zwei Namenswelten:
|
||||||
|
|
||||||
|
1. **Open-Source** GoKlipper `extras_ace.go` (BunnyACE-Stil): `ACE_CHANGE_TOOL`, `ACE_FEED`, `ACE_RETRACT`, `ACE_SET_SLOT`, `ACE_QUERY_SLOTS`, `ACE_START_DRYING` / `ACE_STOP_DRYING`, Feed-Assist, Endless Spool, …
|
||||||
|
2. **Ausgeliefertes Binary** (Rinkhals-RE): u. a. `FEED_FILAMENT`, `UNWIND_FILAMENT`, `UNWIND_ALL_FILAMENT`, `SET_CURRENT_FILAMENT`, `ACE_INFO`, `ACE_STATUS`, Cutter-/Pipe-Befehle.
|
||||||
|
|
||||||
|
Grotti nutzt **keine** Community-Makros `ACE_SET_SLOT` / manuelles Tool-Mapping per G-code. Zuordnung: Sidecar `.acm` + MQTT `ams_settings` ([Senden-Queue](Senden-Queue.md), [ACE-Live](ACE-Live.md)). Slots anzeigen: Moonraker-Objekt `mmu` (Rinkhals). Trocknen: Moonraker `POST /server/filament_hub/start_drying` (nicht zwingend der G-code).
|
||||||
|
|
||||||
|
| Befehl (Auswahl) | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `ACE_CHANGE_TOOL` / Tool-Macros `T0`… | Werkzeugwechsel über ACE. |
|
||||||
|
| `ACE_FEED` / `FEED_FILAMENT` | Filament vom Gate zur Düse laden. **Vorher unloaden.** |
|
||||||
|
| `ACE_RETRACT` / `UNWIND_FILAMENT` | Zurückziehen / parkieren. |
|
||||||
|
| `ACE_SET_SLOT` | Inventar Slot setzen — Grotti meidet das. |
|
||||||
|
| `ACE_START_DRYING` / `ACE_STOP_DRYING` | Trockner; alternativ `filament_hub`-API. |
|
||||||
|
| `ACE_ENABLE_ENDLESS_SPOOL` / `…_DISABLE` / `…_STATUS` | Endless Spool. |
|
||||||
|
|
||||||
|
Details und Parameter: Rinkhals `docs/GOKLIPPER_ACE_GCODE_COMMANDS.md` (lokal unter `D:\Projekte\_ref\Rinkhals\`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kobra S1 — Anycubic-Makros
|
||||||
|
|
||||||
|
Config-Sektion heißt `[leviQ3]`, die Befehle auf dem Gerät heißen **`LEVIQ2`**. Stecken in GoKlipper. Hilfe gelesen 11.10. über `printer/gcode/help`.
|
||||||
|
|
||||||
|
### LeviQ2
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `LEVIQ2` | Ganze Nivellierfolge: heizen, wischen, antasten. **Nicht** aus Mainsail. `SYNC=enable` startet danach neu. |
|
||||||
|
| `LEVIQ2_PREHEATING` | Vorheizen: Bett 55 °C, Düse 170 °C, dann `HOME_XY`. `S` = Düsentemp, `E` = Temp nach dem Wischen. |
|
||||||
|
| `LEVIQ2_WIPING` | Wischen: Netz löschen, Düse auf 140 °C, intern `G9112`, dann `G28 Z`. |
|
||||||
|
| `LEVIQ2_PROBE` | Antasten: `BED_MESH_CALIBRATE`, Lüfter/Bett aus, Z auf 20 mm, `HOME_XY`, Düse aus. |
|
||||||
|
| `LEVIQ_SET_ZERO` | Z-Basis und Home-Position auf 0. |
|
||||||
|
| `LEVIQ2_AUTO_ZOFFSET` | Löscht laut Hilfe den Koeffizienten K. |
|
||||||
|
| `LEVIQ2_AUTO_ZOFFSET_ON_OFF` | Schaltet automatischen Z-Offset. |
|
||||||
|
| `LEVIQ2_TEMP_OFFSET` | Temperaturausgleich (`expansion_factor` / `zoffset_base` in der Config). Fehlt im veröffentlichten Quelltext. |
|
||||||
|
| `HOME_XY` | Nur X/Y homen (von LeviQ-Schritten genutzt). |
|
||||||
|
|
||||||
|
Netz: 5×5, X/Y 5–245 mm. Details: Wissen `Kobra S1.md`.
|
||||||
|
|
||||||
|
### Wipe (Pad hinten)
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `WIPE_ENTER` | Anfahrt Pad (Y 250 → X 81, Y 273). |
|
||||||
|
| `WIPE_NOZZLE` | Wischbahn; wiederholt die Strecke X 96↔81. |
|
||||||
|
| `WIPE_STOP` | Kurze Stopp-Bahnen, dann X 75. |
|
||||||
|
| `WIPE_EXIT` | Weg vom Pad (X 83, Y 250). |
|
||||||
|
| `TO_THROW_POSITION` | Position zum Abwerfen (~X 47, Y 276). |
|
||||||
|
| `MOVE_TO_SWEEP_POSITION` | Wisch-/Kehrposition (gleiche Gegend). |
|
||||||
|
| `MOVE_TO_IMPACT_POSITION` | Weg vom Pad nach vorn rechts (Y 25, X 261). |
|
||||||
|
| `PRINT_LINE` | Purge-Linie bei Y 251, Z 0,28 bis X 125. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kobra 3 — Anycubic-Makros
|
||||||
|
|
||||||
|
Befehle gelesen 11.10. auf `da26` (`92ee`/`6e41` gleiche `printer.generated.cfg`). Im veröffentlichten Quelltext heißen Teile noch `LEVIQ3_*` / `LEVIQ2_PROBE` — auf den drei Druckern gelten die Namen unten.
|
||||||
|
|
||||||
|
### LeviQ
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `LEVIQ` | Ganze Folge inkl. Z-Offset-Rechnung. **Nicht** aus Mainsail. `SYNC=enable` startet danach neu. |
|
||||||
|
| `LEVIQ_PREHEATING` | Z-Basis 0, Bett 60 °C, Düse 170 °C, `HOME_XY`, auf Düse warten. `S` / `E` wie bei S1. |
|
||||||
|
| `LEVIQ_WIPING` | Netz löschen, Düse 140 °C, `G9112`, `G28 Z`. |
|
||||||
|
| `LEVIQ_PROBE` | `BED_MESH_CALIBRATE`, Lüfter/Bett aus, Z auf 30 mm, `HOME_XY`, Düse aus. |
|
||||||
|
| `LEVIQ_SET_ZERO` | Z-Basis und Home auf 0. |
|
||||||
|
| `LEVIQ_AUTO_ZOFFSET` | Löscht Koeffizient K (Hilfe). |
|
||||||
|
| `LEVIQ_AUTO_ZOFFSET_ON_OFF` | Automatischen Z-Offset schalten (oft an). |
|
||||||
|
| `LEVIQ_TEMP_OFFSET` | Temperaturausgleich (Hilfezeile „LEVIQ3 TEMP OFFSET“). Fehlt im Open-Source-Code. |
|
||||||
|
| `HOME_XY` | Nur X/Y homen. |
|
||||||
|
|
||||||
|
Netz: 5×5, X 15–240, Y 15–235 mm. Details: Wissen `Kobra 3.md`.
|
||||||
|
|
||||||
|
### Wipe
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `WIPE_NOZZLE` | Dreimal `SINGLE_WIPE_NOZZLE` (Y 260/259, X 46–90, Z −0,8↔1, Lüfter an). |
|
||||||
|
| `SINGLE_WIPE_NOZZLE` | Eine einzelne Wischbahn. |
|
||||||
|
| `MOVE_HEAT_POS` | Z 15, dann X 278 (Heiz-/Parknahe Position). |
|
||||||
|
| `UNDERLINE` | Purge-Linie Z 0,28 bis X 180, ~40 mm Extrusion. |
|
||||||
|
| `MOVE_END_POS` | Ende/Park X 5, Y 242. |
|
||||||
|
|
||||||
|
### Wägezelle
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `CS1237_DUMP` | Debug-Ausgabe zum CS1237 (Z-Sensor / Kraft). Moonraker liefert keine laufende Gramm-Anzeige; `probe.last_z_result` fehlt. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Siehe auch
|
||||||
|
|
||||||
|
- [Anycubic-Stack](Anycubic-Stack.md) — MQTT-Start/Stop, 10101, kein Firmware-Restart
|
||||||
|
- [Senden-Queue](Senden-Queue.md) — `.acm`, kein Community-ACE-Makro
|
||||||
|
- [Abraeumen](Abraeumen.md) — K3 Farm-Scripts statt Druckermakro
|
||||||
|
- [ACE-Live](ACE-Live.md) — Live-Mapping und Trailer
|
||||||
@@ -3,3 +3,8 @@
|
|||||||
Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App.
|
Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App.
|
||||||
|
|
||||||
Nutzer-Handbuch: `docs/Anleitung-*.md`.
|
Nutzer-Handbuch: `docs/Anleitung-*.md`.
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| [Makros-Kobra.md](Makros-Kobra.md) | G-code/Makros Kobra S1 und 3: Nicht-tun, Vanilla-Alltag, ACE, LeviQ/Wipe |
|
||||||
|
| [Anycubic-Stack.md](Anycubic-Stack.md) | GoKlipper, MQTT-Start/Stop, ACE-Kanäle |
|
||||||
+24
-4
@@ -1,6 +1,7 @@
|
|||||||
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
|
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -9,11 +10,24 @@ from grotti.errors import GrottiError
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
|
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
|
||||||
CONFIG_PATH = ROOT / "config.json"
|
|
||||||
DEFAULT_WATCH_REL = "slice"
|
DEFAULT_WATCH_REL = "slice"
|
||||||
_SETTINGS_LOCK = threading.Lock()
|
_SETTINGS_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def config_path():
|
||||||
|
"""GROTTI_CONFIG, sonst /etc/grotti/config.json (Paket), sonst Repo-config.json."""
|
||||||
|
env = (os.environ.get("GROTTI_CONFIG") or "").strip()
|
||||||
|
if env:
|
||||||
|
return Path(env)
|
||||||
|
etc = Path("/etc/grotti/config.json")
|
||||||
|
if etc.is_file():
|
||||||
|
return etc
|
||||||
|
return ROOT / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_PATH = config_path()
|
||||||
|
|
||||||
|
|
||||||
def _tariff(raw):
|
def _tariff(raw):
|
||||||
tariff = raw.get("tariff") if isinstance(raw, dict) else None
|
tariff = raw.get("tariff") if isinstance(raw, dict) else None
|
||||||
if not isinstance(tariff, dict) or not tariff:
|
if not isinstance(tariff, dict) or not tariff:
|
||||||
@@ -121,9 +135,11 @@ def _ensure_secret_key(path, raw):
|
|||||||
|
|
||||||
|
|
||||||
def load():
|
def load():
|
||||||
path = CONFIG_PATH
|
path = config_path()
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
path.write_text((ROOT / "config.example.json").read_text(encoding="utf-8"), encoding="utf-8")
|
example = ROOT / "config.example.json"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(example.read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
host = (raw.get("host") or "127.0.0.1").strip()
|
host = (raw.get("host") or "127.0.0.1").strip()
|
||||||
token = raw.get("token") or ""
|
token = raw.get("token") or ""
|
||||||
@@ -139,6 +155,9 @@ def load():
|
|||||||
if not data_dir.is_absolute():
|
if not data_dir.is_absolute():
|
||||||
data_dir = ROOT / data_dir
|
data_dir = ROOT / data_dir
|
||||||
watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
|
watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
|
||||||
|
printers_file = Path(raw.get("printers_file") or (ROOT / "drucker.json"))
|
||||||
|
if not printers_file.is_absolute():
|
||||||
|
printers_file = ROOT / printers_file
|
||||||
return {
|
return {
|
||||||
"host": host,
|
"host": host,
|
||||||
"port": int(raw.get("port") or 8770),
|
"port": int(raw.get("port") or 8770),
|
||||||
@@ -149,7 +168,8 @@ 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 ""),
|
||||||
"printers_file": ROOT / "drucker.json",
|
"printers_file": printers_file,
|
||||||
|
"log_level": str(raw.get("log_level") or "INFO").strip() or "INFO",
|
||||||
"energy": _energy(raw),
|
"energy": _energy(raw),
|
||||||
"telegram": _telegram(raw),
|
"telegram": _telegram(raw),
|
||||||
"ntfy": _ntfy(raw),
|
"ntfy": _ntfy(raw),
|
||||||
|
|||||||
+5
-1
@@ -13,6 +13,10 @@ from psycopg import sql
|
|||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
from psycopg_pool import ConnectionPool
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
|
from grotti import logutil
|
||||||
|
|
||||||
|
log = logutil.get("grotti.db")
|
||||||
|
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
|
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
|
||||||
|
|
||||||
@@ -123,7 +127,7 @@ def prepare(url):
|
|||||||
if not exists:
|
if not exists:
|
||||||
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
|
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
|
||||||
except psycopg.Error as exc:
|
except psycopg.Error as exc:
|
||||||
print("Datenbank anlegen: %s" % exc, flush=True)
|
log.warning("Datenbank anlegen: %s", exc)
|
||||||
try:
|
try:
|
||||||
with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
|
with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
|
||||||
apply_migrations(conn)
|
apply_migrations(conn)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Zentraler Logger für Dienst und journald (stdout)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_CONFIGURED = False
|
||||||
|
|
||||||
|
LEVELS = {
|
||||||
|
"critical": logging.CRITICAL,
|
||||||
|
"error": logging.ERROR,
|
||||||
|
"warning": logging.WARNING,
|
||||||
|
"warn": logging.WARNING,
|
||||||
|
"info": logging.INFO,
|
||||||
|
"debug": logging.DEBUG,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_level(value, default=logging.INFO):
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
text = str(value or "").strip().lower()
|
||||||
|
return LEVELS.get(text, default)
|
||||||
|
|
||||||
|
|
||||||
|
def setup(level="INFO"):
|
||||||
|
"""Root-Logger einmalig auf stdout (systemd → journald)."""
|
||||||
|
global _CONFIGURED
|
||||||
|
resolved = parse_level(level)
|
||||||
|
root = logging.getLogger()
|
||||||
|
if _CONFIGURED:
|
||||||
|
root.setLevel(resolved)
|
||||||
|
return root
|
||||||
|
root.handlers.clear()
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(
|
||||||
|
logging.Formatter(
|
||||||
|
"%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
root.addHandler(handler)
|
||||||
|
root.setLevel(resolved)
|
||||||
|
logging.getLogger("waitress").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||||
|
_CONFIGURED = True
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def get(name="grotti"):
|
||||||
|
return logging.getLogger(name)
|
||||||
+4
-1
@@ -8,8 +8,11 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from grotti import logutil
|
||||||
from grotti.parse import embedded_png
|
from grotti.parse import embedded_png
|
||||||
|
|
||||||
|
log = logutil.get("grotti.moonraker")
|
||||||
|
|
||||||
META_CACHE = {}
|
META_CACHE = {}
|
||||||
CAMERA_CACHE = {}
|
CAMERA_CACHE = {}
|
||||||
EMBEDDED = {}
|
EMBEDDED = {}
|
||||||
@@ -1241,7 +1244,7 @@ def snapshot(printers):
|
|||||||
card.update(query(printer["host"]))
|
card.update(query(printer["host"]))
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
|
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
|
||||||
print("snapshot %s: %s" % (printer.get("host"), error), flush=True)
|
log.warning("snapshot %s: %s", printer.get("host"), error)
|
||||||
card.update({
|
card.update({
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"state": "offline",
|
"state": "offline",
|
||||||
|
|||||||
+72
-14
@@ -10,7 +10,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from grotti import acm, eject, jobs, moonraker, notify, store
|
from grotti import acm, eject, jobs, logutil, moonraker, notify, store
|
||||||
from grotti.db import connect
|
from grotti.db import connect
|
||||||
from grotti.errors import GrottiError
|
from grotti.errors import GrottiError
|
||||||
from grotti import time_window
|
from grotti import time_window
|
||||||
@@ -18,6 +18,8 @@ from grotti import time_window
|
|||||||
WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…"
|
WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…"
|
||||||
from grotti.events_bus import publish as publish_live
|
from grotti.events_bus import publish as publish_live
|
||||||
|
|
||||||
|
log = logutil.get("grotti.queue")
|
||||||
|
|
||||||
|
|
||||||
def _notify_queue():
|
def _notify_queue():
|
||||||
publish_live("queue", {})
|
publish_live("queue", {})
|
||||||
@@ -111,7 +113,7 @@ class PrintQueueDispatcher:
|
|||||||
try:
|
try:
|
||||||
self.tick()
|
self.tick()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Druck-Queue: %s" % exc, flush=True)
|
log.warning("Druck-Queue: %s", exc)
|
||||||
self._stop.wait(self.interval)
|
self._stop.wait(self.interval)
|
||||||
|
|
||||||
def _live_map(self, printers):
|
def _live_map(self, printers):
|
||||||
@@ -207,7 +209,7 @@ class PrintQueueDispatcher:
|
|||||||
try:
|
try:
|
||||||
target(*args)
|
target(*args)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Queue-Worker %s: %s" % (item_id, exc), flush=True)
|
log.warning("Queue-Worker %s: %s", item_id, exc)
|
||||||
finally:
|
finally:
|
||||||
_notify_queue()
|
_notify_queue()
|
||||||
with self._worker_lock:
|
with self._worker_lock:
|
||||||
@@ -278,13 +280,15 @@ class PrintQueueDispatcher:
|
|||||||
continue
|
continue
|
||||||
running = store.running_print_on_host(conn, host)
|
running = store.running_print_on_host(conn, host)
|
||||||
if running:
|
if running:
|
||||||
if (
|
if not _has_ablauf(running):
|
||||||
not _has_ablauf(running)
|
if (
|
||||||
and (running.get("phase") or "").lower() == "starting"
|
(running.get("phase") or "").lower() == "starting"
|
||||||
and not self._worker_busy(running["id"])
|
and not self._worker_busy(running["id"])
|
||||||
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
|
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
|
||||||
):
|
):
|
||||||
self._fail_stuck_simple_start(conn, running, printer)
|
self._fail_stuck_simple_start(conn, running, printer)
|
||||||
|
else:
|
||||||
|
self._advance_simple(conn, running, printer, live)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
continue
|
continue
|
||||||
if store.bed_needs_clear(conn, host):
|
if store.bed_needs_clear(conn, host):
|
||||||
@@ -417,15 +421,17 @@ class PrintQueueDispatcher:
|
|||||||
from grotti import orders
|
from grotti import orders
|
||||||
|
|
||||||
orders.attach_order_item_to_live_run(wconn, host, name, oid)
|
orders.attach_order_item_to_live_run(wconn, host, name, oid)
|
||||||
|
# Nicht sofort „done“: sonst greift der nächste Wartende, während
|
||||||
|
# der Drucker noch druckt (Live-Cache kann kurz FREE bleiben).
|
||||||
store.update_print_queue(
|
store.update_print_queue(
|
||||||
wconn,
|
wconn,
|
||||||
item_id,
|
item_id,
|
||||||
{
|
{
|
||||||
"status": "done",
|
"status": "running",
|
||||||
"message": "Gestartet auf %s: %s" % (printer_copy["name"], name),
|
"message": "Druckt auf %s: %s" % (printer_copy["name"], name),
|
||||||
"host": host,
|
"host": host,
|
||||||
"phase": "",
|
"phase": "print_active",
|
||||||
"phase_started_at": None,
|
"phase_started_at": "now",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self._event(
|
self._event(
|
||||||
@@ -477,6 +483,58 @@ class PrintQueueDispatcher:
|
|||||||
|
|
||||||
self._spawn_worker(item_id, work)
|
self._spawn_worker(item_id, work)
|
||||||
|
|
||||||
|
def _advance_simple(self, conn, item, printer, live):
|
||||||
|
"""Einfach-Druck: running bleiben bis Moonraker fertig/abgebrochen."""
|
||||||
|
phase = (item.get("phase") or "").lower()
|
||||||
|
state = (live.get("state") or "").lower()
|
||||||
|
if phase == "starting":
|
||||||
|
if state in ("printing", "paused", "heating") or live.get("print_active"):
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"phase": "print_active",
|
||||||
|
"phase_started_at": "now",
|
||||||
|
"message": "Druckt…",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if phase != "print_active":
|
||||||
|
return
|
||||||
|
if state in ("printing", "paused", "heating"):
|
||||||
|
return
|
||||||
|
if state == "error":
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": (live.get("message") or "Druckerfehler").strip()[:200],
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if state in FREE or state in ("complete", "cancelled"):
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "done",
|
||||||
|
"message": "Fertig.",
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
conn,
|
||||||
|
printer["host"],
|
||||||
|
printer["name"],
|
||||||
|
"fertig",
|
||||||
|
"%s fertig: %s" % (printer["name"], item.get("filename") or "Druck"),
|
||||||
|
item.get("filename") or "",
|
||||||
|
)
|
||||||
|
|
||||||
def _begin_ablauf(self, conn, item, printer, live):
|
def _begin_ablauf(self, conn, item, printer, live):
|
||||||
try:
|
try:
|
||||||
eject.require_kobra_3(printer)
|
eject.require_kobra_3(printer)
|
||||||
|
|||||||
+21
-11
@@ -2813,6 +2813,20 @@ def _file_needs(conn, file_id):
|
|||||||
return types, float(row.get("weight_g") or 0)
|
return types, float(row.get("weight_g") or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _prefer_color(spools, need_color):
|
||||||
|
"""Farbe soft: Slice-Hex ignorieren; benannte Farbe bevorzugen, nie blockieren."""
|
||||||
|
from grotti.bedarf import _is_slicer_hex
|
||||||
|
|
||||||
|
need_c = (need_color or "").strip().lower()
|
||||||
|
if not need_c or _is_slicer_hex(need_c) or not spools:
|
||||||
|
return list(spools)
|
||||||
|
preferred = [
|
||||||
|
h for h in spools
|
||||||
|
if not (h.get("color") or "").strip() or (h.get("color") or "").strip().lower() == need_c
|
||||||
|
]
|
||||||
|
return preferred or list(spools)
|
||||||
|
|
||||||
|
|
||||||
def _match_spools_for_need(need, have, used_ids=None):
|
def _match_spools_for_need(need, have, used_ids=None):
|
||||||
used = used_ids or set()
|
used = used_ids or set()
|
||||||
candidates = [h for h in have if h.get("spool_id") not in used]
|
candidates = [h for h in have if h.get("spool_id") not in used]
|
||||||
@@ -2822,29 +2836,25 @@ def _match_spools_for_need(need, have, used_ids=None):
|
|||||||
kind = (kind or "").strip().lower()
|
kind = (kind or "").strip().lower()
|
||||||
return [
|
return [
|
||||||
h for h in candidates
|
h for h in candidates
|
||||||
if (not kind or h["material_type"] == kind)
|
if not kind or h["material_type"] == kind
|
||||||
and (not need.get("color") or h["color"] == need["color"] or not h["color"])
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if profile:
|
if profile:
|
||||||
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
|
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
|
||||||
if by_profile:
|
if by_profile:
|
||||||
colored = [
|
return _prefer_color(by_profile, need.get("color"))
|
||||||
h for h in by_profile
|
# Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht
|
||||||
if not need.get("color") or h["color"] == need["color"] or not h["color"]
|
# (Sunlu PLA ↔ Eryone PLA Lite; Slice-Hex #26A69A blockiert nicht „Grau“).
|
||||||
]
|
|
||||||
return colored or by_profile
|
|
||||||
# Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht (Sunlu PLA ↔ Eryone PLA).
|
|
||||||
# Spulen mit Variante im Namen bleiben draußen (kein Meta für „nur PLA“).
|
|
||||||
if not _profile_has_variant(profile):
|
if not _profile_has_variant(profile):
|
||||||
kind = _profile_base_type(profile) or (need.get("material_type") or "")
|
kind = _profile_base_type(profile) or (need.get("material_type") or "")
|
||||||
if kind:
|
if kind:
|
||||||
return [
|
typed = [
|
||||||
h for h in _by_type(kind)
|
h for h in _by_type(kind)
|
||||||
if not _profile_has_variant(h.get("material_name") or "")
|
if not _profile_has_variant(h.get("material_name") or "")
|
||||||
]
|
]
|
||||||
|
return _prefer_color(typed, need.get("color"))
|
||||||
return []
|
return []
|
||||||
return _by_type(need.get("material_type") or "")
|
return _prefer_color(_by_type(need.get("material_type") or ""), need.get("color"))
|
||||||
|
|
||||||
|
|
||||||
def material_fit_score(conn, host, file_id):
|
def material_fit_score(conn, host, file_id):
|
||||||
|
|||||||
+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.4.0"
|
__version__ = "3.5.0"
|
||||||
+10
-8
@@ -6,10 +6,12 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from grotti import jobs, moonraker, notify, store, usage
|
from grotti import 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
|
||||||
|
|
||||||
|
log = logutil.get("grotti.watcher")
|
||||||
|
|
||||||
SAMPLE_EVERY_S = 30
|
SAMPLE_EVERY_S = 30
|
||||||
SAMPLE_HEAT_S = 10
|
SAMPLE_HEAT_S = 10
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@ class StateWatcher:
|
|||||||
try:
|
try:
|
||||||
self.tick()
|
self.tick()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher: %s" % exc, flush=True)
|
log.warning("Watcher: %s", exc)
|
||||||
self._stop.wait(self.interval)
|
self._stop.wait(self.interval)
|
||||||
|
|
||||||
def tick(self):
|
def tick(self):
|
||||||
@@ -118,7 +120,7 @@ class StateWatcher:
|
|||||||
|
|
||||||
jobs.apply_deduction(conn, closed, self.cfg["energy"])
|
jobs.apply_deduction(conn, closed, self.cfg["energy"])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Abzug offline %s: %s" % (host, exc), flush=True)
|
log.warning("Watcher Abzug offline %s: %s", host, exc)
|
||||||
self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
|
self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -193,7 +195,7 @@ class StateWatcher:
|
|||||||
|
|
||||||
jobs.sync_history(conn, self.cfg)
|
jobs.sync_history(conn, self.cfg)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
log.warning("Watcher Abgleich %s: %s", host, exc)
|
||||||
# History oft ohne Endzeit (interrupted) — Live trotzdem schließen.
|
# History oft ohne Endzeit (interrupted) — Live trotzdem schließen.
|
||||||
dangling = store.find_live_run(conn, host)
|
dangling = store.find_live_run(conn, host)
|
||||||
if dangling:
|
if dangling:
|
||||||
@@ -204,7 +206,7 @@ class StateWatcher:
|
|||||||
|
|
||||||
jobs.apply_deduction(conn, closed, self.cfg["energy"])
|
jobs.apply_deduction(conn, closed, self.cfg["energy"])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Abzug %s: %s" % (host, exc), flush=True)
|
log.warning("Watcher Abzug %s: %s", host, exc)
|
||||||
elif state in ("cancelled", "standby"):
|
elif state in ("cancelled", "standby"):
|
||||||
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
|
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
|
||||||
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
|
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
|
||||||
@@ -231,7 +233,7 @@ class StateWatcher:
|
|||||||
|
|
||||||
jobs.sync_history(conn, self.cfg)
|
jobs.sync_history(conn, self.cfg)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
log.warning("Watcher Abgleich %s: %s", host, exc)
|
||||||
elif state == "error" and prev_state != "error":
|
elif state == "error" and prev_state != "error":
|
||||||
if prev_state == "printing" or prev_state == "paused":
|
if prev_state == "printing" or prev_state == "paused":
|
||||||
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
|
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
|
||||||
@@ -254,7 +256,7 @@ class StateWatcher:
|
|||||||
|
|
||||||
jobs.sync_history(conn, self.cfg)
|
jobs.sync_history(conn, self.cfg)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
log.warning("Watcher Abgleich %s: %s", host, exc)
|
||||||
elif state == "error":
|
elif state == "error":
|
||||||
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
|
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
|
||||||
self._cool_heaters(host)
|
self._cool_heaters(host)
|
||||||
@@ -436,7 +438,7 @@ class StateWatcher:
|
|||||||
try:
|
try:
|
||||||
moonraker.heaters_off(host)
|
moonraker.heaters_off(host)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Heizung aus %s: %s" % (host, exc), flush=True)
|
log.warning("Watcher Heizung aus %s: %s", host, exc)
|
||||||
|
|
||||||
def _check_spool(self, conn, host, name, filename, live):
|
def _check_spool(self, conn, host, name, filename, live):
|
||||||
need = float(live.get("weight") or 0)
|
need = float(live.get("weight") or 0)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut grotti_<version>_<arch>.deb (auf dem Zielsystem oder arm64-Container).
|
||||||
|
# Aufruf aus dem Repo-Root: bash packaging/build-deb.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
if ! command -v dpkg-deb >/dev/null 2>&1; then
|
||||||
|
echo "dpkg-deb fehlt — bitte auf Debian/Raspberry Pi OS bauen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="$(python3 -c "from grotti.version import __version__; print(__version__)")"
|
||||||
|
ARCH="$(dpkg --print-architecture)"
|
||||||
|
PKG="grotti_${VERSION}_${ARCH}"
|
||||||
|
DIST="$ROOT/packaging/dist"
|
||||||
|
STAGE="$DIST/$PKG"
|
||||||
|
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
mkdir -p "$STAGE/DEBIAN" \
|
||||||
|
"$STAGE/usr/lib/grotti" \
|
||||||
|
"$STAGE/usr/bin" \
|
||||||
|
"$STAGE/etc/grotti" \
|
||||||
|
"$STAGE/lib/systemd/system" \
|
||||||
|
"$STAGE/usr/share/grotti" \
|
||||||
|
"$STAGE/usr/share/doc/grotti" \
|
||||||
|
"$STAGE/var/lib/grotti/data/eingang" \
|
||||||
|
"$STAGE/var/lib/grotti/slice"
|
||||||
|
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude '__pycache__' --exclude '*.pyc' --exclude '.pytest_cache' \
|
||||||
|
"$ROOT/grotti/" "$STAGE/usr/lib/grotti/grotti/"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude '__pycache__' --exclude '*.pyc' \
|
||||||
|
"$ROOT/web/" "$STAGE/usr/lib/grotti/web/"
|
||||||
|
cp "$ROOT/server.py" "$STAGE/usr/lib/grotti/server.py"
|
||||||
|
cp "$ROOT/requirements.txt" "$STAGE/usr/lib/grotti/requirements.txt"
|
||||||
|
cp "$ROOT/config.example.json" "$STAGE/usr/lib/grotti/config.example.json"
|
||||||
|
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/etc/grotti/config.json"
|
||||||
|
cp "$ROOT/packaging/grotti.service" "$STAGE/lib/systemd/system/grotti.service"
|
||||||
|
cp "$ROOT/docs/Anleitung-Installation-Pi.md" "$STAGE/usr/share/doc/grotti/"
|
||||||
|
cp "$ROOT/CHANGELOG.md" "$STAGE/usr/share/doc/grotti/changelog"
|
||||||
|
gzip -9 -f "$STAGE/usr/share/doc/grotti/changelog"
|
||||||
|
|
||||||
|
cat > "$STAGE/usr/bin/grotti" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
export GROTTI_CONFIG="${GROTTI_CONFIG:-/etc/grotti/config.json}"
|
||||||
|
exec /usr/lib/grotti/venv/bin/python /usr/lib/grotti/server.py "$@"
|
||||||
|
EOF
|
||||||
|
chmod 755 "$STAGE/usr/bin/grotti"
|
||||||
|
|
||||||
|
python3 -m venv "$STAGE/usr/lib/grotti/venv"
|
||||||
|
"$STAGE/usr/lib/grotti/venv/bin/pip" install --upgrade pip wheel
|
||||||
|
"$STAGE/usr/lib/grotti/venv/bin/pip" install -r "$ROOT/requirements.txt"
|
||||||
|
|
||||||
|
SIZE_KB="$(du -sk "$STAGE" | awk '{print $1}')"
|
||||||
|
cat > "$STAGE/DEBIAN/control" <<EOF
|
||||||
|
Package: grotti
|
||||||
|
Version: ${VERSION}
|
||||||
|
Section: web
|
||||||
|
Priority: optional
|
||||||
|
Architecture: ${ARCH}
|
||||||
|
Maintainer: Dominic Gundel <[email protected]>
|
||||||
|
Depends: python3 (>= 3.11), python3-venv, postgresql, adduser
|
||||||
|
Installed-Size: ${SIZE_KB}
|
||||||
|
Homepage: https://git.gundel.dev/dg/grotti
|
||||||
|
Description: Farm-Manager für Moonraker-Drucker (Kobra 3 / S1)
|
||||||
|
Grotti steuert eine 3D-Drucker-Farm über Moonraker: Wand, Warteschlange,
|
||||||
|
Material, Abläufe und Protokoll. PostgreSQL wird lokal mit eingerichtet.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cp "$ROOT/packaging/debian/conffiles" "$STAGE/DEBIAN/conffiles"
|
||||||
|
for script in postinst prerm postrm; do
|
||||||
|
cp "$ROOT/packaging/debian/$script" "$STAGE/DEBIAN/$script"
|
||||||
|
chmod 755 "$STAGE/DEBIAN/$script"
|
||||||
|
done
|
||||||
|
|
||||||
|
dpkg-deb --root-owner-group --build "$STAGE" "$DIST/${PKG}.deb"
|
||||||
|
echo "Gebaut: $DIST/${PKG}.deb"
|
||||||
|
echo "Install: sudo apt install ./packaging/dist/${PKG}.deb"
|
||||||
|
echo "Logs: journalctl -u grotti -f"
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8770,
|
||||||
|
"data_dir": "/var/lib/grotti/data",
|
||||||
|
"watch_dir": "/var/lib/grotti/slice",
|
||||||
|
"database_url": "postgresql:///grotti?host=/var/run/postgresql",
|
||||||
|
"token": "",
|
||||||
|
"secret_key": "",
|
||||||
|
"fqdn": "",
|
||||||
|
"log_level": "INFO",
|
||||||
|
"auto_queue": true,
|
||||||
|
"auto_retry": true,
|
||||||
|
"oauth": {
|
||||||
|
"enabled": false,
|
||||||
|
"issuer": "",
|
||||||
|
"client_id": "",
|
||||||
|
"client_secret": "",
|
||||||
|
"redirect_uri": ""
|
||||||
|
},
|
||||||
|
"energy": {
|
||||||
|
"watts": 40,
|
||||||
|
"nozzle_watts": 60,
|
||||||
|
"bed_watts": 220,
|
||||||
|
"euro_per_kwh": 0.35,
|
||||||
|
"dryer_watts": 0,
|
||||||
|
"dry_hours": 0,
|
||||||
|
"waste_percent": 0,
|
||||||
|
"utilization": 50,
|
||||||
|
"tariff": {
|
||||||
|
"ht_euro": 0.35,
|
||||||
|
"nt_euro": 0.22,
|
||||||
|
"nt_from": "22:00",
|
||||||
|
"nt_to": "06:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"enabled": false,
|
||||||
|
"bot_token": "",
|
||||||
|
"chat_id": ""
|
||||||
|
},
|
||||||
|
"ntfy": {
|
||||||
|
"enabled": false,
|
||||||
|
"server": "https://ntfy.sh",
|
||||||
|
"topic": "",
|
||||||
|
"token": ""
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"fertig": true,
|
||||||
|
"fehler": true,
|
||||||
|
"offline": true,
|
||||||
|
"queue": true,
|
||||||
|
"spule": true,
|
||||||
|
"ablauf": false,
|
||||||
|
"start": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/etc/grotti/config.json
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
_pg() {
|
||||||
|
if command -v runuser >/dev/null 2>&1; then
|
||||||
|
runuser -u postgres -- "$@"
|
||||||
|
else
|
||||||
|
su -s /bin/sh postgres -c "$*"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
configure)
|
||||||
|
if ! getent passwd grotti >/dev/null; then
|
||||||
|
adduser --system --group --home /var/lib/grotti --no-create-home \
|
||||||
|
--gecos "Grotti farm manager" grotti
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p /var/lib/grotti/data/eingang /var/lib/grotti/slice /etc/grotti
|
||||||
|
chown -R grotti:grotti /var/lib/grotti
|
||||||
|
chmod 750 /var/lib/grotti
|
||||||
|
|
||||||
|
if [ ! -f /etc/grotti/config.json ]; then
|
||||||
|
cp /usr/share/grotti/config.pi.json /etc/grotti/config.json
|
||||||
|
fi
|
||||||
|
chmod 640 /etc/grotti/config.json
|
||||||
|
chown root:grotti /etc/grotti/config.json
|
||||||
|
|
||||||
|
if getent passwd postgres >/dev/null 2>&1; then
|
||||||
|
_pg psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='grotti'" | grep -q 1 \
|
||||||
|
|| _pg createuser -D -R -S grotti || true
|
||||||
|
_pg psql -tAc "SELECT 1 FROM pg_database WHERE datname='grotti'" | grep -q 1 \
|
||||||
|
|| _pg createdb -O grotti grotti || true
|
||||||
|
_pg psql -c "GRANT ALL PRIVILEGES ON DATABASE grotti TO grotti;" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
systemctl enable grotti.service || true
|
||||||
|
systemctl restart grotti.service || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
purge)
|
||||||
|
rm -f /etc/grotti/config.json
|
||||||
|
rmdir /etc/grotti 2>/dev/null || true
|
||||||
|
# Daten und DB bleiben absichtlich (Backup). Manuell:
|
||||||
|
# sudo rm -rf /var/lib/grotti
|
||||||
|
# sudo -u postgres dropdb grotti; sudo -u postgres dropuser grotti
|
||||||
|
;;
|
||||||
|
remove)
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
remove|upgrade|deconfigure)
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl stop grotti.service 2>/dev/null || true
|
||||||
|
systemctl disable grotti.service 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
Vendored
Whitespace-only changes.
@@ -0,0 +1,24 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Grotti Farm-Manager
|
||||||
|
Documentation=file:///usr/share/doc/grotti/Anleitung-Installation-Pi.md
|
||||||
|
After=network-online.target postgresql.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=grotti
|
||||||
|
Group=grotti
|
||||||
|
WorkingDirectory=/usr/lib/grotti
|
||||||
|
Environment=GROTTI_CONFIG=/etc/grotti/config.json
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/usr/lib/grotti/venv/bin/python /usr/lib/grotti/server.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
# Logs → journald (journalctl -u grotti -f)
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=grotti
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -7,10 +7,12 @@ import time
|
|||||||
|
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
|
|
||||||
from grotti import db, jobs, library, store
|
from grotti import db, jobs, library, logutil, store
|
||||||
from grotti.config import load
|
from grotti.config import load
|
||||||
from web.app import create_app
|
from web.app import create_app
|
||||||
|
|
||||||
|
log = logutil.get("grotti.server")
|
||||||
|
|
||||||
|
|
||||||
def background(cfg):
|
def background(cfg):
|
||||||
def work():
|
def work():
|
||||||
@@ -20,10 +22,10 @@ def background(cfg):
|
|||||||
jobs.sync_history(conn, cfg)
|
jobs.sync_history(conn, cfg)
|
||||||
deleted = db.prune_run_samples(conn, keep_days=90)
|
deleted = db.prune_run_samples(conn, keep_days=90)
|
||||||
if deleted:
|
if deleted:
|
||||||
print("Proben gelöscht: %s" % deleted, flush=True)
|
log.info("Proben gelöscht: %s", deleted)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Abgleich: %s" % exc, flush=True)
|
log.warning("Abgleich: %s", exc)
|
||||||
|
|
||||||
threading.Thread(target=work, name="grotti-abgleich", daemon=True).start()
|
threading.Thread(target=work, name="grotti-abgleich", daemon=True).start()
|
||||||
|
|
||||||
@@ -39,14 +41,15 @@ def _retention_loop(cfg, stop_event):
|
|||||||
n = db.prune_run_samples(conn, keep_days=90)
|
n = db.prune_run_samples(conn, keep_days=90)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
if n:
|
if n:
|
||||||
print("Retention: %s Proben gelöscht." % n, flush=True)
|
log.info("Retention: %s Proben gelöscht.", n)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Retention: %s" % exc, flush=True)
|
log.warning("Retention: %s", exc)
|
||||||
stop_event.wait(3600)
|
stop_event.wait(3600)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
cfg = load()
|
cfg = load()
|
||||||
|
logutil.setup(cfg.get("log_level") or "INFO")
|
||||||
cfg["data_dir"].mkdir(parents=True, exist_ok=True)
|
cfg["data_dir"].mkdir(parents=True, exist_ok=True)
|
||||||
(cfg["data_dir"] / "eingang").mkdir(exist_ok=True)
|
(cfg["data_dir"] / "eingang").mkdir(exist_ok=True)
|
||||||
db.prepare(cfg["database_url"])
|
db.prepare(cfg["database_url"])
|
||||||
@@ -107,7 +110,7 @@ def main():
|
|||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
print("Grotti: http://%s:%s/" % (cfg["host"], cfg["port"]), flush=True)
|
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=16, channel_timeout=120)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Logging-Hilfen."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from grotti import logutil
|
||||||
|
|
||||||
|
|
||||||
|
class LogutilTests(unittest.TestCase):
|
||||||
|
def test_parse_level(self):
|
||||||
|
self.assertEqual(logutil.parse_level("DEBUG"), logging.DEBUG)
|
||||||
|
self.assertEqual(logutil.parse_level("warn"), logging.WARNING)
|
||||||
|
self.assertEqual(logutil.parse_level("nope"), logging.INFO)
|
||||||
|
|
||||||
|
def test_setup_idempotent(self):
|
||||||
|
logutil.setup("INFO")
|
||||||
|
logutil.setup("DEBUG")
|
||||||
|
self.assertEqual(logging.getLogger().level, logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -59,6 +59,35 @@ class MatchSpoolsTests(unittest.TestCase):
|
|||||||
ids = {m["spool_id"] for m in matched}
|
ids = {m["spool_id"] for m in matched}
|
||||||
self.assertEqual(ids, {3}) # Eryone PLA; Meta/Matte nicht
|
self.assertEqual(ids, {3}) # Eryone PLA; Meta/Matte nicht
|
||||||
|
|
||||||
|
def test_pla_lite_hex_matches_plain_pla(self):
|
||||||
|
# Orca: Profil „Eryone PLA Lite“, Farbe #26A69A — Lager: Eryone/Sunlu PLA „Grau“
|
||||||
|
need = {
|
||||||
|
"profile": "Eryone PLA Lite",
|
||||||
|
"material_type": "pla",
|
||||||
|
"color": "#26A69A",
|
||||||
|
"paint_index": 0,
|
||||||
|
}
|
||||||
|
have = [
|
||||||
|
{
|
||||||
|
"spool_id": 15,
|
||||||
|
"material_type": "pla",
|
||||||
|
"color": "grau",
|
||||||
|
"remaining_g": 873,
|
||||||
|
"material_name": "sunlu pla",
|
||||||
|
"ace_slot": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"spool_id": 1,
|
||||||
|
"material_type": "pla",
|
||||||
|
"color": "grau",
|
||||||
|
"remaining_g": 800,
|
||||||
|
"material_name": "sunlu pla meta",
|
||||||
|
"ace_slot": 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
matched = store._match_spools_for_need(need, have)
|
||||||
|
self.assertEqual({m["spool_id"] for m in matched}, {15})
|
||||||
|
|
||||||
def test_picks_meta_not_matte(self):
|
def test_picks_meta_not_matte(self):
|
||||||
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
|
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
|
||||||
matched = store._match_spools_for_need(need, self.have)
|
matched = store._match_spools_for_need(need, self.have)
|
||||||
|
|||||||
+12
-4
@@ -19,7 +19,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
|
|||||||
from grotti import auth as grotti_auth
|
from grotti import auth as grotti_auth
|
||||||
from grotti import config as grotti_config
|
from grotti import config as grotti_config
|
||||||
from grotti import db as grotti_db
|
from grotti import db as grotti_db
|
||||||
from grotti import eject, jobs, library, moonraker, notify, store
|
from grotti import eject, jobs, library, logutil, moonraker, notify, store
|
||||||
from grotti.catalog import color_catalog
|
from grotti.catalog import color_catalog
|
||||||
from grotti.cost import calculate, offer_from_file, order_actuals
|
from grotti.cost import calculate, offer_from_file, order_actuals
|
||||||
from grotti import orders as grotti_orders
|
from grotti import orders as grotti_orders
|
||||||
@@ -32,6 +32,8 @@ from grotti.version import __version__
|
|||||||
from grotti import verdict as grotti_verdict
|
from grotti import verdict as grotti_verdict
|
||||||
from grotti.watcher import StateWatcher
|
from grotti.watcher import StateWatcher
|
||||||
|
|
||||||
|
log = logutil.get("grotti.web")
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
TOOL_ROOT = ROOT.parent
|
TOOL_ROOT = ROOT.parent
|
||||||
DOCS_DIR = TOOL_ROOT / "docs"
|
DOCS_DIR = TOOL_ROOT / "docs"
|
||||||
@@ -316,7 +318,7 @@ def create_app(cfg):
|
|||||||
message = "Diesen Eintrag gibt es schon."
|
message = "Diesen Eintrag gibt es schon."
|
||||||
elif isinstance(err, psycopg.errors.InvalidTextRepresentation):
|
elif isinstance(err, psycopg.errors.InvalidTextRepresentation):
|
||||||
message = "Ungültiger Wert in der Anfrage."
|
message = "Ungültiger Wert in der Anfrage."
|
||||||
print("DB: %s" % err, flush=True)
|
log.warning("DB: %s", err)
|
||||||
return jsonify({"error": message, "code": "db"}), 500
|
return jsonify({"error": message, "code": "db"}), 500
|
||||||
|
|
||||||
def body():
|
def body():
|
||||||
@@ -582,6 +584,7 @@ def create_app(cfg):
|
|||||||
|
|
||||||
ANLEITUNG_ORDER = (
|
ANLEITUNG_ORDER = (
|
||||||
"Anleitung-Einrichtung",
|
"Anleitung-Einrichtung",
|
||||||
|
"Anleitung-Installation-Pi",
|
||||||
"Anleitung-Oberflaeche",
|
"Anleitung-Oberflaeche",
|
||||||
"Anleitung-Wand",
|
"Anleitung-Wand",
|
||||||
"Anleitung-Dateien",
|
"Anleitung-Dateien",
|
||||||
@@ -605,6 +608,7 @@ def create_app(cfg):
|
|||||||
def anleitung_title(name):
|
def anleitung_title(name):
|
||||||
labels = {
|
labels = {
|
||||||
"Anleitung-Einrichtung": "Einrichtung",
|
"Anleitung-Einrichtung": "Einrichtung",
|
||||||
|
"Anleitung-Installation-Pi": "Raspberry Pi (.deb)",
|
||||||
"Anleitung-Oberflaeche": "Oberfläche",
|
"Anleitung-Oberflaeche": "Oberfläche",
|
||||||
"Anleitung-Wand": "Druckerwand",
|
"Anleitung-Wand": "Druckerwand",
|
||||||
"Anleitung-Dateien": "Dateien senden",
|
"Anleitung-Dateien": "Dateien senden",
|
||||||
@@ -1698,7 +1702,7 @@ def create_app(cfg):
|
|||||||
try:
|
try:
|
||||||
jobs.apply_deduction(c, closed, cfg["energy"])
|
jobs.apply_deduction(c, closed, cfg["energy"])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Abräumen Abzug %s: %s" % (host, exc), flush=True)
|
log.warning("Abräumen Abzug %s: %s", host, exc)
|
||||||
row = store.get_run_row(c, closed["id"]) or closed
|
row = store.get_run_row(c, closed["id"]) or closed
|
||||||
run_json = store.run_out(row)
|
run_json = store.run_out(row)
|
||||||
reprint = None
|
reprint = None
|
||||||
@@ -2451,7 +2455,11 @@ def create_app(cfg):
|
|||||||
|
|
||||||
@app.get("/api/v1/laeufe")
|
@app.get("/api/v1/laeufe")
|
||||||
def run_list():
|
def run_list():
|
||||||
return jsonify(store.list_runs(conn()))
|
try:
|
||||||
|
limit = min(1000, max(1, int(request.args.get("limit") or 500)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 500
|
||||||
|
return jsonify(store.list_runs(conn(), limit=limit))
|
||||||
|
|
||||||
@app.get("/api/v1/laeufe/aktuell")
|
@app.get("/api/v1/laeufe/aktuell")
|
||||||
def run_aktuell():
|
def run_aktuell():
|
||||||
|
|||||||
+30
-8
@@ -2510,21 +2510,22 @@ article.machine.flash { animation: flash 2s var(--ease); }
|
|||||||
.lib-info > summary { list-style: none; }
|
.lib-info > summary { list-style: none; }
|
||||||
.lib-info > summary::-webkit-details-marker { display: none; }
|
.lib-info > summary::-webkit-details-marker { display: none; }
|
||||||
.lib-info-pop {
|
.lib-info-pop {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
right: 0;
|
z-index: 80;
|
||||||
top: calc(100% + 0.25rem);
|
|
||||||
z-index: 20;
|
|
||||||
min-width: 11rem;
|
min-width: 11rem;
|
||||||
|
max-width: min(18rem, calc(100vw - 1rem));
|
||||||
padding: 0.55rem 0.7rem;
|
padding: 0.55rem 0.7rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
background: #121217;
|
background: var(--card-2);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
font-size: var(--t-xs);
|
font-size: var(--t-xs);
|
||||||
color: var(--muted);
|
color: var(--ink);
|
||||||
display: grid;
|
display: none;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
.lib-info[open] > .lib-info-pop { display: grid; }
|
||||||
.picked-details { opacity: 0.9; }
|
.picked-details { opacity: 0.9; }
|
||||||
|
|
||||||
.flow-step { padding: 0.9rem 0 0.95rem; border-top: 1px solid var(--line); }
|
.flow-step { padding: 0.9rem 0 0.95rem; border-top: 1px solid var(--line); }
|
||||||
@@ -3644,8 +3645,29 @@ dialog::backdrop { background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(4px);
|
|||||||
margin-right: 0.35rem;
|
margin-right: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
}
|
||||||
|
.pager[hidden] { display: none !important; }
|
||||||
|
.pager-info {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
min-width: 7rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pager button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle { display: none !important; }
|
.sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle, .pager { display: none !important; }
|
||||||
.app-shell { display: block; }
|
.app-shell { display: block; }
|
||||||
.app-main { margin: 0; }
|
.app-main { margin: 0; }
|
||||||
body { background: #fff; color: #111; }
|
body { background: #fff; color: #111; }
|
||||||
|
|||||||
@@ -333,3 +333,47 @@ window.setConnectionBanner = setConnectionBanner;
|
|||||||
window.createPoller = createPoller;
|
window.createPoller = createPoller;
|
||||||
window.promptAbraeumen = promptAbraeumen;
|
window.promptAbraeumen = promptAbraeumen;
|
||||||
window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS;
|
window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS;
|
||||||
|
|
||||||
|
/** Listen-Pagination (clientseitig auf gefiltertem Array). */
|
||||||
|
const DEFAULT_PAGE_SIZE = 40;
|
||||||
|
|
||||||
|
function pageSlice(items, page, pageSize) {
|
||||||
|
const list = Array.isArray(items) ? items : [];
|
||||||
|
const size = Math.max(1, Number(pageSize) || DEFAULT_PAGE_SIZE);
|
||||||
|
const total = list.length;
|
||||||
|
const pages = Math.max(1, Math.ceil(total / size) || 1);
|
||||||
|
let p = Math.max(1, parseInt(page, 10) || 1);
|
||||||
|
if (p > pages) p = pages;
|
||||||
|
const start = (p - 1) * size;
|
||||||
|
return {
|
||||||
|
items: list.slice(start, start + size),
|
||||||
|
page: p,
|
||||||
|
pages,
|
||||||
|
total,
|
||||||
|
pageSize: size,
|
||||||
|
from: total ? start + 1 : 0,
|
||||||
|
to: Math.min(start + size, total),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPager(el, state, onPage) {
|
||||||
|
if (!el) return;
|
||||||
|
const s = state || pageSlice([], 1);
|
||||||
|
if (s.total <= s.pageSize) {
|
||||||
|
el.hidden = true;
|
||||||
|
el.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.hidden = false;
|
||||||
|
el.className = "pager";
|
||||||
|
el.innerHTML =
|
||||||
|
'<button type="button" class="ghost pager-prev" ' + (s.page <= 1 ? "disabled" : "") + ">Zurück</button>" +
|
||||||
|
'<span class="pager-info">' + s.from + "–" + s.to + " von " + s.total + "</span>" +
|
||||||
|
'<button type="button" class="ghost pager-next" ' + (s.page >= s.pages ? "disabled" : "") + ">Weiter</button>";
|
||||||
|
el.querySelector(".pager-prev").onclick = () => onPage(s.page - 1);
|
||||||
|
el.querySelector(".pager-next").onclick = () => onPage(s.page + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
||||||
|
window.pageSlice = pageSlice;
|
||||||
|
window.renderPager = renderPager;
|
||||||
@@ -26,9 +26,12 @@
|
|||||||
<div class="skeleton" style="height:4rem"></div>
|
<div class="skeleton" style="height:4rem"></div>
|
||||||
<div class="skeleton" style="height:4rem"></div>
|
<div class="skeleton" style="height:4rem"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pager" id="tl-pager" hidden></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
<script>
|
<script>
|
||||||
|
let tlPage = 1;
|
||||||
|
let tlItems = [];
|
||||||
const KINDS = [
|
const KINDS = [
|
||||||
["queue", "Warteschlange"],
|
["queue", "Warteschlange"],
|
||||||
["ablauf", "Ablauf"],
|
["ablauf", "Ablauf"],
|
||||||
@@ -111,14 +114,19 @@ function itemHtml(ev) {
|
|||||||
function render(items) {
|
function render(items) {
|
||||||
const box = document.getElementById("timeline");
|
const box = document.getElementById("timeline");
|
||||||
const note = document.getElementById("tl-note");
|
const note = document.getElementById("tl-note");
|
||||||
if (!items.length) {
|
const pager = document.getElementById("tl-pager");
|
||||||
|
tlItems = items || [];
|
||||||
|
if (!tlItems.length) {
|
||||||
box.innerHTML = `<div class="card empty-state">${icon("activity")}<p>Keine Einträge${filter.range === "day" ? " heute" : ""}.</p>${
|
box.innerHTML = `<div class="card empty-state">${icon("activity")}<p>Keine Einträge${filter.range === "day" ? " heute" : ""}.</p>${
|
||||||
filter.range !== "all" ? `<button type="button" class="ghost" data-range-jump="all">Alle anzeigen</button>` : ""}</div>`;
|
filter.range !== "all" ? `<button type="button" class="ghost" data-range-jump="all">Alle anzeigen</button>` : ""}</div>`;
|
||||||
note.textContent = "";
|
note.textContent = "";
|
||||||
|
renderPager(pager, null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const paged = pageSlice(tlItems, tlPage, DEFAULT_PAGE_SIZE);
|
||||||
|
tlPage = paged.page;
|
||||||
const groups = [];
|
const groups = [];
|
||||||
for (const ev of items) {
|
for (const ev of paged.items) {
|
||||||
const label = dayLabel(ev.created_at);
|
const label = dayLabel(ev.created_at);
|
||||||
if (!groups.length || groups[groups.length - 1].label !== label) groups.push({ label, items: [] });
|
if (!groups.length || groups[groups.length - 1].label !== label) groups.push({ label, items: [] });
|
||||||
groups[groups.length - 1].items.push(ev);
|
groups[groups.length - 1].items.push(ev);
|
||||||
@@ -126,7 +134,11 @@ function render(items) {
|
|||||||
box.innerHTML = groups.map((g) =>
|
box.innerHTML = groups.map((g) =>
|
||||||
`<section class="tl-day"><h3>${escapeHtml(g.label)} · ${g.items.length}</h3><ol class="tl-list">${g.items.map(itemHtml).join("")}</ol></section>`
|
`<section class="tl-day"><h3>${escapeHtml(g.label)} · ${g.items.length}</h3><ol class="tl-list">${g.items.map(itemHtml).join("")}</ol></section>`
|
||||||
).join("");
|
).join("");
|
||||||
note.textContent = items.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : "";
|
note.textContent = tlItems.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : "";
|
||||||
|
renderPager(pager, paged, (p) => {
|
||||||
|
tlPage = p;
|
||||||
|
render(tlItems);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -137,6 +149,7 @@ async function load() {
|
|||||||
if (filter.host) q.set("host", filter.host);
|
if (filter.host) q.set("host", filter.host);
|
||||||
try {
|
try {
|
||||||
const data = await api("/ereignisse?" + q.toString());
|
const data = await api("/ereignisse?" + q.toString());
|
||||||
|
tlPage = 1;
|
||||||
render(data.items || []);
|
render(data.items || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById("timeline").innerHTML = `<div class="strip bad">${icon("alert")}<span>${escapeHtml(err.message)}</span></div>`;
|
document.getElementById("timeline").innerHTML = `<div class="strip bad">${icon("alert")}<span>${escapeHtml(err.message)}</span></div>`;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead>
|
<thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead>
|
||||||
<tbody id="order-rows"></tbody>
|
<tbody id="order-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="pager" id="order-pager" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -246,16 +247,22 @@ async function einreihen(itemId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let orderPage = 1;
|
||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
const body = document.getElementById("order-rows");
|
const body = document.getElementById("order-rows");
|
||||||
body.innerHTML = "";
|
body.innerHTML = "";
|
||||||
|
const pager = document.getElementById("order-pager");
|
||||||
if (!orders.length) {
|
if (!orders.length) {
|
||||||
body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="ghost" id="order-empty-new">Ersten Auftrag anlegen →</button></p></div></td></tr>';
|
body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="ghost" id="order-empty-new">Ersten Auftrag anlegen →</button></p></div></td></tr>';
|
||||||
document.getElementById("order-empty-new")?.addEventListener("click", () => fillOrder(null));
|
document.getElementById("order-empty-new")?.addEventListener("click", () => fillOrder(null));
|
||||||
|
renderPager(pager, null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selected = document.getElementById("order-id").value;
|
const selected = document.getElementById("order-id").value;
|
||||||
orders.forEach((order) => {
|
const paged = pageSlice(orders, orderPage, DEFAULT_PAGE_SIZE);
|
||||||
|
orderPage = paged.page;
|
||||||
|
paged.items.forEach((order) => {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
|
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
|
||||||
tr.innerHTML = "<td></td><td></td><td></td>";
|
tr.innerHTML = "<td></td><td></td><td></td>";
|
||||||
@@ -275,12 +282,17 @@ function renderList() {
|
|||||||
});
|
});
|
||||||
body.appendChild(tr);
|
body.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
renderPager(pager, paged, (p) => {
|
||||||
|
orderPage = p;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadList() {
|
async function loadList() {
|
||||||
const st = document.getElementById("filter-status").value;
|
const st = document.getElementById("filter-status").value;
|
||||||
const q = st ? "?status=" + encodeURIComponent(st) : "";
|
const q = st ? "?status=" + encodeURIComponent(st) : "";
|
||||||
orders = await api("/auftraege" + q);
|
orders = await api("/auftraege" + q);
|
||||||
|
orderPage = 1;
|
||||||
renderList();
|
renderList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
<div class="skeleton" style="height:4rem"></div>
|
<div class="skeleton" style="height:4rem"></div>
|
||||||
<div class="skeleton" style="height:4rem"></div>
|
<div class="skeleton" style="height:4rem"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pager" id="lib-pager" hidden></div>
|
||||||
<p class="drop-hint">Dateien hierher ziehen zum Hochladen.</p>
|
<p class="drop-hint">Dateien hierher ziehen zum Hochladen.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -839,6 +840,39 @@ function infoPopupHtml(file) {
|
|||||||
</details>`;
|
</details>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function placeLibInfoPop(details) {
|
||||||
|
const pop = details.querySelector(".lib-info-pop");
|
||||||
|
const anchor = details.querySelector("summary");
|
||||||
|
if (!pop || !anchor) return;
|
||||||
|
const margin = 6;
|
||||||
|
const r = anchor.getBoundingClientRect();
|
||||||
|
pop.style.left = "0px";
|
||||||
|
pop.style.top = "0px";
|
||||||
|
const pw = pop.offsetWidth;
|
||||||
|
const ph = pop.offsetHeight;
|
||||||
|
let left = r.right - pw;
|
||||||
|
left = Math.max(margin, Math.min(left, window.innerWidth - pw - margin));
|
||||||
|
let top = r.bottom + margin;
|
||||||
|
if (top + ph > window.innerHeight - margin && r.top - margin - ph >= margin) {
|
||||||
|
top = r.top - margin - ph;
|
||||||
|
} else {
|
||||||
|
top = Math.min(top, Math.max(margin, window.innerHeight - ph - margin));
|
||||||
|
}
|
||||||
|
pop.style.left = left + "px";
|
||||||
|
pop.style.top = top + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOpenLibInfo(except) {
|
||||||
|
document.querySelectorAll("details.lib-info[open]").forEach((node) => {
|
||||||
|
if (node !== except) node.removeAttribute("open");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function repositionOpenLibInfo() {
|
||||||
|
const open = document.querySelector("details.lib-info[open]");
|
||||||
|
if (open) placeLibInfoPop(open);
|
||||||
|
}
|
||||||
|
|
||||||
function thumbHtml(file) {
|
function thumbHtml(file) {
|
||||||
return file.preview
|
return file.preview
|
||||||
? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">`
|
? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">`
|
||||||
@@ -856,6 +890,8 @@ function libItemHtml(file) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let libPage = 1;
|
||||||
|
|
||||||
function renderLibrary() {
|
function renderLibrary() {
|
||||||
const list = document.getElementById("lib-list");
|
const list = document.getElementById("lib-list");
|
||||||
const shown = filteredFiles();
|
const shown = filteredFiles();
|
||||||
@@ -863,15 +899,19 @@ function renderLibrary() {
|
|||||||
document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length;
|
document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length;
|
||||||
if (!files.length) {
|
if (!files.length) {
|
||||||
list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>';
|
list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>';
|
||||||
|
renderPager(document.getElementById("lib-pager"), null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!shown.length) {
|
if (!shown.length) {
|
||||||
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
|
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
|
||||||
|
renderPager(document.getElementById("lib-pager"), null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const paged = pageSlice(shown, libPage, DEFAULT_PAGE_SIZE);
|
||||||
|
libPage = paged.page;
|
||||||
const parts = [];
|
const parts = [];
|
||||||
let lastGroup = null;
|
let lastGroup = null;
|
||||||
shown.forEach((file) => {
|
paged.items.forEach((file) => {
|
||||||
let group = null;
|
let group = null;
|
||||||
if (sort === "material") group = materialKey(file) || "Ohne Material";
|
if (sort === "material") group = materialKey(file) || "Ohne Material";
|
||||||
else if (sort === "folder") group = folderLabel(folderKey(file));
|
else if (sort === "folder") group = folderLabel(folderKey(file));
|
||||||
@@ -882,6 +922,10 @@ function renderLibrary() {
|
|||||||
parts.push(libItemHtml(file));
|
parts.push(libItemHtml(file));
|
||||||
});
|
});
|
||||||
list.innerHTML = parts.join("");
|
list.innerHTML = parts.join("");
|
||||||
|
renderPager(document.getElementById("lib-pager"), paged, (p) => {
|
||||||
|
libPage = p;
|
||||||
|
renderLibrary();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Flow rechts ---------- */
|
/* ---------- Flow rechts ---------- */
|
||||||
@@ -1546,6 +1590,23 @@ async function doAblauf() {
|
|||||||
|
|
||||||
/* ---------- Ereignisse ---------- */
|
/* ---------- Ereignisse ---------- */
|
||||||
|
|
||||||
|
document.getElementById("lib-list").addEventListener("toggle", (event) => {
|
||||||
|
const details = event.target;
|
||||||
|
if (!(details instanceof HTMLDetailsElement) || !details.classList.contains("lib-info")) return;
|
||||||
|
if (details.open) {
|
||||||
|
closeOpenLibInfo(details);
|
||||||
|
placeLibInfoPop(details);
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.getElementById("lib-list").addEventListener("scroll", repositionOpenLibInfo, { passive: true });
|
||||||
|
window.addEventListener("resize", repositionOpenLibInfo);
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", (event) => {
|
||||||
|
if (event.target.closest("details.lib-info")) return;
|
||||||
|
closeOpenLibInfo();
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("lib-list").addEventListener("click", (event) => {
|
document.getElementById("lib-list").addEventListener("click", (event) => {
|
||||||
if (event.target.closest(".lib-move")) {
|
if (event.target.closest(".lib-move")) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -1628,6 +1689,7 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => {
|
|||||||
const node = document.getElementById(id);
|
const node = document.getElementById(id);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const onChange = () => {
|
const onChange = () => {
|
||||||
|
libPage = 1;
|
||||||
saveLibMemory();
|
saveLibMemory();
|
||||||
renderLibrary();
|
renderLibrary();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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=68">
|
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=70">
|
||||||
<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>
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
<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>
|
||||||
<script src="{{ url_for('static', filename='api.js') }}?v=32"></script>
|
<script src="{{ url_for('static', filename='api.js') }}?v=32"></script>
|
||||||
<script src="{{ url_for('static', filename='ui.js') }}?v=5"></script>
|
<script src="{{ url_for('static', filename='ui.js') }}?v=6"></script>
|
||||||
<script>
|
<script>
|
||||||
async function doLogout() {
|
async function doLogout() {
|
||||||
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
|
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
<thead><tr><th>Spule</th><th>Charge</th><th>Sorte</th><th>Farbe</th><th>Ort</th><th class="num">Rest</th></tr></thead>
|
<thead><tr><th>Spule</th><th>Charge</th><th>Sorte</th><th>Farbe</th><th>Ort</th><th class="num">Rest</th></tr></thead>
|
||||||
<tbody id="spool-rows"></tbody>
|
<tbody id="spool-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="pager" id="spool-pager" hidden></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card mat-form" id="spool-panel" hidden>
|
<section class="card mat-form" id="spool-panel" hidden>
|
||||||
@@ -683,6 +684,8 @@ function fillSelects() {
|
|||||||
materialSelect.value = currentMaterial;
|
materialSelect.value = currentMaterial;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let spoolPage = 1;
|
||||||
|
|
||||||
function renderSpools() {
|
function renderSpools() {
|
||||||
document.querySelectorAll("#spool-filter .fchip").forEach((btn) => {
|
document.querySelectorAll("#spool-filter .fchip").forEach((btn) => {
|
||||||
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
|
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
|
||||||
@@ -698,14 +701,17 @@ function renderSpools() {
|
|||||||
spoolFilter === "archive" ? shown.length : activeCount
|
spoolFilter === "archive" ? shown.length : activeCount
|
||||||
);
|
);
|
||||||
const note = document.getElementById("spool-list-note");
|
const note = document.getElementById("spool-list-note");
|
||||||
|
const pager = document.getElementById("spool-pager");
|
||||||
if (!spools.length) {
|
if (!spools.length) {
|
||||||
note.textContent = "";
|
note.textContent = "";
|
||||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("spool") + '<p>Noch keine Spule.</p><p class="tiny">„Neue Spule“ anlegen oder Code scannen.</p></div></td></tr>';
|
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("spool") + '<p>Noch keine Spule.</p><p class="tiny">„Neue Spule“ anlegen oder Code scannen.</p></div></td></tr>';
|
||||||
|
renderPager(pager, null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!shown.length) {
|
if (!shown.length) {
|
||||||
note.textContent = "";
|
note.textContent = "";
|
||||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>';
|
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>';
|
||||||
|
renderPager(pager, null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
note.textContent = spoolFilter === "archive"
|
note.textContent = spoolFilter === "archive"
|
||||||
@@ -719,8 +725,10 @@ function renderSpools() {
|
|||||||
const bh = b.printer_host || "zzz";
|
const bh = b.printer_host || "zzz";
|
||||||
return ah.localeCompare(bh, "de") || (Number(a.ace_slot) || 0) - (Number(b.ace_slot) || 0) || a.name.localeCompare(b.name, "de");
|
return ah.localeCompare(bh, "de") || (Number(a.ace_slot) || 0) - (Number(b.ace_slot) || 0) || a.name.localeCompare(b.name, "de");
|
||||||
});
|
});
|
||||||
|
const paged = pageSlice(ordered, spoolPage, DEFAULT_PAGE_SIZE);
|
||||||
|
spoolPage = paged.page;
|
||||||
let lastPlace = null;
|
let lastPlace = null;
|
||||||
ordered.forEach((spool) => {
|
paged.items.forEach((spool) => {
|
||||||
const group = spool.printer_host ? printerName(spool.printer_host) : "Regal";
|
const group = spool.printer_host ? printerName(spool.printer_host) : "Regal";
|
||||||
if (group !== lastPlace) {
|
if (group !== lastPlace) {
|
||||||
lastPlace = group;
|
lastPlace = group;
|
||||||
@@ -753,6 +761,10 @@ function renderSpools() {
|
|||||||
tr.addEventListener("click", () => fill(spool));
|
tr.addEventListener("click", () => fill(spool));
|
||||||
body.appendChild(tr);
|
body.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
renderPager(pager, paged, (p) => {
|
||||||
|
spoolPage = p;
|
||||||
|
renderSpools();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSorten() {
|
function renderSorten() {
|
||||||
@@ -867,6 +879,7 @@ document.querySelectorAll(".mat-tabs [data-view]").forEach((btn) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("spool-filter").addEventListener("click", (event) => {
|
document.getElementById("spool-filter").addEventListener("click", (event) => {
|
||||||
|
spoolPage = 1;
|
||||||
const btn = event.target.closest("[data-filter]");
|
const btn = event.target.closest("[data-filter]");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
spoolFilter = btn.dataset.filter || "all";
|
spoolFilter = btn.dataset.filter || "all";
|
||||||
@@ -874,6 +887,7 @@ document.getElementById("spool-filter").addEventListener("click", (event) => {
|
|||||||
});
|
});
|
||||||
document.getElementById("spool-search").addEventListener("input", (event) => {
|
document.getElementById("spool-search").addEventListener("input", (event) => {
|
||||||
spoolSearch = event.target.value || "";
|
spoolSearch = event.target.value || "";
|
||||||
|
spoolPage = 1;
|
||||||
renderSpools();
|
renderSpools();
|
||||||
});
|
});
|
||||||
document.getElementById("sorten-search").addEventListener("input", (event) => {
|
document.getElementById("sorten-search").addEventListener("input", (event) => {
|
||||||
@@ -996,10 +1010,12 @@ document.getElementById("spool-delete").addEventListener("click", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.getElementById("spool-filter-type")?.addEventListener("change", (event) => {
|
document.getElementById("spool-filter-type")?.addEventListener("change", (event) => {
|
||||||
|
spoolPage = 1;
|
||||||
spoolTypeFilter = event.target.value || "";
|
spoolTypeFilter = event.target.value || "";
|
||||||
renderSpools();
|
renderSpools();
|
||||||
});
|
});
|
||||||
document.getElementById("spool-filter-color")?.addEventListener("change", (event) => {
|
document.getElementById("spool-filter-color")?.addEventListener("change", (event) => {
|
||||||
|
spoolPage = 1;
|
||||||
spoolColorFilter = event.target.value || "";
|
spoolColorFilter = event.target.value || "";
|
||||||
renderSpools();
|
renderSpools();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th></tr></thead>
|
<thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th></tr></thead>
|
||||||
<tbody id="proj-rows"></tbody>
|
<tbody id="proj-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="pager" id="proj-pager" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -110,15 +111,21 @@ function fill(project) {
|
|||||||
renderDetail(project);
|
renderDetail(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let projPage = 1;
|
||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
const body = document.getElementById("proj-rows");
|
const body = document.getElementById("proj-rows");
|
||||||
|
const pager = document.getElementById("proj-pager");
|
||||||
document.getElementById("proj-count").textContent = String(projects.length);
|
document.getElementById("proj-count").textContent = String(projects.length);
|
||||||
if (!projects.length) {
|
if (!projects.length) {
|
||||||
body.innerHTML = '<tr><td class="empty" colspan="4"><div class="empty-state">' + icon("folder") + "<p>Noch kein Projekt.</p></div></td></tr>";
|
body.innerHTML = '<tr><td class="empty" colspan="4"><div class="empty-state">' + icon("folder") + "<p>Noch kein Projekt.</p></div></td></tr>";
|
||||||
|
renderPager(pager, null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
body.innerHTML = "";
|
body.innerHTML = "";
|
||||||
projects.forEach((project) => {
|
const paged = pageSlice(projects, projPage, DEFAULT_PAGE_SIZE);
|
||||||
|
projPage = paged.page;
|
||||||
|
paged.items.forEach((project) => {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.className = "pick" + (String(project.id) === String(selectedId) ? " on" : "");
|
tr.className = "pick" + (String(project.id) === String(selectedId) ? " on" : "");
|
||||||
tr.innerHTML = "<td></td><td></td><td></td><td class=\"num\"></td>";
|
tr.innerHTML = "<td></td><td></td><td></td><td class=\"num\"></td>";
|
||||||
@@ -130,6 +137,10 @@ function renderList() {
|
|||||||
tr.addEventListener("click", () => openProject(project.id));
|
tr.addEventListener("click", () => openProject(project.id));
|
||||||
body.appendChild(tr);
|
body.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
renderPager(pager, paged, (p) => {
|
||||||
|
projPage = p;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDetail(project) {
|
function renderDetail(project) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
|
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
|
||||||
<tbody id="run-rows"></tbody>
|
<tbody id="run-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="pager" id="run-pager" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
@@ -37,6 +38,7 @@ const states = {
|
|||||||
};
|
};
|
||||||
let runs = [];
|
let runs = [];
|
||||||
let spools = [];
|
let spools = [];
|
||||||
|
let runPage = 1;
|
||||||
|
|
||||||
function fillPrinterFilter() {
|
function fillPrinterFilter() {
|
||||||
const select = document.getElementById("run-filter-printer");
|
const select = document.getElementById("run-filter-printer");
|
||||||
@@ -82,13 +84,17 @@ function render() {
|
|||||||
const shown = filteredRuns();
|
const shown = filteredRuns();
|
||||||
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, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!shown.length) {
|
if (!shown.length) {
|
||||||
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("filter") + '<p>Kein Lauf passt zum Filter.</p></div></td></tr>';
|
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("filter") + '<p>Kein Lauf passt zum Filter.</p></div></td></tr>';
|
||||||
|
renderPager(document.getElementById("run-pager"), null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
shown.forEach((run) => {
|
const paged = pageSlice(shown, runPage, DEFAULT_PAGE_SIZE);
|
||||||
|
runPage = paged.page;
|
||||||
|
paged.items.forEach((run) => {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.className = "pick";
|
tr.className = "pick";
|
||||||
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
|
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
|
||||||
@@ -233,16 +239,21 @@ function render() {
|
|||||||
}
|
}
|
||||||
body.appendChild(tr);
|
body.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
renderPager(document.getElementById("run-pager"), paged, (p) => {
|
||||||
|
runPage = p;
|
||||||
|
render();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
[runs, spools] = await Promise.all([api("/laeufe"), api("/spulen")]);
|
[runs, spools] = await Promise.all([api("/laeufe?limit=500"), api("/spulen")]);
|
||||||
|
runPage = 1;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
["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", render);
|
document.getElementById(id).addEventListener("change", () => { runPage = 1; render(); });
|
||||||
document.getElementById(id).addEventListener("input", render);
|
document.getElementById(id).addEventListener("input", () => { runPage = 1; render(); });
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("sync").addEventListener("click", async () => {
|
document.getElementById("sync").addEventListener("click", async () => {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="q-rows"></tbody>
|
<tbody id="q-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="pager" id="q-pager" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
@@ -45,6 +46,7 @@ const STATUS_LABEL = {
|
|||||||
let jobs = [];
|
let jobs = [];
|
||||||
let liveDirect = [];
|
let liveDirect = [];
|
||||||
let filter = "";
|
let filter = "";
|
||||||
|
let qPage = 1;
|
||||||
|
|
||||||
function targetOf(job) {
|
function targetOf(job) {
|
||||||
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
|
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
|
||||||
@@ -86,6 +88,7 @@ function render() {
|
|||||||
});
|
});
|
||||||
const body = document.getElementById("q-rows");
|
const body = document.getElementById("q-rows");
|
||||||
const shown = shownJobs();
|
const shown = shownJobs();
|
||||||
|
const pager = document.getElementById("q-pager");
|
||||||
if (!shown.length) {
|
if (!shown.length) {
|
||||||
const hint = jobs.length
|
const hint = jobs.length
|
||||||
? "Nichts in dieser Ansicht."
|
? "Nichts in dieser Ansicht."
|
||||||
@@ -97,10 +100,13 @@ function render() {
|
|||||||
: "";
|
: "";
|
||||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p>' + cta + '</div></td></tr>';
|
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p>' + cta + '</div></td></tr>';
|
||||||
document.getElementById("q-note").textContent = "";
|
document.getElementById("q-note").textContent = "";
|
||||||
|
renderPager(pager, null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const waiting = jobs.filter((j) => j.status === "waiting");
|
const waiting = jobs.filter((j) => j.status === "waiting");
|
||||||
body.innerHTML = shown.map((job) => {
|
const paged = pageSlice(shown, qPage, DEFAULT_PAGE_SIZE);
|
||||||
|
qPage = paged.page;
|
||||||
|
body.innerHTML = paged.items.map((job) => {
|
||||||
const st = job.status;
|
const st = job.status;
|
||||||
if (job.direct) {
|
if (job.direct) {
|
||||||
return `<tr data-direct="${escapeHtml(job.host)}">
|
return `<tr data-direct="${escapeHtml(job.host)}">
|
||||||
@@ -141,17 +147,22 @@ function render() {
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge";
|
document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge";
|
||||||
|
renderPager(pager, paged, (p) => {
|
||||||
|
qPage = p;
|
||||||
|
render();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [queue, active] = await Promise.all([
|
const [queue, active] = await Promise.all([
|
||||||
api("/druck-queue?limit=120"),
|
api("/druck-queue?limit=200"),
|
||||||
api("/laeuft").catch(() => []),
|
api("/laeuft").catch(() => []),
|
||||||
]);
|
]);
|
||||||
jobs = queue || [];
|
jobs = queue || [];
|
||||||
const queuedHosts = new Set(jobs.filter((j) => j.status === "running").map((j) => j.host));
|
const queuedHosts = new Set(jobs.filter((j) => j.status === "running").map((j) => j.host));
|
||||||
liveDirect = (active || []).filter((p) => p.host && !queuedHosts.has(p.host));
|
liveDirect = (active || []).filter((p) => p.host && !queuedHosts.has(p.host));
|
||||||
|
qPage = 1;
|
||||||
render();
|
render();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
document.getElementById("q-rows").innerHTML =
|
document.getElementById("q-rows").innerHTML =
|
||||||
@@ -163,6 +174,7 @@ document.getElementById("q-filter").addEventListener("click", (event) => {
|
|||||||
const btn = event.target.closest("[data-status]");
|
const btn = event.target.closest("[data-status]");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
filter = btn.dataset.status || "";
|
filter = btn.dataset.status || "";
|
||||||
|
qPage = 1;
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
document.getElementById("q-refresh").addEventListener("click", load);
|
document.getElementById("q-refresh").addEventListener("click", load);
|
||||||
|
|||||||
Reference in new issue
Block a user