From 2b1e36634a0913fe30485581761beeea2194c0e9 Mon Sep 17 00:00:00 2001 From: Dominic Gundel Date: Sun, 25 Oct 2026 08:24:24 +0100 Subject: [PATCH] =?UTF-8?q?Grotti=203.6.0:=20Lauf-Identit=C3=A4t=20nach=20?= =?UTF-8?q?Filament-Pause,=20robustere=20Queue,=20Projekte/Auftr=C3=A4ge?= =?UTF-8?q?=20list-first=20und=20API-Polish.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 38 ++ README.md | 19 +- docs/Anleitung-ACE.md | 4 +- docs/Anleitung-Dateien.md | 2 +- docs/Anleitung-Einrichtung.md | 6 +- docs/Anleitung-Einstellungen.md | 2 +- docs/Anleitung-Installation-Pi.md | 28 + docs/Anleitung-Material.md | 4 +- docs/Anleitung-Projekte.md | 16 +- docs/Anleitung-Wand.md | 7 +- docs/internal/API.md | 86 +++ docs/internal/Anycubic-Stack.md | 10 + docs/internal/Lauf-Identitaet.md | 31 + docs/internal/Ops-Queue-Alerts.md | 2 +- docs/internal/README.md | 3 + docs/internal/openapi.yaml | 180 ++++++ grotti/daily_report.py | 5 +- grotti/jetzt.py | 2 +- grotti/jobs.py | 613 ++++++++++++++++--- grotti/migrations/014_run_jobs.sql | 16 + grotti/migrations/015_active_run.sql | 6 + grotti/migrations/016_queue_run.sql | 10 + grotti/print_queue.py | 204 ++++++- grotti/store.py | 862 +++++++++++++++++++++++++-- grotti/version.py | 2 +- grotti/watcher.py | 219 ++++--- packaging/build-deb.sh | 6 + requirements.txt | 2 +- scripts/laeufe_reparieren.py | 136 +++++ scripts/pfade_umziehen.py | 98 +++ tests/test_history_shell.py | 107 ++++ tests/test_jobs_spool.py | 10 + tests/test_material_routing.py | 24 +- tests/test_print_queue.py | 42 +- tests/test_run_identity.py | 79 +++ tests/test_watcher_offline.py | 142 +++++ web/app.py | 255 +++++++- web/static/grotti.css | 181 +++++- web/static/ui.js | 50 ++ web/templates/_live_panel.html | 2 +- web/templates/aktivitaet.html | 26 +- web/templates/auftraege.html | 311 +++++++--- web/templates/bericht.html | 16 +- web/templates/dateien.html | 31 +- web/templates/einstellungen.html | 21 +- web/templates/kameras.html | 136 ----- web/templates/layout.html | 22 +- web/templates/material.html | 28 +- web/templates/projekte.html | 401 +++++++++++-- web/templates/protokoll.html | 95 ++- web/templates/statistik.html | 41 +- web/templates/wand.html | 197 +++++- web/templates/warteschlange.html | 54 +- 53 files changed, 4254 insertions(+), 636 deletions(-) create mode 100644 docs/internal/API.md create mode 100644 docs/internal/Lauf-Identitaet.md create mode 100644 docs/internal/openapi.yaml create mode 100644 grotti/migrations/014_run_jobs.sql create mode 100644 grotti/migrations/015_active_run.sql create mode 100644 grotti/migrations/016_queue_run.sql create mode 100644 scripts/laeufe_reparieren.py create mode 100644 scripts/pfade_umziehen.py create mode 100644 tests/test_history_shell.py create mode 100644 tests/test_run_identity.py create mode 100644 tests/test_watcher_offline.py delete mode 100644 web/templates/kameras.html diff --git a/CHANGELOG.md b/CHANGELOG.md index c23159f..0870217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,44 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo ## [Unreleased] +## [3.6.0] – 2026-10-25 + +### Neu + +- **Projekte:** Kennzahlen (Läufe, Zeit, Filament, Kosten), Tab Läufe, Einreihen / Alle einreihen, Filter Aktiv/Fertig, Deep-Link `#id`. API: `GET …/laeufe`, `POST …/einreihen`. +- **Wand-Cockpit:** Block „Als Nächstes“ — Nochmal, Queue-Hinweis, passende Dateien (Start oder Anpassen unter Dateien). +- **Querverlinkung:** Datei/Projekt/Spule/Auftrag/Drucker verlinkt in Protokoll, Bericht, Warteschlange, Aufträge, Material, Statistik (Projektfilter), Dateien. +- **API für Apps:** `POST /auth/token`, QR bei Token-Erzeugung, Server-Filter für Dateien/Queue/Spulen (`limit`/`offset`, `X-Total-Count`), `?token=` für Datei-Vorschau und Assets, `/version` mit `api`/`features`, Aktions-Alias `resume`/`cancel`. Doku: `docs/internal/API.md`, `openapi.yaml`. +- **Pi:** Umzugsanleitung Windows→Pi, Skript `scripts/pfade_umziehen.py` im `.deb`. +- **Protokoll Verbinden:** Verdachtspaare nach Runout manuell zusammenlegen (`POST /laeufe//verbinden`). Liste zeigt Rollenwechsel, mehrere Spulen und Pausendauer. +- **Reparatur:** `scripts/laeufe_reparieren.py` findet Alt-Paare und verwaiste Live-Läufe (Probelauf standardmäßig). Orphan-Übernahme nur bei gleicher Datei und wenn der Live-Lauf wirklich verschwindet; kein Host-Fallback auf fremde offene Drucke. + +### Geändert + +- **Projekte / Aufträge:** Liste zuerst (volle Breite); Formular und Detail nur bei Neu, Auswahl oder Bearbeiten — wie Material. Aufträge: Positionen ohne `prompt()`, Kopf-Button „Neuer Auftrag“. +- **Layout:** `.edit-split.is-idle`, Split/Panel ab 1080px einspaltig; Pfad-Ellipsis in Tabellen; Protokoll-Hilfe einklappbar; Queue-Aktionen mit Textlabels; Jetzt-Seite zeigt „Alles erledigt“; Aktivität Drucker-Filter als Select; Statistik-Filter unter „Weitere Filter“; Nav Aufträge = Clipboard-Icon; Mobile-Mehr gruppiert. +- **Material-Routing:** PLA ist PLA. Meta, Matte, Silk und die Marke halten die Queue nicht mehr an; liegt die genannte Sorte am Drucker, wird sie weiter bevorzugt. Ein anderer Typ (PETG statt PLA) bleibt ein Wartegrund. +- **mcp** auf `2.0.0` gepinnt. +- **Lauf-Identität:** Zusammenlegen nur mit Beweis (`print_duration` fortgesetzt + Lücke ≤ 5 s, oder Watcher „druckt weiter“). Leere History-Hüllen werden nicht gespeichert. `sync_history` unter Advisory-Lock; Abzug atomar. Doku: `docs/internal/Lauf-Identitaet.md`. +- **Queue `print_active`:** Nur `complete` = fertig. `cancelled`/`standby` werden entprellt; echter Abbruch heißt „Druck abgebrochen“, nicht „Fertig.“. Kein Auto-Retry nach Abbruch. Worker respektiert Abbruch während Upload. `print_queue.run_id` + höchstens ein `running` pro Host. +- **Wand Runout:** Hinweis nennt ACE-Platz und verlinkt Material mit vorausgefülltem Drucker/Platz. +- **Live-UI:** SSE `queue`/`drucker` aktualisiert Warteschlange und Protokoll. + +### Behoben + +- **Legacy-Kameras-Seite:** totes Template entfernt (Route leitete schon auf die Wand um). +- **Laufende Charge bleibt am History-Job:** Kommt die Moonraker-Job-ID, übernimmt dieser Lauf die Nummer. Sie fällt nicht weg, nur weil der Druck noch läuft. +- **Alte History-Jobs verbrauchen die Charge von heute:** Eine Filament-Pause öffnet eine zweite Job-ID. Bleibt die ohne Ende und ohne Dauer in der History stehen, hing Grotti sie als neuen Druck an den heutigen Tag und buchte das Filament noch einmal ab. Die Job-ID bleibt am ursprünglichen Lauf, eine leere Hülle überschreibt ihn nicht, und die Charge trägt das Startdatum des Drucks. +- **Kontaktverlust spaltet den Druck:** Zwei fehlende Polls melden den Drucker offline, lassen den Lauf aber offen und ziehen nichts ab. Dieselbe Charge läuft weiter, wenn derselbe Auftrag wieder da ist. Erst fertig, Abbruch, Fehler oder ein anderer Auftrag schließt den Lauf. +- **Queue bleibt auf „Druckstart kann eine Minute dauern“, während der Drucker längst druckt:** Der Abschluss hat `await_print` erst geprüft, wenn der Drucker wieder frei war, und den laufenden Druck dann als Start-Timeout gewertet. +- **Strombericht ohne Düse/Bett nach Pause:** Beim Zusammenlegen zweier History-Jobs bleiben Temperatur- und ED-Proben am Lauf. Vorher wurden die Proben des Folgejobs gelöscht, der Bericht fiel auf die Grundlast zurück. +- **Filament-Pause spaltet das Protokoll:** Setzt Anycubic nach dem Fortsetzen eine neue History-Job-ID, bleibt es ein Lauf. Der Abbruch-Teil wird nicht noch einmal als ganzer Druck abgezogen. +- **Materialwechsel öffnet trotzdem einen zweiten Auftrag:** Moonraker liefert die History neueste zuerst. Die Fortsetzung kam vor dem abgebrochenen Teil an die Reihe, fand keinen Vorgänger und wurde ein eigener Lauf; der alte Teil wurde als Abbruch abgezogen. Der Abgleich geht jetzt pro Drucker von alt nach neu. Eine eingeklappte Job-ID setzt den laufenden Druck nicht mehr auf „Abgebrochen“ zurück, und der zusammengelegte Lauf hat kein Ende, solange er druckt. +- **Fertig vor History bucht doppelt:** Live-Lauf wird geschlossen ohne Abzug; History bucht. Fallback nach 10 min ohne History. +- **Abbruch + schnelles Nochmal:** Frischer Neustart (Dauer nahe 0) wird nicht mehr als Filament-Fortsetzung zusammengelegt. +- **Alter Lauf auf der neuen Spule:** Der History-Abgleich zieht einen Druck nur von Rollen ab, die vor dem Druckende schon existierten. Die Rolle, die gerade im ACE liegt, bekommt keine alten Jobs. +- **Projektfilter leer neben „Wurzel“:** Ein gewähltes Projekt hebt den Ordnerfilter „Wurzel“ auf. Ein Ordner zeigt auch die Dateien in seinen Unterordnern. + ## [3.5.2] – 2026-10-21 ### Geändert diff --git a/README.md b/README.md index 5fce73e..b5ce974 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- Version 3.5.2 + Version 3.6.0 Python 3.11+ PostgreSQL Moonraker @@ -115,22 +115,23 @@ Schema-Migrationen laufen beim Start (`grotti/migrations/`). Vor Major-Updates: ## API -Alles unter `/api/v1`, JSON. Auth: Session oder `Authorization: Bearer grt_…`. +Alles unter `/api/v1`, JSON. Auth: Session oder `Authorization: Bearer grt_…` (kein CSRF). Referenz: [`docs/internal/API.md`](docs/internal/API.md), OpenAPI-Skizze [`docs/internal/openapi.yaml`](docs/internal/openapi.yaml). | Methode und Pfad | Zweck | | --- | --- | -| `POST /auth/setup`, `/auth/login`, `/auth/logout` | Ersten Admin, Login, Logout | +| `POST /auth/setup`, `/auth/login`, `/auth/token`, `/auth/logout` | Admin, Session-Login, App-Token, Logout | | `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens | -| `GET /version` | Version | -| `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe | +| `GET /version` | Version, `api`, `features` | +| `GET`/`PATCH /einstellungen`, `POST /telegram/test`, `/ntfy/test` | Config und Push-Probe | | `GET /drucker?live=1` | Drucker mit Live-Zustand | -| `POST /drucker//` | Pause, Fortsetzen, Abbrechen, Fehler löschen | -| `POST /drucker//steuern` | Temperatur, Speed, Flow, Lüfter, ACE | +| `POST /drucker//` | `pause`, `fortsetzen`/`resume`, `abbrechen`/`cancel` | +| `POST /drucker//steuern` | Temperatur, Speed, Flow, Lüfter, ACE, `clear_error`, E-Stop | | `POST /drucker//abraeumen` | Platte geräumt (`confirm`) | -| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/` | Bibliothek | +| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/` | Bibliothek (Filter `family`, `material`, `project_id`, …) | | `POST /senden`, `GET`/`POST /druck-queue` | Senden und Warteschlange (`confirm`) | +| `GET`/`POST /projekte`, `…/einreihen`, Assets | Projekte | | `GET /spulen`, `POST /spulen//einlegen` | Material | -| `GET /laeufe`, `/statistik`, `/ereignisse` | Protokoll und Aktivität | +| `GET /laeufe`, `/statistik`, `/ereignisse`, `/stream` | Protokoll, Statistik, SSE | MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` / `GROTTI_TOKEN` → `docs/internal/MCP.md`. diff --git a/docs/Anleitung-ACE.md b/docs/Anleitung-ACE.md index bde6863..e1a664e 100644 --- a/docs/Anleitung-ACE.md +++ b/docs/Anleitung-ACE.md @@ -16,7 +16,7 @@ Es gibt **kein freies Spulen-Dropdown** beim Start: Grotti nimmt die Rolle(n), d Während Druck oder Pause die neue Spule unter Material dem Drucker zuweisen und speichern. Grotti schließt den Abschnitt der alten Rolle und öffnet einen neuen — im Druckbericht erscheinen dann zwei Spulenzeilen und Ereignisse. Nur Filament am Gerät laden ohne Zuweisung: Abzug und Protokoll bleiben bei der alten Rolle. -Wenn Moonraker den Druck nach Filament-Pause als neuen History-Job führt, übernimmt Grotti die neu zugewiesene Rolle für den fortgesetzten Teil (Send- und Lauf-Spule werden beim Einlegen aktualisiert). +Wenn Moonraker den Druck nach Filament-Pause als neuen History-Job führt, übernimmt Grotti die neu zugewiesene Rolle für den fortgesetzten Teil (Send- und Lauf-Spule werden beim Einlegen aktualisiert). Die beiden Job-IDs bleiben **ein** Protokoll-Lauf, wenn die Druckdauer fortgesetzt wird oder die Wand „druckt weiter“ gesehen hat. Etiketten mit QR: [Material und mehr](Anleitung-Material.md). @@ -30,7 +30,7 @@ Unter **Dateien → Optionen → ACE-Zuordnung**: - Ohne passende Map schreibt der Drucker oft **„cannot parse the file“** und startet nicht - Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit -Bei **Nächster passender** (kein Drucker gewählt) baut Grotti die Map erst beim Claim aus den Spulen am gewählten Drucker. Passt die Sorte nicht → Wartegrund in der Queue. +Bei **Nächster passender** (kein Drucker gewählt) baut Grotti die Map erst beim Claim aus den Spulen am gewählten Drucker. Passt der Typ nicht (PETG statt PLA) → Wartegrund in der Queue. Meta/Matte/Silk halten nicht an; die genannte Sorte wird nur bevorzugt. Externe Spule / keine ACE-Slots: keine `.acm` nötig. diff --git a/docs/Anleitung-Dateien.md b/docs/Anleitung-Dateien.md index 3b63cca..a5433c3 100644 --- a/docs/Anleitung-Dateien.md +++ b/docs/Anleitung-Dateien.md @@ -28,7 +28,7 @@ Projekte mit eigenem Ordner: [Projekte](Anleitung-Projekte.md). 2. **Drucker** wählen — Kacheln zeigen Live-Zustand und eingelegte Rolle. Passt die Maschine nicht, ist die Kachel blass. 3. **Start:** - **Dieser Drucker** — frei und Platte klar → sofort starten; sonst Warteschlange für diesen Drucker - - **Nächster passender** — Grotti wählt den nächsten freien Drucker mit passender Sorte (z. B. Sunlu PLA Meta, nicht Matte) + - **Nächster passender** — Grotti wählt den nächsten freien Drucker mit passendem Typ (PLA, PETG, …). Die genannte Sorte (Meta, Matte) wird bevorzugt, wenn sie liegt; sonst reicht derselbe Typ. Auswahl merkt sich der Browser (`localStorage`). diff --git a/docs/Anleitung-Einrichtung.md b/docs/Anleitung-Einrichtung.md index 56e44c4..b43cda8 100644 --- a/docs/Anleitung-Einrichtung.md +++ b/docs/Anleitung-Einrichtung.md @@ -1,6 +1,6 @@ # Einrichtung -Stand **15.10.2026**. Grotti auf dem Haus-PC installieren und den Browser öffnen. Die Drucker brauchen vorher Moonraker (Rinkhals auf Kobra 3 und S1). +Stand **24.10.2026**. Grotti auf dem Haus-PC oder Raspberry Pi installieren und den Browser öffnen. Die Drucker brauchen vorher Moonraker (Rinkhals auf Kobra 3 und S1). ## Was du brauchst @@ -56,8 +56,10 @@ Dann öffnen. Beim ersten Start legst du den Admin-Benu - Druckstart über die API nur mit `confirm: true` (Dateien-UI: ein Klick). - Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt. -- Benutzer und API-Tokens unter Einstellungen. Für Zugriff aus dem Internet: Reverse Proxy mit TLS, nicht den Entwickler-Server öffentlich freigeben. +- Benutzer und API-Tokens unter Einstellungen. App-Kopplung: Token erzeugen und QR scannen, oder `POST /api/v1/auth/token` mit Benutzer/Passwort. Push aufs Handy: **ntfy** (kein FCM). API-Referenz: intern `docs/internal/API.md`. +- Für Zugriff aus dem Internet: Reverse Proxy mit TLS, nicht den Entwickler-Server öffentlich freigeben. - `config.json` und `data/` bleiben lokal, nicht im Git. +- Raspberry Pi: [Installation auf dem Pi](Anleitung-Installation-Pi.md) (`.deb`, Umzug Windows→Pi). ## Weiter diff --git a/docs/Anleitung-Einstellungen.md b/docs/Anleitung-Einstellungen.md index d95a1df..dedb8d6 100644 --- a/docs/Anleitung-Einstellungen.md +++ b/docs/Anleitung-Einstellungen.md @@ -37,7 +37,7 @@ Telegram: Bot-Token und Chat-ID, pro Ereignisart an/aus, Test-Knopf. **ntfy:** S ## Benutzer und API-Tokens -Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`). Ein Shared-`token` in der Config-Datei gilt nicht mehr. +Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`), dazu ein QR mit Server-URL und Token zum Scannen. Alternativ stellt die App `POST /api/v1/auth/token` mit Benutzer/Passwort aus. Ein Shared-`token` in der Config-Datei gilt nicht mehr. MCP (optional): Token erzeugen, dann `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN`. Für nur Lesen: `GROTTI_MCP_READ_ONLY=1`. diff --git a/docs/Anleitung-Installation-Pi.md b/docs/Anleitung-Installation-Pi.md index 7ed42e3..dd3ceee 100644 --- a/docs/Anleitung-Installation-Pi.md +++ b/docs/Anleitung-Installation-Pi.md @@ -85,8 +85,36 @@ sudo apt purge grotti # zusätzlich /etc/grotti # sudo rm -rf /var/lib/grotti ``` +## Umzug Windows → Pi + +1. **Backup auf dem alten Rechner** (Einstellungen → Backup) oder `pg_dump` der PostgreSQL-Datenbank. +2. Auf dem Pi Grotti per `.deb` installieren (oben). +3. Backup einspielen (Einstellungen → Backup wiederherstellen) **oder** `pg_restore` in die DB `grotti`. +4. Slice-Ordner nach `/var/lib/grotti/slice` kopieren (rsync/scp). +5. Absolute Windows-Pfade in der DB umschreiben (Dry-Run zuerst): + +```bash +sudo -u grotti /opt/grotti/venv/bin/python /opt/grotti/scripts/pfade_umziehen.py \ + --database-url 'postgresql:///grotti?host=/var/run/postgresql' \ + --from 'D:/dein/slice' --to /var/lib/grotti/slice +# prüfen, dann mit --apply +``` + +(Alternativ aus dem Repo-Clone mit derselben DB-URL.) + +6. Unter Einstellungen: `watch_dir` = `/var/lib/grotti/slice`, ggf. `fqdn` auf die Pi-URL. +7. In Orca „Senden an Grotti“ auf `http://:8770/` umstellen. +8. `sudo systemctl restart grotti` und unter **Dateien → Ordner neu lesen**. + +## Last auf dem Pi + +- Watcher und Toolpath-Cache liegen unter `/var/lib/grotti/`. Bei wenig Speicher Toolpath-Cache-Ordner (`data/toolpath`) gelegentlich leeren. +- Wenige SSE-Clients sind unkritisch; viele offene Browser-Tabs mit Kamera-Stream belasten CPU/Netz. +- Empfohlen: Pi 4/5 mit SSD oder schneller SD, nicht parallel schwere Builds auf demselben Gerät. + ## 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). +- `.deb` auf dem Ziel-Pi bauen (`bash packaging/build-deb.sh`), damit arm64-Wheels (`psycopg`, `cryptography`) passen. diff --git a/docs/Anleitung-Material.md b/docs/Anleitung-Material.md index d99269a..f90a94b 100644 --- a/docs/Anleitung-Material.md +++ b/docs/Anleitung-Material.md @@ -26,9 +26,9 @@ Unter **Aufträge** Kunden, Positionen und Fortschritt. Dateien lassen sich eine ## Protokoll und Statistik -Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Kosten-Aufschlüsselung Filament + Strom mit Tortendiagramm, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option. +Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Kosten-Aufschlüsselung Filament + Strom mit Tortendiagramm, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option (nicht während der Druck noch läuft). -Nach Filament-Ende: neue Spule unter **Material** dem Drucker/ACE-Platz zuweisen (Ort setzen und speichern — das legt den Rollenwechsel im Lauf an), am Gerät laden, an der Wand **Weiter**. Nur physisch nachlegen reicht nicht. Die Zuweisung gilt auch, wenn Moonraker den Druck nach der Pause als neuen Job zählt. +Nach Filament-Ende: neue Spule unter **Material** dem Drucker/ACE-Platz zuweisen (Ort setzen und speichern — das legt den Rollenwechsel im Lauf an), am Gerät laden, an der Wand **Weiter**. Nur physisch nachlegen reicht nicht. Die Zuweisung gilt auch, wenn Moonraker den Druck nach der Pause als neuen Job zählt — Grotti hält das als **einen** Lauf (gleiche Charge). Im Protokoll siehst du bei Rollenwechsel die Spulennamen; falls doch zwei Zeilen stehen, **Läufe holen** oder **Verbinden**. Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis. diff --git a/docs/Anleitung-Projekte.md b/docs/Anleitung-Projekte.md index 6a90e03..5ccf27b 100644 --- a/docs/Anleitung-Projekte.md +++ b/docs/Anleitung-Projekte.md @@ -1,6 +1,6 @@ # Projekte -Stand **18.10.2026**. Arbeitsmappen unter `/projekte` — getrennt von Kunden-[Aufträgen](Anleitung-Auftraege.md). +Stand **24.10.2026**. Arbeitsmappen unter `/projekte` — getrennt von Kunden-[Aufträgen](Anleitung-Auftraege.md). ## Wozu @@ -16,11 +16,19 @@ Ein **Projekt** bündelt Konstruktion und Druckdateien in einem Ordner unter dem **Neues Projekt** — Name, Status, optional Ordner (Standard: `Projekte/`). Mit gesetztem Slice-Ordner legt Grotti den Ordner an. -G-code und 3MF in diesem Ordner (oder dorthin [verschoben](Anleitung-Dateien.md)) werden dem Projekt zugeordnet und erscheinen in der Druck-Bibliothek. +Filter **Aktiv** / **Fertig** / **Alle** in der Liste. G-code und 3MF in diesem Ordner (oder dorthin [verschoben](Anleitung-Dateien.md)) werden dem Projekt zugeordnet und erscheinen in der Druck-Bibliothek. -## Weitere Dateien +## Detail -Im Projekt-Detail: Upload von STEP, STL, PDF, SolidWorks usw. — liegen im selben Ordner, erscheinen **nicht** in der Druck-Bibliothek. Download und Löschen pro Datei. +Im Projekt siehst du Kennzahlen (Läufe, Druckzeit, Filament, Kosten) und drei Tabs: + +- **Druckdateien** — Link zur Bibliothek (`?file=` / `?project=`), **Einreihen** pro Datei oder **Alle einreihen** (Warteschlange, mit Bestätigung) +- **Läufe** — letzte Drucke mit Link zum Protokoll / Bericht +- **Weitere Dateien** — STEP, STL, PDF, SolidWorks usw. (nicht in der Druck-Bibliothek) + +Wenn jede Datei mindestens einmal ok gedruckt wurde, schlägt Grotti vor, den Status auf **fertig** zu setzen. + +Deep-Link: `/projekte#`. ## Löschen diff --git a/docs/Anleitung-Wand.md b/docs/Anleitung-Wand.md index 7568e7c..1639f97 100644 --- a/docs/Anleitung-Wand.md +++ b/docs/Anleitung-Wand.md @@ -1,6 +1,6 @@ # Druckerwand -Stand **18.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit. +Stand **24.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit. ![Druckerwand](../web/static/doku/wand-uebersicht.png) @@ -19,7 +19,7 @@ Aktionen auf der Karte brauchen oft **zwei Klicks** (erster Klick „scharf“, | Aktion | Wann | | --- | --- | -| **Pause** / **Weiter** | Während des Drucks; nach Materialwechsel Spule in Grotti an den Drucker hängen, dann **Weiter** | +| **Pause** / **Weiter** | Während des Drucks; nach Materialwechsel gelber Hinweis mit ACE-Platz → Link zu Material (Drucker/Platz vorausgefüllt), Speichern, dann **Weiter**. Dieselbe Charge läuft weiter. | | **Abbrechen** | Druck oder Pause | | **Fehler löschen** | Zustand Fehler — räumt den hängenden Fehler, Heizung aus | | **Platte geräumt** | Nach Fertig, wenn die Platte wieder frei ist | @@ -39,7 +39,8 @@ Inhalt: - Status, Temps inkl. Bauraum wo verfügbar, Lüfter - **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole. Mit ACE zusätzlich Trockner (Temp, Dauer, Start/Stopp) und pro bestücktem Platz **an den Drucker** (Sorte und Farbe aus der Grotti-Spule) - **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an -- **Verlauf** Temperatur und ED des aktuellen oder letzten Drucks; Link zum Druckbericht (dort Kosten: Filament + Grundlast/Düse/Bett) +- **Verlauf** Temperatur und ED des aktuellen oder letzten Drucks; Links zu Datei und Druckbericht (dort Kosten: Filament + Grundlast/Düse/Bett) +- **Als Nächstes** (nur wenn der Drucker frei und die Platte quittiert ist): **Nochmal** der letzten Datei, Hinweis zur Warteschlange, passende Dateien nach Familie/Material — Start mit Standardwerten oder **Anpassen…** unter Dateien - **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung) - **Bettnetz**-Heatmap, wenn vorhanden diff --git a/docs/internal/API.md b/docs/internal/API.md new file mode 100644 index 0000000..fc94789 --- /dev/null +++ b/docs/internal/API.md @@ -0,0 +1,86 @@ +# Grotti HTTP-API (`/api/v1`) + +Stand für Android/App-Clients und Integrationen. Nutzer-Anleitungen bleiben unter `docs/Anleitung-*.md`. + +## Auth + +| Weg | Header / Cookie | CSRF | +| --- | --- | --- | +| Session | Cookie nach `POST /auth/login` | `X-CSRF-Token` bei mutierenden API-Calls | +| Bearer | `Authorization: Bearer grt_…` | nicht nötig | + +**Token ausstellen** + +- Eingeloggt: `POST /tokens` `{name, role?}` → Klartext einmalig in `token` +- Ohne Session (App): `POST /auth/token` `{username, password, device_name?, role?}` → `201` mit `token` + `user` (Rate-Limit wie Login) + +Öffentlich ohne Login: `/auth/status`, `/auth/login`, `/auth/token`, `/auth/setup`, `/version`. + +Rollen: `operator` ≤ `admin`. Admin-only u. a. Benutzer, Backup, volle Einstellungen, Drucker anlegen. + +**Medien mit Query-Token** (nur GET): `?token=grt_…` für Kamera, Live-Vorschau, Datei-Vorschau (`/dateien//vorschau`), Projekt-Assets. + +## Fehler + +```json +{"error": "Text", "code": "kurzcode"} +``` + +HTTP-Status: 400/401/403/404/409/429/503. Bild-Endpunkte liefern bei fehlendem Bild ebenfalls dieses JSON (nicht nur leeren 404). + +## Pagination + +Listen akzeptieren `limit` / `offset` wo sinnvoll. Antwort bleibt ein **JSON-Array**; Gesamtzahl im Header `X-Total-Count`. + +Ohne `limit` liefern `/dateien` und `/spulen` die volle Liste (Web-UI). + +## Bestätigung + +Mutierende Druckaktionen brauchen oft `"confirm": true`: + +- `POST /senden`, `POST /druck-queue`, `POST /ablauf` +- `POST /projekte//einreihen`, `POST /auftraege//einreihen` +- `POST /drucker//abraeumen` +- `steuern` mit `emergency_stop`, `clear_error`, `exclude_object` + +## Server-Info + +`GET /version` → `{name, version, api: 1, features: [...]}`. + +## SSE Live + +`GET /stream` (Session-Cookie oder Bearer). Events: + +| Event | Bedeutung | +| --- | --- | +| `ping` | Keepalive (~15 s), Payload `{}` | +| `drucker` | Live-Zustand der Drucker geändert | +| `queue` | Druckwarteschlange geändert | +| `senden` | Sende-Auftrag geändert | +| `ereignis` | Neues Ereignis / Aktivität | +| `aufgaben` | Offene Aufgaben (Platte …) | + +Ohne Stream: Client pollt `/drucker?live=1`, `/jetzt`, `/druck-queue`. + +## Push an Geräte + +Grotti sendet **kein** FCM. Empfohlen: **ntfy** (Topic unter Einstellungen). Die App kann denselben Topic abonnieren; Secrets (ntfy-Token) bleiben serverseitig. + +## Wichtige Ressourcen + +| Bereich | Pfade | +| --- | --- | +| Drucker | `GET /drucker?live=1`, `POST /drucker//pause\|fortsetzen\|abbrechen` (Alias `resume`/`cancel`), `…/steuern`, `…/abraeumen` | +| Dateien | `GET /dateien?family=&material=&project_id=&host=&q=&limit=&offset=`, Upload multipart, `…/vorschau`, `…/pfad` | +| Senden / Queue | `POST /senden`, `GET /senden-zuletzt?host=&file_id=`, `GET\|POST /druck-queue?host=&status=`, `POST /ablauf` | +| Projekte | `GET\|POST /projekte`, `GET /projekte/`, `…/laeufe`, `POST …/einreihen`, Assets | +| Aufträge | `GET\|POST /auftraege`, Positionen, `…/einreihen` | +| Material | `GET /spulen?aktiv=1`, Akte, Einlegen | +| Protokoll | `GET /laeufe`, `GET /laeufe/`, `GET /statistik?project_id=` | +| Kamera | `GET /kamera?host=`, `…/stream` | + +Pfadnamen sind überwiegend **deutsch**. Aktionen: deutsch + englische Alias für Pause/Resume/Cancel. + +## MCP + +`python -m grotti.mcp_server` nutzt dieselbe REST-API (Teilmenge). Siehe `docs/internal/MCP.md`. diff --git a/docs/internal/Anycubic-Stack.md b/docs/internal/Anycubic-Stack.md index 800d0c1..523b5d7 100644 --- a/docs/internal/Anycubic-Stack.md +++ b/docs/internal/Anycubic-Stack.md @@ -134,6 +134,16 @@ Ohne Map: Rinkhals füllt oft Identity T0→Platz0 … — deshalb Grotti bei ei | Slots anzeigen | Moonraker `mmu` (Rinkhals) | `mmu_ace.py` | | Kein Firmware-Restart | abgelehnt | Rinkhals #32, `patch_klipper_restart` | +## History nach Filament-Pause + +Runout, Rolle tauschen, Fortsetzen: Anycubic schließt den History-Job als `cancelled` und öffnet eine neue Job-ID. Beobachtet am 2026-10-25 auf Kobra 3 A1 (`000040` → `000041`): + +- Der neue `start_time` liegt etwa 0,04 s nach dem `end_time` des alten. +- `print_duration` und `total_duration` sind an beiden Einträgen gleich und laufen weiter. Der Zähler beginnt nicht bei 0. +- `/server/history/list` liefert neueste zuerst. Grotti sortiert pro Drucker nach `start_time`, sonst kommt die Fortsetzung vor ihrem Vorgänger dran (`jobs.history_in_order`). +- Nach dem Zusammenlegen hängt die alte Job-ID in `run_jobs` am Lauf. `store.history_job_retired` überspringt sie, sonst schreibt sie `cancelled` zurück. +- Zusammenlegen nur mit Beweis: `print_duration` fortgesetzt und Lücke ≤ 5 s, oder Watcher `paused→printing` (`docs/internal/Lauf-Identitaet.md`). + ## Offene Grenzen - Quellcode von **gkapi** (Task-State-Machine, exakte 10101-Bedingungen) ist nicht öffentlich. diff --git a/docs/internal/Lauf-Identitaet.md b/docs/internal/Lauf-Identitaet.md new file mode 100644 index 0000000..3ec9e26 --- /dev/null +++ b/docs/internal/Lauf-Identitaet.md @@ -0,0 +1,31 @@ +# Lauf-Identität: ein physischer Druck = ein Lauf + +## Regel + +Zusammengelegt wird nur mit **Beweis**, nicht nur wegen Zeit und Dateiname: + +1. Der neue Moonraker-Job hat `print_duration` ≥ Dauer des Vorgängers − 30 s, und die Lücke `end→start` ist höchstens 5 s (Anycubic setzt `end_time` erst beim Fortsetzen). +2. Oder der Watcher hat `paused → printing` bei gleicher Datei gesehen (`watcher_resume`, `printers.active_run_id`). + +Ein Neustart mit `print_duration` ≤ 60 s wird nie zusammengelegt. + +## Pfade + +| Pfad | Datei | +| --- | --- | +| Beweis | `jobs.is_filament_resume` | +| Fold | `jobs.fold_filament_resume` | +| Abgleich | `jobs.sync_history` (Advisory-Lock) | +| Job-Aliase | `run_jobs` + `history_job_retired` | +| Leere Hülle | übersprungen in `_sync_one_job` | +| Live-Abzug | erst History; Fallback nach 10 min (`LIVE_DEDUCT_FALLBACK_S`) | +| Manuell | `POST /api/v1/laeufe//verbinden` | + +## Queue + +In `print_active` heißt nur `complete` fertig. `cancelled`/`standby` werden entprellt (`TERMINAL_CONFIRM_HITS`). Echter Abbruch → Status `cancelled`, nicht „Fertig.“. + +## Reparatur + +`scripts/laeufe_reparieren.py` (Probelauf standardmäßig, `--apply` nach Freigabe). +Verwaiste `live:`-Läufe nur an offenen History-Jobs **derselben Datei**; `adopt_live_run` greift geschlossene Live-Läufe nur ≤ 2 h. diff --git a/docs/internal/Ops-Queue-Alerts.md b/docs/internal/Ops-Queue-Alerts.md index 25c0aa8..c5585ff 100644 --- a/docs/internal/Ops-Queue-Alerts.md +++ b/docs/internal/Ops-Queue-Alerts.md @@ -27,7 +27,7 @@ Jedes Ereignis landet in `events` und auf der Seite `/aktivitaet`. Telegram ist | `fertig` | Watcher, `printing`→`complete` | nein, Platte unter „Aufgaben“ | an | | `fehler` | Watcher, Zustand `error` | ja | an | | `abbruch` | Watcher, `printing`/`paused`→`cancelled`/`standby` | ja | wie `fehler` | -| `offline` | Watcher, ≥2 Polls weg; auch „wieder erreichbar“ | ja | an | +| `offline` | Watcher, ≥2 Polls weg; auch „wieder erreichbar“. Beendet den Lauf nicht und zieht nichts ab — der letzte Druckzustand bleibt gemerkt | ja | an | | `spule` | Watcher, Rest unter Job-Gewicht | ja | an | | `queue` | Dispatcher: Queue-Fehler, Ablauf gestoppt | ja | an | | `start` | Watcher: Druck startet, Pause, Fortsetzen | nein | aus | diff --git a/docs/internal/README.md b/docs/internal/README.md index 857c767..49f6796 100644 --- a/docs/internal/README.md +++ b/docs/internal/README.md @@ -6,5 +6,8 @@ Nutzer-Handbuch: `docs/Anleitung-*.md`. | Datei | Inhalt | | --- | --- | +| [API.md](API.md) | HTTP-API für App/Integrationen (Auth, confirm, SSE, Filter) | +| [openapi.yaml](openapi.yaml) | OpenAPI-Skizze (Client-Generator) | +| [MCP.md](MCP.md) | MCP-Server und Tool-Ablauf | | [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 | diff --git a/docs/internal/openapi.yaml b/docs/internal/openapi.yaml new file mode 100644 index 0000000..3a8a99b --- /dev/null +++ b/docs/internal/openapi.yaml @@ -0,0 +1,180 @@ +openapi: 3.0.3 +info: + title: Grotti API + version: "1" + description: | + JSON unter /api/v1. Auth: Session-Cookie oder Bearer grt_…. + Mutierende Session-Calls brauchen Header X-CSRF-Token. + Siehe docs/internal/API.md. +servers: + - url: /api/v1 +paths: + /version: + get: + summary: Server-Info + security: [] + responses: + "200": + description: name, version, api, features + /auth/login: + post: + summary: Session-Login + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [username, password] + properties: + username: { type: string } + password: { type: string } + responses: + "200": + description: user + csrf + /auth/token: + post: + summary: Passwort → API-Token (App) + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [username, password] + properties: + username: { type: string } + password: { type: string } + device_name: { type: string } + role: { type: string, enum: [operator, admin] } + responses: + "201": + description: token (einmalig) + user + /drucker: + get: + summary: Druckerliste + parameters: + - name: live + in: query + schema: { type: integer, enum: [0, 1] } + responses: + "200": + description: Array Drucker + /drucker/{host}/{action}: + post: + summary: Pause / Fortsetzen / Abbrechen + parameters: + - name: host + in: path + required: true + schema: { type: string } + - name: action + in: path + required: true + schema: + type: string + enum: [pause, fortsetzen, abbrechen, resume, cancel] + responses: + "200": + description: OK + /dateien: + get: + summary: Bibliothek + parameters: + - { name: family, in: query, schema: { type: string } } + - { name: material, in: query, schema: { type: string } } + - { name: project_id, in: query, schema: { type: integer } } + - { name: host, in: query, schema: { type: string } } + - { name: q, in: query, schema: { type: string } } + - { name: limit, in: query, schema: { type: integer } } + - { name: offset, in: query, schema: { type: integer } } + responses: + "200": + description: Array Dateien; Header X-Total-Count + headers: + X-Total-Count: + schema: { type: integer } + /senden: + post: + summary: Druck starten oder einreihen + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [file_id, confirm] + properties: + file_id: { type: integer } + hosts: { type: array, items: { type: string } } + confirm: { type: boolean } + when_free: { type: boolean } + ace_maps: { type: object } + bed_leveling: { type: boolean } + responses: + "202": + description: jobs + /druck-queue: + get: + summary: Warteschlange + parameters: + - { name: host, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: string } } + - { name: limit, in: query, schema: { type: integer } } + - { name: offset, in: query, schema: { type: integer } } + responses: + "200": + description: Array; X-Total-Count + post: + summary: Einreihen + requestBody: + content: + application/json: + schema: + type: object + required: [file_id, confirm] + responses: + "200": + description: Queue-Eintrag + /projekte/{id}/einreihen: + post: + summary: Projektdateien einreihen + parameters: + - name: id + in: path + required: true + schema: { type: integer } + requestBody: + content: + application/json: + schema: + type: object + required: [confirm] + properties: + confirm: { type: boolean } + file_ids: { type: array, items: { type: integer } } + menge: { type: integer } + responses: + "201": + description: jobs + /stream: + get: + summary: SSE Live-Events + responses: + "200": + description: text/event-stream (drucker, queue, senden, ereignis, ping) +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: grt_ + cookieAuth: + type: apiKey + in: cookie + name: session +security: + - bearerAuth: [] + - cookieAuth: [] diff --git a/grotti/daily_report.py b/grotti/daily_report.py index c48b1a3..25c893e 100644 --- a/grotti/daily_report.py +++ b/grotti/daily_report.py @@ -45,7 +45,10 @@ def _yesterday_stats(conn): def build_text(conn, watcher_latest=None, printers=None): stats = _yesterday_stats(conn) - waiting = len([j for j in store.list_print_queue(conn, limit=200) if j.get("status") == "waiting"]) + waiting = len([ + j for j in store.list_print_queue(conn, limit=200)["items"] + if j.get("status") == "waiting" + ]) eta = jetzt_mod.farm_eta([ {"remain": (live or {}).get("remain") or 0} for live in (watcher_latest or []) diff --git a/grotti/jetzt.py b/grotti/jetzt.py index de15911..b528fbf 100644 --- a/grotti/jetzt.py +++ b/grotti/jetzt.py @@ -111,7 +111,7 @@ def build(conn, watcher_latest, printers=None, send_jobs=None): "SELECT DISTINCT host FROM print_queue WHERE status = 'running'" ).fetchall() } - queue = store.list_print_queue(conn, limit=80) + queue = store.list_print_queue(conn, limit=80)["items"] waiting = [j for j in queue if j.get("status") == "waiting"] errors = [j for j in queue if j.get("status") == "error"] running_q = [j for j in queue if j.get("status") == "running"] diff --git a/grotti/jobs.py b/grotti/jobs.py index c248755..19bf788 100644 --- a/grotti/jobs.py +++ b/grotti/jobs.py @@ -5,7 +5,7 @@ import urllib.error from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone -from grotti import acm, catalog, eject, library, moonraker, store, usage +from grotti import acm, catalog, eject, library, logutil, moonraker, store, usage from grotti.cost import energy_cost from grotti.errors import GrottiError from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode @@ -39,12 +39,12 @@ def _ace_map_of(sent): return [] -def _targets_from_ace(conn, host, ace): +def _targets_from_ace(conn, host, ace, ended_at=None): targets = [] for item in ace: slot = int(item["ams_index"]) + 1 row = store.spool_at_slot(conn, host, slot) - if row: + if row and _spool_valid_for_end(row, ended_at): targets.append(row) return targets @@ -55,6 +55,380 @@ def unix_to_dt(value): return datetime.fromtimestamp(float(value), timezone.utc) +# Anycubic schließt bei Filament-Pause den History-Job (cancelled) und öffnet +# beim Fortsetzen eine neue Job-ID. Zusammengelegt wird nur mit Beweis: +# print_duration läuft weiter und die Lücke ist klein, oder der Watcher +# hat paused→printing gesehen. Ein Neustart mit Dauer nahe 0 bleibt getrennt. +RESUME_GAP_S = 5 +RESUME_DURATION_TOL_S = 30 +RESUME_FRESH_MAX_S = 60 +# Abwärtskompatibel für alte Aufrufe / Docs. +RESUME_JOIN_S = RESUME_GAP_S +LIVE_DEDUCT_FALLBACK_S = 600 +SYNC_LOCK_KEY = 748291053 + + +def _as_utc(value): + if value is None: + return None + if getattr(value, "tzinfo", None) is None: + return value.replace(tzinfo=timezone.utc) + return value + + +def history_shell_is_empty(run_or_job): + """History-Job ohne Ende und ohne Dauer. Nie als eigenen Lauf speichern.""" + if not run_or_job: + return False + duration = float( + run_or_job.get("duration_s") + or run_or_job.get("print_duration") + or run_or_job.get("total_duration") + or 0 + ) + ended = run_or_job.get("ended_at") or run_or_job.get("end_time") + return duration <= 0 and not ended + + +def is_filament_resume( + prev_end, + new_start, + prev_duration_s=None, + new_duration_s=None, + watcher_resume=False, + gap_s=RESUME_GAP_S, +): + """True nur mit Beweis: Dauer fortgesetzt + kleine Lücke, oder Watcher-Signal. + + Aufruf mit zwei Zeitpunkten (alt): nur Lücke — gilt nicht mehr als Resume. + """ + prev_end = _as_utc(prev_end) + new_start = _as_utc(new_start) + if prev_end is None or new_start is None: + return False + gap = (new_start - prev_end).total_seconds() + if not (-30 <= gap <= max(float(gap_s), RESUME_GAP_S)): + # Watcher-Signal: Lücke darf länger sein (Pause vor dem Resume-Ende). + if not watcher_resume or gap > 24 * 3600 or gap < -30: + return False + return True + if watcher_resume: + return True + # Zwei-Argument-Aufruf (Tests/alt): ohne Dauer kein Beweis → kein Resume. + if prev_duration_s is None and new_duration_s is None: + return False + prev_d = float(prev_duration_s or 0) + new_d = float(new_duration_s or 0) + # Frischer Neustart: Dauer beginnt nahe 0 — nie zusammenlegen. + if new_d <= RESUME_FRESH_MAX_S: + return False + if new_d < max(0.0, prev_d - RESUME_DURATION_TOL_S): + return False + return True + + +def undo_deduction(conn, run): + """Spulenabzug eines Laufs zurücknehmen. Abschnitte bleiben.""" + if not run or not run.get("deducted"): + return False + rows = conn.execute( + "SELECT spool_id, weight_g FROM run_spools WHERE run_id = %s", + (run["id"],), + ).fetchall() + for row in rows: + grams = float(row["weight_g"] or 0) + if grams and row["spool_id"]: + conn.execute( + "UPDATE spools SET remaining_g = remaining_g + %s WHERE id = %s", + (grams, row["spool_id"]), + ) + conn.execute("DELETE FROM run_spools WHERE run_id = %s", (run["id"],)) + conn.execute( + "UPDATE runs SET deducted = false, filament_cost = 0 WHERE id = %s", + (run["id"],), + ) + return True + + +def fold_filament_resume( + conn, + host, + filename, + job_id, + start_dt, + print_duration_s=0, + watcher_resume=False, +): + """Fortsetzung nach Filament-Pause auf den bestehenden Lauf legen. + + Gibt die Lauf-ID zurück, deren moonraker_job_id jetzt job_id ist. + Leere Hüllen (Dauer 0, kein Ende) werden vorher übersprungen — nicht hier. + """ + pred = store.find_resume_predecessor( + conn, host, filename, job_id, start_dt, watcher_resume=watcher_resume + ) + if not pred: + return None + if not is_filament_resume( + pred.get("ended_at"), + start_dt, + prev_duration_s=pred.get("duration_s"), + new_duration_s=print_duration_s, + watcher_resume=watcher_resume, + ): + return None + other = conn.execute( + """ + SELECT * FROM runs + WHERE printer_host = %s AND moonraker_job_id = %s AND id <> %s + """, + (host, str(job_id), pred["id"]), + ).fetchone() + store.remember_run_job(conn, host, pred.get("moonraker_job_id"), pred["id"], replace=True) + undo_deduction(conn, pred) + kept = None + if other: + undo_deduction(conn, other) + conn.execute( + "UPDATE events SET run_id = %s WHERE run_id = %s", + (pred["id"], other["id"]), + ) + if not (pred.get("verdict") or "").strip() and (other.get("verdict") or "").strip(): + conn.execute( + """ + UPDATE runs + SET verdict = %s, verdict_reason = %s, pieces_ok = %s, pieces_bad = %s + WHERE id = %s + """, + ( + other.get("verdict") or "", + other.get("verdict_reason") or "", + other.get("pieces_ok") or 0, + other.get("pieces_bad") or 0, + pred["id"], + ), + ) + kept = { + "state": other.get("state") or "completed", + "ended_at": other.get("ended_at"), + "duration_s": float(other.get("duration_s") or 0), + "weight_used_g": float(other.get("weight_used_g") or 0), + "weight_source": other.get("weight_source") or "", + "filament_used_mm": float(other.get("filament_used_mm") or 0), + "filament_total_mm": float(other.get("filament_total_mm") or 0), + "progress_last": float(other.get("progress_last") or 0), + } + for table in ("run_samples", "run_segments"): + conn.execute( + "UPDATE %s SET run_id = %%s WHERE run_id = %%s" % table, + (pred["id"], other["id"]), + ) + try: + conn.execute( + """ + DELETE FROM run_labels AS src + WHERE src.run_id = %s + AND EXISTS ( + SELECT 1 FROM run_labels AS dst + WHERE dst.run_id = %s AND dst.object_name = src.object_name + ) + """, + (other["id"], pred["id"]), + ) + conn.execute( + "UPDATE run_labels SET run_id = %s WHERE run_id = %s", + (pred["id"], other["id"]), + ) + except Exception: + logutil.get("grotti.jobs").warning( + "Etiketten von Lauf %s nicht übernommen.", other["id"] + ) + conn.execute("DELETE FROM run_spools WHERE run_id = %s", (other["id"],)) + store.move_run_jobs(conn, host, other["id"], pred["id"]) + # Job-ID freigeben, sonst Unique (printer_host, moonraker_job_id) beim Pred. + conn.execute( + "UPDATE runs SET moonraker_job_id = %s WHERE id = %s", + ("live:merged:%s" % other["id"], other["id"]), + ) + conn.execute("DELETE FROM runs WHERE id = %s", (other["id"],)) + # Der Druck läuft weiter. Das Ende kommt vom Folgejob. + conn.execute( + """ + UPDATE runs + SET moonraker_job_id = %s, + ended_at = NULL, + state = CASE + WHEN %s IN ('in_progress', 'printing', 'paused') THEN %s + WHEN state IN ('cancelled', 'interrupted', 'error') THEN 'in_progress' + ELSE state + END + WHERE id = %s + """, + ( + str(job_id), + (kept or {}).get("state") or "in_progress", + (kept or {}).get("state") or "in_progress", + pred["id"], + ), + ) + store.remember_run_job(conn, host, job_id, pred["id"], replace=True) + store.set_printer_active_run(conn, host, pred["id"]) + if kept and kept.get("ended_at"): + conn.execute( + """ + UPDATE runs SET + state = %s, + ended_at = COALESCE(%s, ended_at), + duration_s = GREATEST(COALESCE(duration_s, 0), %s), + weight_used_g = GREATEST(COALESCE(weight_used_g, 0), %s), + weight_source = CASE WHEN %s <> '' THEN %s ELSE weight_source END, + filament_used_mm = GREATEST(COALESCE(filament_used_mm, 0), %s), + filament_total_mm = CASE WHEN %s > 0 THEN %s ELSE filament_total_mm END, + progress_last = GREATEST(COALESCE(progress_last, 0), %s), + deducted = false + WHERE id = %s + """, + ( + kept["state"], + kept["ended_at"], + kept["duration_s"], + kept["weight_used_g"], + kept["weight_source"], + kept["weight_source"], + kept["filament_used_mm"], + kept["filament_total_mm"], + kept["filament_total_mm"], + kept["progress_last"], + pred["id"], + ), + ) + elif print_duration_s: + conn.execute( + """ + UPDATE runs + SET duration_s = GREATEST(COALESCE(duration_s, 0), %s), + deducted = false + WHERE id = %s + """, + (float(print_duration_s), pred["id"]), + ) + return pred["id"] + + +def history_in_order(items): + """Älteste zuerst. Der abgebrochene Teil muss stehen, bevor die Fortsetzung kommt.""" + return sorted( + items or [], + key=lambda job: (float(job.get("start_time") or 0), str(job.get("job_id") or "")), + ) + + +def manual_merge_runs(conn, keep_id, absorb_id, confirm=False): + """Zwei Läufe manuell verbinden. keep überlebt, absorb wird übernommen.""" + if not confirm: + raise GrottiError("Verbinden braucht eine Bestätigung.", "bestaetigung") + keep = store.get_run_row(conn, keep_id) + absorb = store.get_run_row(conn, absorb_id) + if not keep or not absorb: + raise GrottiError("Der Lauf fehlt.", "lauf", 404) + if keep["id"] == absorb["id"]: + raise GrottiError("Gleicher Lauf.", "lauf") + if (keep.get("printer_host") or "") != (absorb.get("printer_host") or ""): + raise GrottiError("Nur Läufe desselben Druckers.", "drucker") + if (keep.get("filename") or "") != (absorb.get("filename") or ""): + raise GrottiError("Nur Läufe derselben Datei.", "datei") + # Früherer Start bleibt der überlebende Lauf. + if keep.get("started_at") and absorb.get("started_at") and absorb["started_at"] < keep["started_at"]: + keep, absorb = absorb, keep + host = keep["printer_host"] + undo_deduction(conn, keep) + undo_deduction(conn, absorb) + snapshot = { + "keep_id": keep["id"], + "absorb_id": absorb["id"], + "absorb_job_id": absorb.get("moonraker_job_id"), + } + for table in ("run_samples", "run_segments", "run_spools", "events"): + conn.execute( + "UPDATE %s SET run_id = %%s WHERE run_id = %%s" % table, + (keep["id"], absorb["id"]), + ) + try: + conn.execute( + """ + DELETE FROM run_labels AS src + WHERE src.run_id = %s + AND EXISTS ( + SELECT 1 FROM run_labels AS dst + WHERE dst.run_id = %s AND dst.object_name = src.object_name + ) + """, + (absorb["id"], keep["id"]), + ) + conn.execute( + "UPDATE run_labels SET run_id = %s WHERE run_id = %s", + (keep["id"], absorb["id"]), + ) + except Exception: + pass + store.move_run_jobs(conn, host, absorb["id"], keep["id"]) + job_id = absorb.get("moonraker_job_id") or keep.get("moonraker_job_id") + if job_id and not str(job_id).startswith("live:"): + store.remember_run_job(conn, host, job_id, keep["id"], replace=True) + # Job-ID am absorb freigeben, sonst Unique beim UPDATE auf keep. + conn.execute( + "UPDATE runs SET moonraker_job_id = %s WHERE id = %s", + ("live:merged:%s" % absorb["id"], absorb["id"]), + ) + absorb_terminal = (absorb.get("state") or "") in TERMINAL + keep_open = (keep.get("state") or "") in ("in_progress", "printing", "paused") + new_state = absorb["state"] if absorb_terminal and not keep_open else ( + absorb["state"] if not keep_open else keep["state"] + ) + if keep_open or (absorb.get("state") or "") in ("in_progress", "printing", "paused"): + new_state = absorb["state"] if (absorb.get("state") or "") in ("in_progress", "printing", "paused") else "in_progress" + ended = None + else: + ended = absorb.get("ended_at") or keep.get("ended_at") + conn.execute( + """ + UPDATE runs SET + moonraker_job_id = COALESCE(NULLIF(%s, ''), moonraker_job_id), + state = %s, + ended_at = %s, + duration_s = GREATEST(COALESCE(duration_s, 0), COALESCE(%s, 0)), + weight_used_g = GREATEST(COALESCE(weight_used_g, 0), COALESCE(%s, 0)), + filament_used_mm = GREATEST(COALESCE(filament_used_mm, 0), COALESCE(%s, 0)), + progress_last = GREATEST(COALESCE(progress_last, 0), COALESCE(%s, 0)), + deducted = false + WHERE id = %s + """, + ( + str(job_id or ""), + new_state, + ended, + float(absorb.get("duration_s") or 0), + float(absorb.get("weight_used_g") or 0), + float(absorb.get("filament_used_mm") or 0), + float(absorb.get("progress_last") or 0), + keep["id"], + ), + ) + try: + conn.execute( + "UPDATE print_queue SET run_id = %s WHERE run_id = %s", + (keep["id"], absorb["id"]), + ) + except Exception: + pass + conn.execute("DELETE FROM runs WHERE id = %s", (absorb["id"],)) + store.set_printer_active_run( + conn, host, keep["id"] if new_state in ("in_progress", "printing", "paused") else None + ) + fresh = store.get_run_row(conn, keep["id"]) + return {"run": store.run_out(fresh), "undo": snapshot} + + def choose_spool(conn, host, material, hinted_id, ended_at=None): """Spule für History-Abzug. Ohne Hint nur Rollen, die vor Druckende existierten.""" at_host = [ @@ -196,12 +570,13 @@ def apply_deduction(conn, run, energy, metadata=None): store.close_open_segments(conn, run["id"], None, run.get("filament_used_mm") or 0) segments = store.list_run_segments(conn, run["id"]) + ended = run.get("ended_at") entries = [] - targets = _targets_from_ace(conn, run["printer_host"], ace) if ace else [] + targets = _targets_from_ace(conn, run["printer_host"], ace, ended_at=ended) if ace else [] if not targets and run.get("spool_id"): spool = store.get_spool_row(conn, run["spool_id"]) - if spool: + if spool and _spool_valid_for_end(spool, ended): targets = [spool] # Abschnitte ohne ACE-Map: externe Rollen nacheinander if segments and not ace: @@ -291,8 +666,10 @@ def apply_deduction(conn, run, energy, metadata=None): filament = sum(item["cost"] for item in entries) samples = store.list_run_samples(conn, run["id"]) power = energy_cost(run["duration_s"], energy, samples) - # run_spools vor deducted=true, sonst bleibt die Akte leer bei Abbruch dazwischen - store.replace_run_spools(conn, run["id"], entries) + weight_source = source or ( + "estimate" if any(e["is_estimate"] for e in entries) else "measured" + ) + # Zuerst deducted=true — nur der Gewinner schreibt Akte und zieht ab. marked = store.mark_deducted( conn, run["id"], @@ -300,11 +677,11 @@ def apply_deduction(conn, run, energy, metadata=None): filament, power, weight_used_g=used_total, - weight_source=source or ("estimate" if any(e["is_estimate"] for e in entries) else "measured"), + weight_source=weight_source, ) if not marked: - store.replace_run_spools(conn, run["id"], []) return False + store.replace_run_spools(conn, run["id"], entries) for item in entries: store.deduct_spool(conn, item["spool_id"], item["weight_g"]) store.ensure_run_chargen(conn, run["id"], used_total) @@ -318,99 +695,149 @@ def apply_deduction(conn, run, energy, metadata=None): return True -def sync_history(conn, cfg): +def _sync_one_job(conn, cfg, printer, job, watcher_resume=False): + """Einen History-Job verarbeiten. Gibt 1 zurück wenn abgezogen wurde.""" + job_id = str(job.get("job_id") or "") + if not job_id or str(job_id).startswith("live:"): + return 0 + filename = job.get("filename") or "" + start_dt = unix_to_dt(job.get("start_time")) + end_dt = unix_to_dt(job.get("end_time")) + duration_s = float(job.get("print_duration") or job.get("total_duration") or 0) + state = job.get("status") or "" + # Leere Hülle: nie speichern. Sobald Dauer da ist, gilt die Beweis-Regel. + if history_shell_is_empty({ + "duration_s": duration_s, + "ended_at": end_dt, + "print_duration": duration_s, + "end_time": end_dt, + }) and state not in TERMINAL: + return 0 + if store.history_job_already_folded(conn, printer["host"], filename, job_id, end_dt): + return 0 + if store.history_job_retired(conn, printer["host"], job_id): + return 0 + fold_filament_resume( + conn, + printer["host"], + filename, + job_id, + start_dt, + print_duration_s=duration_s, + watcher_resume=watcher_resume, + ) + metadata = job.get("metadata") or {} + planned = weight_of(metadata) + material = material_of(metadata) + used_mm = usage.filament_used_mm(job) + total_mm = usage.filament_total_mm(metadata, job) + estimated = float(metadata.get("estimated_time") or 0) + sent = store.latest_send(conn, printer["host"], filename) + known = store.file_by_name(conn, filename) + file_id = (sent or {}).get("file_id") or (known or {}).get("id") + project_id = (sent or {}).get("project_id") or (known or {}).get("project_id") + hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id") + ended_at = end_dt + if not sent and not hinted: + spool = None + else: + spool = choose_spool(conn, printer["host"], material, hinted, ended_at=ended_at) + terminal = state in TERMINAL + live = store.find_live_run(conn, printer["host"], filename) + progress = float((live or {}).get("progress_last") or 0) + if used_mm <= 0 and live: + used_mm = float(live.get("filament_used_mm") or 0) + if total_mm <= 0 and live: + total_mm = float(live.get("filament_total_mm") or 0) + if planned <= 0 and live: + planned = float(live.get("weight_planned_g") or 0) + objects = (live or {}).get("objects") or [] + if isinstance(objects, str): + try: + objects = json.loads(objects) + except json.JSONDecodeError: + objects = [] + used_g, source = usage.resolve_used_grams( + planned, used_mm, total_mm, progress, state, duration_s + ) + row = store.upsert_run(conn, { + "printer_host": printer["host"], + "printer_name": printer["name"], + "filename": filename, + "file_id": file_id, + "project_id": project_id, + "spool_id": spool["id"] if spool else None, + "state": state, + "started_at": start_dt, + "ended_at": ended_at, + "duration_s": duration_s, + "weight_g": planned, + "weight_planned_g": planned, + "weight_used_g": used_g, + "weight_source": source, + "filament_used_mm": used_mm, + "filament_total_mm": total_mm, + "progress_last": progress, + "estimated_s": estimated, + "energy_cost": energy_cost(duration_s, cfg["energy"]), + "moonraker_job_id": job_id, + "needs_spool": bool(terminal and used_g > 0 and not spool), + "material_type": material, + "message": (job.get("message") or "") if state == "error" else "", + "objects": objects, + }) + store.adopt_live_run(conn, printer["host"], filename, row["id"]) + row = store.get_run_row(conn, row["id"]) + if not terminal: + store.set_printer_active_run(conn, printer["host"], row["id"]) + else: + active = store.find_live_run(conn, printer["host"]) + if not active: + ended = row.get("ended_at") or ended_at + store.ensure_bed_after_print( + conn, printer["host"], ended_at=ended, message=filename or state + ) + store.set_printer_active_run(conn, printer["host"], None) + if apply_deduction(conn, row, cfg["energy"], metadata=metadata): + return 1 + return 0 + + +def deduct_stale_live_runs(conn, cfg, older_than_s=LIVE_DEDUCT_FALLBACK_S): + """Live-Läufe ohne History nach Frist abziehen (Fertig-Pfad ohne History).""" + rows = store.list_stale_live_runs(conn, older_than_s=older_than_s) + n = 0 + for row in rows: + if apply_deduction(conn, row, cfg["energy"]): + n += 1 + return n + + +def sync_history(conn, cfg, host=None, watcher_resume=False): + """History abgleichen. Serialisiert über Session-Advisory-Lock.""" printers = store.list_printers(conn) + if host: + printers = [p for p in printers if p.get("host") == host] def fetch(printer): return printer, moonraker.history(printer["host"]) - with ThreadPoolExecutor(max_workers=max(1, len(printers))) as pool: - fetched = list(pool.map(fetch, printers)) + with ThreadPoolExecutor(max_workers=max(1, len(printers) or 1)) as pool: + fetched = list(pool.map(fetch, printers)) if printers else [] collected = [] for printer, items in fetched: - for job in items: + for job in history_in_order(items): collected.append((printer, job)) deducted = 0 - for printer, job in collected: - job_id = str(job.get("job_id") or "") - if not job_id or str(job_id).startswith("live:"): - continue - filename = job.get("filename") or "" - metadata = job.get("metadata") or {} - planned = weight_of(metadata) - material = material_of(metadata) - state = job.get("status") or "" - used_mm = usage.filament_used_mm(job) - total_mm = usage.filament_total_mm(metadata, job) - estimated = float(metadata.get("estimated_time") or 0) - sent = store.latest_send(conn, printer["host"], filename) - known = store.file_by_name(conn, filename) - file_id = (sent or {}).get("file_id") or (known or {}).get("id") - project_id = (sent or {}).get("project_id") or (known or {}).get("project_id") - hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id") - ended_at = unix_to_dt(job.get("end_time")) - # Ohne Send-Eintrag keine Rate — sonst zieht eine neue Spule alle alten Jobs ab. - if not sent and not hinted: - spool = None - else: - spool = choose_spool(conn, printer["host"], material, hinted, ended_at=ended_at) - terminal = state in TERMINAL - live = store.find_live_run(conn, printer["host"], filename) - progress = float((live or {}).get("progress_last") or 0) - if used_mm <= 0 and live: - used_mm = float(live.get("filament_used_mm") or 0) - if total_mm <= 0 and live: - total_mm = float(live.get("filament_total_mm") or 0) - if planned <= 0 and live: - planned = float(live.get("weight_planned_g") or 0) - objects = (live or {}).get("objects") or [] - if isinstance(objects, str): - try: - objects = json.loads(objects) - except json.JSONDecodeError: - objects = [] - used_g, source = usage.resolve_used_grams( - planned, used_mm, total_mm, progress, state, job.get("print_duration") or 0 - ) - row = store.upsert_run(conn, { - "printer_host": printer["host"], - "printer_name": printer["name"], - "filename": filename, - "file_id": file_id, - "project_id": project_id, - "spool_id": spool["id"] if spool else None, - "state": state, - "started_at": unix_to_dt(job.get("start_time")), - "ended_at": unix_to_dt(job.get("end_time")), - "duration_s": float(job.get("print_duration") or job.get("total_duration") or 0), - "weight_g": planned, - "weight_planned_g": planned, - "weight_used_g": used_g, - "weight_source": source, - "filament_used_mm": used_mm, - "filament_total_mm": total_mm, - "progress_last": progress, - "estimated_s": estimated, - "energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]), - "moonraker_job_id": job_id, - "needs_spool": bool(terminal and used_g > 0 and not spool), - "material_type": material, - "message": (job.get("message") or "") if state == "error" else "", - "objects": objects, - }) - # Live sofort auf History hängen — auch solange noch in_progress (eine Protokoll-Zeile) - store.adopt_live_run(conn, printer["host"], filename, row["id"]) - row = store.get_run_row(conn, row["id"]) - if terminal: - # Nachzug nicht während eines neuen Live-Drucks — sonst „Platte belegt“ zu Unrecht - active = store.find_live_run(conn, printer["host"]) - if not active: - ended = row.get("ended_at") or unix_to_dt(job.get("end_time")) - store.ensure_bed_after_print( - conn, printer["host"], ended_at=ended, message=filename or state - ) - if apply_deduction(conn, row, cfg["energy"], metadata=metadata): - deducted += 1 + conn.execute("SELECT pg_advisory_lock(%s)", (SYNC_LOCK_KEY,)) + try: + for printer, job in collected: + deducted += _sync_one_job( + conn, cfg, printer, job, watcher_resume=watcher_resume + ) + deducted += deduct_stale_live_runs(conn, cfg) + finally: + conn.execute("SELECT pg_advisory_unlock(%s)", (SYNC_LOCK_KEY,)) return {"gelesen": len(collected), "abgezogen": deducted} diff --git a/grotti/migrations/014_run_jobs.sql b/grotti/migrations/014_run_jobs.sql new file mode 100644 index 0000000..a5fe889 --- /dev/null +++ b/grotti/migrations/014_run_jobs.sql @@ -0,0 +1,16 @@ +-- Jede Moonraker-Job-ID bleibt einem Lauf zugeordnet, auch nach dem Zusammenlegen. +CREATE TABLE IF NOT EXISTS run_jobs ( + printer_host text NOT NULL, + job_id text NOT NULL, + run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE, + PRIMARY KEY (printer_host, job_id) +); + +CREATE INDEX IF NOT EXISTS run_jobs_run ON run_jobs (run_id); + +INSERT INTO run_jobs (printer_host, job_id, run_id) +SELECT printer_host, moonraker_job_id, id +FROM runs +WHERE moonraker_job_id <> '' + AND moonraker_job_id NOT LIKE 'live:%' +ON CONFLICT DO NOTHING; diff --git a/grotti/migrations/015_active_run.sql b/grotti/migrations/015_active_run.sql new file mode 100644 index 0000000..b9bb81d --- /dev/null +++ b/grotti/migrations/015_active_run.sql @@ -0,0 +1,6 @@ +-- Last active run per printer (survives Grotti restart during pause) +ALTER TABLE printers + ADD COLUMN IF NOT EXISTS active_run_id bigint REFERENCES runs (id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS printers_active_run ON printers (active_run_id) + WHERE active_run_id IS NOT NULL; diff --git a/grotti/migrations/016_queue_run.sql b/grotti/migrations/016_queue_run.sql new file mode 100644 index 0000000..fdf7ba6 --- /dev/null +++ b/grotti/migrations/016_queue_run.sql @@ -0,0 +1,10 @@ +-- Queue item points at the run it started. At most one running job per host. +ALTER TABLE print_queue + ADD COLUMN IF NOT EXISTS run_id bigint REFERENCES runs (id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS print_queue_run ON print_queue (run_id) + WHERE run_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS print_queue_one_running_per_host + ON print_queue (host) + WHERE status = 'running' AND host <> ''; diff --git a/grotti/print_queue.py b/grotti/print_queue.py index 0c7d33b..f55ef0b 100644 --- a/grotti/print_queue.py +++ b/grotti/print_queue.py @@ -34,6 +34,9 @@ PRINT_START_TIMEOUT_S = 600 # Nach erfolgreichem Senden ist die Phase await_print — dort gilt das nicht, # sonst wird ein noch anlaufender Druck als „Neustart“ neu eingereiht. SIMPLE_START_STUCK_S = 45 +# Anycubic meldet kurz cancelled/standby bei LeviQ und Filament-Resume. +TERMINAL_CONFIRM_HITS = 3 +AWAIT_CANCEL_HITS = 3 def _ablauf_needs_motors(item): @@ -151,10 +154,20 @@ class PrintQueueDispatcher: def _auto_retry_enabled(self): return bool((self.cfg or {}).get("auto_retry", True)) + def _retryable_message(self, message): + text = (message or "").lower() + if "abgebrochen" in text: + return False + if "druck abgebrochen" in text: + return False + return True + def _try_auto_retry(self, conn, item, message): - """Ein automatischer Neuversuch; sonst False.""" + """Ein automatischer Neuversuch; sonst False. Nicht nach Abbruch.""" if not self._auto_retry_enabled(): return False + if not self._retryable_message(message): + return False retries = int(item.get("retry_count") or 0) if retries >= 1: return False @@ -172,6 +185,23 @@ class PrintQueueDispatcher: ) return True + def _terminal_hits(self, item_id, kind="end"): + key = "%s:%s" % (item_id, kind) + if not hasattr(self, "_term_hits"): + self._term_hits = {} + self._term_hits[key] = self._term_hits.get(key, 0) + 1 + return self._term_hits[key] + + def _clear_terminal_hits(self, item_id, kind=None): + if not hasattr(self, "_term_hits"): + return + if kind: + self._term_hits.pop("%s:%s" % (item_id, kind), None) + return + for key in list(self._term_hits): + if key.startswith("%s:" % item_id): + self._term_hits.pop(key, None) + def _fail_stuck_simple_start(self, conn, item, printer): host = printer["host"] msg = "Start abgebrochen (Neustart)" @@ -277,6 +307,13 @@ class PrintQueueDispatcher: lives[host] = live state = (live.get("state") or "").lower() if state in BUSY: + # Laufenden Einfach-Druck mitziehen. Sonst bleibt await_print + # stehen, bis der Drucker frei ist — und der Start gilt als Timeout, + # obwohl er längst druckt. + running = store.running_print_on_host(conn, host) + if running and not _has_ablauf(running): + self._advance_simple(conn, running, printer, live) + conn.commit() continue if state not in FREE: continue @@ -418,26 +455,31 @@ class PrintQueueDispatcher: bed_leveling=bed_leveling, ) name = (result or {}).get("filename") or "" + # Abbruch während Upload: Status nicht überschreiben, Druck stoppen. + current = store.get_print_queue_row(wconn, item_id) + if current and (current.get("status") or "") in ("cancelled", "dismissed"): + try: + moonraker.command(host, "cancel") + except Exception: + pass + return oid = item.get("order_item_id") if oid: from grotti import orders orders.attach_order_item_to_live_run(wconn, host, name, oid) - # await_print: Datei ist raus, Drucker meldet printing oft erst - # nach einer Minute. Nicht bei starting lassen — sonst gilt nach - # 45 s der Neustart-Retry und der zweite Start trifft „druckt schon“. - store.update_print_queue( - wconn, - item_id, - { - "status": "running", - "message": "Datei ist auf %s. Druckstart kann etwa eine Minute dauern." - % printer_copy["name"], - "host": host, - "phase": "await_print", - "phase_started_at": "now", - }, - ) + live_run = store.find_active_run(wconn, host, name) + fields = { + "status": "running", + "message": "Datei ist auf %s. Druckstart kann etwa eine Minute dauern." + % printer_copy["name"], + "host": host, + "phase": "await_print", + "phase_started_at": "now", + } + if live_run: + fields["run_id"] = live_run["id"] + store.update_print_queue(wconn, item_id, fields) self._event( wconn, host, @@ -493,6 +535,7 @@ class PrintQueueDispatcher: state = (live.get("state") or "").lower() if phase in ("starting", "await_print"): if state in ("printing", "paused", "heating") or live.get("print_active"): + self._clear_terminal_hits(item["id"]) store.update_print_queue( conn, item["id"], @@ -503,6 +546,21 @@ class PrintQueueDispatcher: }, ) return + if phase == "await_print" and state == "cancelled": + hits = self._terminal_hits(item["id"], "await_cancel") + if hits >= AWAIT_CANCEL_HITS: + self._clear_terminal_hits(item["id"]) + store.update_print_queue( + conn, + item["id"], + { + "status": "error", + "message": "Druck abgebrochen.", + "phase": "", + "phase_started_at": None, + }, + ) + return if phase == "await_print" and self._print_start_timed_out(item): store.update_print_queue( conn, @@ -521,6 +579,7 @@ class PrintQueueDispatcher: if phase != "print_active": return if state in ("printing", "paused", "heating"): + self._clear_terminal_hits(item["id"]) return if state == "error": store.update_print_queue( @@ -534,7 +593,9 @@ class PrintQueueDispatcher: }, ) return - if state in FREE or state in ("complete", "cancelled"): + # Nur complete = fertig. cancelled/standby erst nach Entprellen. + if state == "complete": + self._clear_terminal_hits(item["id"]) store.update_print_queue( conn, item["id"], @@ -553,6 +614,61 @@ class PrintQueueDispatcher: "%s fertig: %s" % (printer["name"], item.get("filename") or "Druck"), item.get("filename") or "", ) + return + if state in ("cancelled", "standby"): + hits = self._terminal_hits(item["id"], "end") + if hits < TERMINAL_CONFIRM_HITS: + return + live2 = live + try: + live2 = moonraker.query(printer["host"]) + except Exception: + pass + state2 = (live2.get("state") or "").lower() + if state2 in ("printing", "paused", "heating") or live2.get("print_active"): + self._clear_terminal_hits(item["id"]) + return + self._clear_terminal_hits(item["id"]) + if state2 == "complete": + 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 "", + ) + return + # Echter Abbruch — nicht „Fertig.“ und kein Auto-Retry. + store.update_print_queue( + conn, + item["id"], + { + "status": "cancelled", + "message": "Druck abgebrochen.", + "phase": "", + "phase_started_at": None, + }, + ) + self._event( + conn, + printer["host"], + printer["name"], + "abbruch", + "%s: Druck abgebrochen (%s)" + % (printer["name"], item.get("filename") or "ohne Datei"), + item.get("filename") or "", + ) def _begin_ablauf(self, conn, item, printer, live): try: @@ -790,9 +906,31 @@ class PrintQueueDispatcher: live.get("message") or "Druckerfehler während des Drucks.", ) return - # Erst weiter, wenn der Job wirklich gelaufen ist — sonst Race - # (noch standby/complete vom vorherigen Auftrag). - if phase == "print_active" and state in ("complete", "standby"): + # Nur complete = Druckende. standby/cancelled entprellen (Filament-Resume). + if phase == "print_active" and state == "complete": + self._clear_terminal_hits(item["id"]) + self._next_step(conn, item, printer, live) + return + if phase == "print_active" and state in ("cancelled", "standby"): + hits = self._terminal_hits(item["id"], "ablauf_end") + if hits < TERMINAL_CONFIRM_HITS: + return + try: + live = moonraker.query(printer["host"]) + state = (live.get("state") or "").lower() + except Exception: + pass + if state in ("printing", "paused", "heating") or live.get("print_active"): + self._clear_terminal_hits(item["id"]) + return + self._clear_terminal_hits(item["id"]) + if state == "complete": + self._next_step(conn, item, printer, live) + return + if state == "cancelled": + self._fail_ablauf(conn, item, printer, "Druck abgebrochen.") + return + # standby nach Entprellen: Job wirklich zu Ende self._next_step(conn, item, printer, live) return # Idle schon während des Drucks verlängern — sonst greift M84/Default zu früh. @@ -912,16 +1050,22 @@ class PrintQueueDispatcher: ) name = (result or {}).get("filename") or "" row = store.get_print_queue_row(wconn, item_id) or item - store.update_print_queue( - wconn, - item_id, - { - "phase": "printing", - "phase_started_at": "now", - "file_id": step_copy["file_id"], - "message": self._ablauf_message(row, "druckt %s" % name), - }, - ) + if (row.get("status") or "") in ("cancelled", "dismissed"): + try: + moonraker.command(printer_copy["host"], "cancel") + except Exception: + pass + return + fields = { + "phase": "printing", + "phase_started_at": "now", + "file_id": step_copy["file_id"], + "message": self._ablauf_message(row, "druckt %s" % name), + } + live_run = store.find_active_run(wconn, printer_copy["host"], name) + if live_run: + fields["run_id"] = live_run["id"] + store.update_print_queue(wconn, item_id, fields) self._step_event(wconn, row, printer_copy, "druckt %s" % name) wconn.commit() except GrottiError as exc: diff --git a/grotti/store.py b/grotti/store.py index 2637795..0764095 100644 --- a/grotti/store.py +++ b/grotti/store.py @@ -13,7 +13,7 @@ def iso(value): return value.isoformat() if value else None -def project_out(row, files=None, file_count=None): +def project_out(row, files=None, file_count=None, stats=None): data = { "id": row["id"], "name": row["name"], @@ -27,9 +27,166 @@ def project_out(row, files=None, file_count=None): data["files"] = files if file_count is not None: data["file_count"] = file_count + if stats is not None: + data["stats"] = stats return data +def _project_stats_row(conn, project_id): + """Aggregation Läufe/Kosten/Druckzeit für ein Projekt.""" + agg = conn.execute( + """ + SELECT + COUNT(*)::int AS run_count, + COALESCE(SUM(CASE WHEN state IN ('in_progress', 'printing', 'paused') THEN 1 ELSE 0 END), 0)::int AS active_count, + COALESCE(SUM(CASE WHEN verdict = 'gut' OR pieces_ok > 0 THEN 1 ELSE 0 END), 0)::int AS ok_count, + COALESCE(SUM(CASE WHEN verdict = 'ausschuss' OR pieces_bad > 0 THEN 1 ELSE 0 END), 0)::int AS scrap_count, + COALESCE(SUM(pieces_ok), 0)::int AS pieces_ok, + COALESCE(SUM(pieces_bad), 0)::int AS pieces_bad, + COALESCE(SUM(duration_s), 0)::bigint AS duration_s, + COALESCE(SUM(COALESCE(NULLIF(weight_used_g, 0), weight_g, 0)), 0)::float AS weight_g, + COALESCE(SUM(filament_cost), 0)::float AS filament_cost, + COALESCE(SUM(energy_cost), 0)::float AS energy_cost, + MAX(started_at) AS last_started_at + FROM runs + WHERE project_id = %s + AND NOT (moonraker_job_id LIKE 'live:%%' AND state IN ('in_progress', 'printing', 'paused')) + """, + (project_id,), + ).fetchone() + last = conn.execute( + """ + SELECT id, filename, state, started_at, ended_at, duration_s, + filament_cost, energy_cost, verdict, file_id + FROM runs + WHERE project_id = %s + AND NOT (moonraker_job_id LIKE 'live:%%' AND state IN ('in_progress', 'printing', 'paused')) + ORDER BY started_at DESC NULLS LAST, id DESC + LIMIT 1 + """, + (project_id,), + ).fetchone() + filament = float(agg["filament_cost"] or 0) if agg else 0.0 + energy = float(agg["energy_cost"] or 0) if agg else 0.0 + last_out = None + if last: + last_out = { + "id": last["id"], + "filename": last["filename"] or "", + "state": last["state"] or "", + "started_at": iso(last["started_at"]), + "ended_at": iso(last["ended_at"]), + "duration_s": last["duration_s"], + "cost": float(last["filament_cost"] or 0) + float(last["energy_cost"] or 0), + "verdict": last["verdict"] or "", + "file_id": last["file_id"], + } + return { + "run_count": int(agg["run_count"] or 0) if agg else 0, + "active_count": int(agg["active_count"] or 0) if agg else 0, + "ok_count": int(agg["ok_count"] or 0) if agg else 0, + "scrap_count": int(agg["scrap_count"] or 0) if agg else 0, + "pieces_ok": int(agg["pieces_ok"] or 0) if agg else 0, + "pieces_bad": int(agg["pieces_bad"] or 0) if agg else 0, + "duration_s": int(agg["duration_s"] or 0) if agg else 0, + "weight_g": float(agg["weight_g"] or 0) if agg else 0.0, + "filament_cost": filament, + "energy_cost": energy, + "cost": filament + energy, + "last_started_at": iso(agg["last_started_at"]) if agg else None, + "last_run": last_out, + } + + +def _file_print_stats(conn, file_ids): + """pro Datei: wie oft gedruckt, wie oft in der Queue.""" + ids = [int(x) for x in file_ids if x] + if not ids: + return {} + printed = { + int(row["file_id"]): int(row["n"] or 0) + for row in conn.execute( + """ + SELECT file_id, COUNT(*)::int AS n + FROM runs + WHERE file_id = ANY(%s) + AND state NOT IN ('in_progress', 'printing', 'paused') + AND NOT (moonraker_job_id LIKE 'live:%%') + GROUP BY file_id + """, + (ids,), + ).fetchall() + if row["file_id"] + } + queued = { + int(row["file_id"]): int(row["n"] or 0) + for row in conn.execute( + """ + SELECT file_id, COUNT(*)::int AS n + FROM print_queue + WHERE file_id = ANY(%s) AND status IN ('waiting', 'running', 'error') + GROUP BY file_id + """, + (ids,), + ).fetchall() + if row["file_id"] + } + ok_once = { + int(row["file_id"]) + for row in conn.execute( + """ + SELECT DISTINCT file_id + FROM runs + WHERE file_id = ANY(%s) + AND (verdict = 'gut' OR pieces_ok > 0 OR state IN ('completed', 'complete')) + AND NOT (moonraker_job_id LIKE 'live:%%') + """, + (ids,), + ).fetchall() + if row["file_id"] + } + out = {} + for fid in ids: + out[fid] = { + "printed": printed.get(fid, 0), + "queued": queued.get(fid, 0), + "ok_once": fid in ok_once, + } + return out + + +def list_project_runs(conn, project_id, limit=40, offset=0): + """Läufe eines Projekts, neueste zuerst.""" + limit = max(1, min(200, int(limit or 40))) + offset = max(0, int(offset or 0)) + total_row = conn.execute( + """ + SELECT COUNT(*)::int AS n FROM runs + WHERE project_id = %s + AND NOT (moonraker_job_id LIKE 'live:%%' AND state IN ('in_progress', 'printing', 'paused')) + """, + (project_id,), + ).fetchone() + rows = conn.execute( + """ + SELECT r.*, oi.order_id AS order_id + FROM runs r + LEFT JOIN order_items oi ON oi.id = r.order_item_id + WHERE r.project_id = %s + AND NOT (r.moonraker_job_id LIKE 'live:%%' AND r.state IN ('in_progress', 'printing', 'paused')) + ORDER BY r.started_at DESC NULLS LAST, r.id DESC + LIMIT %s OFFSET %s + """, + (project_id, limit, offset), + ).fetchall() + return { + "items": [run_out(row) for row in rows], + "total": int(total_row["n"] or 0) if total_row else 0, + "limit": limit, + "offset": offset, + } + + def file_folder(stored_path, source, watch_dir=None): """Relativpfad des Elternordners unter watch_dir; Uploads → „Upload“.""" if (source or "") == "upload": @@ -176,10 +333,15 @@ def run_out(row): "pieces_ok": int(row.get("pieces_ok") or 0), "pieces_bad": int(row.get("pieces_bad") or 0), "order_item_id": row.get("order_item_id"), + "order_id": row.get("order_id"), "material_type": row["material_type"], "chargen_nr": row.get("chargen_nr") or "", "message": row.get("message") or "", "objects": _json_list(row.get("objects")), + "spool_names": list(row.get("spool_names") or []), + "segment_count": int(row.get("segment_count") or 0), + "pause_s": float(row.get("pause_s") or 0) or None, + "merge_with_id": row.get("merge_with_id"), "cost_per_part": ( (float(row.get("filament_cost") or 0) + float(row.get("energy_cost") or 0)) / int(row["pieces_ok"]) @@ -189,20 +351,127 @@ def run_out(row): } -def list_projects(conn): +def enrich_runs(conn, runs): + """Spulennamen, Abschnitte, Pausendauer und Verdachtspaare ergänzen.""" + if not runs: + return runs + ids = [int(r["id"]) for r in runs] + by_id = {r["id"]: r for r in runs} + for r in runs: + r["spool_names"] = [] + r["segment_count"] = 0 + r["pause_s"] = None + r["merge_with_id"] = None + for row in conn.execute( + """ + SELECT rs.run_id, s.name + FROM run_spools rs + JOIN spools s ON s.id = rs.spool_id + WHERE rs.run_id = ANY(%s) + ORDER BY rs.id + """, + (ids,), + ).fetchall(): + names = by_id[row["run_id"]].setdefault("spool_names", []) + if row["name"] and row["name"] not in names: + names.append(row["name"]) + for row in conn.execute( + """ + SELECT run_id, COUNT(*)::int AS n, + COUNT(DISTINCT spool_id)::int AS spools + FROM run_segments + WHERE run_id = ANY(%s) + GROUP BY run_id + """, + (ids,), + ).fetchall(): + by_id[row["run_id"]]["segment_count"] = int(row["n"] or 0) + if int(row["spools"] or 0) > 1 and not by_id[row["run_id"]]["spool_names"]: + segs = list_run_segments(conn, row["run_id"]) + by_id[row["run_id"]]["spool_names"] = [ + s["spool_name"] for s in segs if s.get("spool_name") + ] + for row in conn.execute( + """ + SELECT run_id, + EXTRACT(EPOCH FROM ( + MAX(created_at) FILTER (WHERE message ILIKE '%%druckt weiter%%') + - MIN(created_at) FILTER (WHERE kind = 'spule' OR message ILIKE '%%pausiert%%') + )) AS pause_s + FROM events + WHERE run_id = ANY(%s) + GROUP BY run_id + """, + (ids,), + ).fetchall(): + pause = row.get("pause_s") + if pause is not None and float(pause) > 0: + by_id[row["run_id"]]["pause_s"] = float(pause) + # Verdacht: Abbruch + Folgelauf gleiche Datei, Lücke < 10 min + for run in runs: + if (run.get("state") or "") not in ("cancelled", "interrupted", "error"): + continue + if not run.get("ended_at") or not run.get("filename"): + continue + partner = conn.execute( + """ + SELECT id FROM runs + WHERE printer_host = %s + AND filename = %s + AND id <> %s + AND started_at IS NOT NULL + AND started_at BETWEEN %s::timestamptz - interval '30 seconds' + AND %s::timestamptz + interval '10 minutes' + ORDER BY started_at + LIMIT 1 + """, + ( + run["printer_host"], + run["filename"], + run["id"], + run["ended_at"], + run["ended_at"], + ), + ).fetchone() + if partner: + run["merge_with_id"] = partner["id"] + other = by_id.get(partner["id"]) + if other and not other.get("merge_with_id"): + other["merge_with_id"] = run["id"] + return runs + + +def list_projects(conn, status=None): + where = [] + values = [] + if status == "aktiv": + where.append("p.status <> 'fertig'") + elif status == "fertig": + where.append("p.status = 'fertig'") + elif status: + where.append("p.status = %s") + values.append(status) + clause = (" WHERE " + " AND ".join(where)) if where else "" rows = conn.execute( """ SELECT p.*, COUNT(f.id) AS file_count FROM projects p LEFT JOIN files f ON f.project_id = p.id + %s GROUP BY p.id ORDER BY p.created_at DESC - """ + """ % clause, + values, ).fetchall() - return [project_out(row, file_count=row["file_count"]) for row in rows] + out = [] + for row in rows: + stats = _project_stats_row(conn, row["id"]) + item = project_out(row, file_count=row["file_count"], stats=stats) + out.append(item) + return out -def get_project(conn, project_id): +def get_project(conn, project_id, watch_dir=None): row = conn.execute("SELECT * FROM projects WHERE id = %s", (project_id,)).fetchone() if not row: return None @@ -210,7 +479,29 @@ def get_project(conn, project_id): "SELECT * FROM files WHERE project_id = %s ORDER BY mtime DESC, name", (project_id,), ).fetchall() - return project_out(row, files=[file_out(item) for item in files]) + file_stats = _file_print_stats(conn, [item["id"] for item in files]) + file_outs = [] + for item in files: + fo = file_out(item, watch_dir=watch_dir) + st = file_stats.get(item["id"]) or {"printed": 0, "queued": 0, "ok_once": False} + fo["printed"] = st["printed"] + fo["queued"] = st["queued"] + fo["ok_once"] = st["ok_once"] + file_outs.append(fo) + stats = _project_stats_row(conn, project_id) + if file_outs: + stats["files_ok"] = sum(1 for f in file_outs if f.get("ok_once")) + stats["files_total"] = len(file_outs) + stats["suggest_fertig"] = ( + stats["files_ok"] >= stats["files_total"] + and stats["active_count"] == 0 + and row["status"] != "fertig" + ) + else: + stats["files_ok"] = 0 + stats["files_total"] = 0 + stats["suggest_fertig"] = False + return project_out(row, files=file_outs, file_count=len(file_outs), stats=stats) def create_project(conn, name, notes, status, folder=""): @@ -275,8 +566,67 @@ def list_file_rows(conn): return conn.execute("SELECT * FROM files ORDER BY mtime DESC, name").fetchall() -def list_files(conn, watch_dir=None): - return [file_out(row, watch_dir=watch_dir) for row in list_file_rows(conn)] +def list_files( + conn, + watch_dir=None, + *, + family="", + material="", + project_id=None, + q="", + host="", + limit=None, + offset=0, +): + """Dateien mit optionalen Filtern. Ohne limit: volle Liste (Web-UI).""" + where = [] + values = [] + family = (family or "").strip() + material = (material or "").strip() + q = (q or "").strip() + host = (host or "").strip() + if family: + where.append("printer_family = %s") + values.append(family) + if project_id is not None: + where.append("project_id = %s") + values.append(int(project_id)) + if material: + where.append( + """EXISTS ( + SELECT 1 FROM jsonb_array_elements(COALESCE(filaments, '[]'::jsonb)) AS f + WHERE COALESCE(f->>'type', f->>'material_type', '') ILIKE %s + )""" + ) + values.append("%" + material + "%") + if q: + where.append("name ILIKE %s") + values.append("%" + q + "%") + if host: + # zuletzt auf diesem Host gesendet/gedruckt + where.append( + """( + EXISTS (SELECT 1 FROM sends s WHERE s.file_id = files.id AND s.printer_host = %s) + OR EXISTS (SELECT 1 FROM runs r WHERE r.file_id = files.id AND r.printer_host = %s) + )""" + ) + values.extend([host, host]) + clause = (" WHERE " + " AND ".join(where)) if where else "" + total_row = conn.execute( + "SELECT COUNT(*)::int AS n FROM files" + clause, + values, + ).fetchone() + total = int(total_row["n"] or 0) if total_row else 0 + sql = "SELECT * FROM files" + clause + " ORDER BY mtime DESC, name" + params = list(values) + if limit is not None: + limit = max(1, min(500, int(limit))) + offset = max(0, int(offset or 0)) + sql += " LIMIT %s OFFSET %s" + params.extend([limit, offset]) + rows = conn.execute(sql, params).fetchall() + items = [file_out(row, watch_dir=watch_dir) for row in rows] + return {"items": items, "total": total, "limit": limit, "offset": int(offset or 0)} def get_file_row(conn, file_id): @@ -400,10 +750,32 @@ def forget_missing_watch(conn, seen): conn.execute("DELETE FROM files WHERE source = 'watch'") -def list_spools(conn): +def list_spools(conn, *, aktiv=None, limit=None, offset=0): ensure_spool_codes(conn) - rows = conn.execute("SELECT * FROM spools ORDER BY name, id").fetchall() - return [spool_out(row) for row in rows] + where = [] + values = [] + if aktiv is True: + where.append("archived_at IS NULL") + where.append("COALESCE(remaining_g, 0) > 0") + elif aktiv is False: + where.append("(archived_at IS NOT NULL OR COALESCE(remaining_g, 0) <= 0)") + clause = (" WHERE " + " AND ".join(where)) if where else "" + total_row = conn.execute("SELECT COUNT(*)::int AS n FROM spools" + clause, values).fetchone() + total = int(total_row["n"] or 0) if total_row else 0 + sql = "SELECT * FROM spools" + clause + " ORDER BY name, id" + params = list(values) + if limit is not None: + limit = max(1, min(500, int(limit))) + offset = max(0, int(offset or 0)) + sql += " LIMIT %s OFFSET %s" + params.extend([limit, offset]) + rows = conn.execute(sql, params).fetchall() + return { + "items": [spool_out(row) for row in rows], + "total": total, + "limit": limit, + "offset": int(offset or 0), + } def get_spool_row(conn, spool_id): @@ -692,19 +1064,7 @@ def latest_send(conn, host, filename): ).fetchone() -def latest_send_for_file(conn, host, file_id): - """Letzter Sende-Eintrag für Datei auf diesem Host (für „Nochmal“).""" - if not host or not file_id: - return None - row = conn.execute( - """ - SELECT * FROM sends - WHERE printer_host = %s AND file_id = %s - ORDER BY created_at DESC - LIMIT 1 - """, - (host, file_id), - ).fetchone() +def _send_out(row): if not row: return None return { @@ -720,6 +1080,163 @@ def latest_send_for_file(conn, host, file_id): } +def latest_send_for_file(conn, host, file_id): + """Letzter Sende-Eintrag für Datei auf diesem Host (für „Nochmal“).""" + if not host or not file_id: + return None + row = conn.execute( + """ + SELECT * FROM sends + WHERE printer_host = %s AND file_id = %s + ORDER BY created_at DESC + LIMIT 1 + """, + (host, file_id), + ).fetchone() + return _send_out(row) + + +def latest_send_for_host(conn, host): + """Letzter Sende-Eintrag dieses Hosts (Cockpit „Nochmal“).""" + host = (host or "").strip() + if not host: + return None + row = conn.execute( + """ + SELECT * FROM sends + WHERE printer_host = %s AND file_id IS NOT NULL + ORDER BY created_at DESC + LIMIT 1 + """, + (host,), + ).fetchone() + return _send_out(row) + + +def find_run_by_job(conn, host, job_id): + """Lauf zu einer Moonraker-Job-ID, auch wenn sie nur noch als Alias hängt.""" + host = (host or "").strip() + job_id = str(job_id or "") + if not host or not job_id: + return None + row = conn.execute( + """ + SELECT r.* + FROM run_jobs j + JOIN runs r ON r.id = j.run_id + WHERE j.printer_host = %s AND j.job_id = %s + """, + (host, job_id), + ).fetchone() + if row: + return row + return conn.execute( + """ + SELECT * FROM runs + WHERE printer_host = %s AND moonraker_job_id = %s + """, + (host, job_id), + ).fetchone() + + +def remember_run_job(conn, host, job_id, run_id, replace=False): + """Job-ID dauerhaft diesem Lauf zuordnen. History darf sie nicht neu anlegen.""" + host = (host or "").strip() + job_id = str(job_id or "") + if not host or not job_id or not run_id or job_id.startswith("live:"): + return None + if replace: + return conn.execute( + """ + INSERT INTO run_jobs (printer_host, job_id, run_id) + VALUES (%s, %s, %s) + ON CONFLICT (printer_host, job_id) DO UPDATE SET run_id = EXCLUDED.run_id + RETURNING run_id + """, + (host, job_id, run_id), + ).fetchone() + return conn.execute( + """ + INSERT INTO run_jobs (printer_host, job_id, run_id) + VALUES (%s, %s, %s) + ON CONFLICT (printer_host, job_id) DO NOTHING + RETURNING run_id + """, + (host, job_id, run_id), + ).fetchone() + + +def move_run_jobs(conn, host, source_id, target_id): + """Alle Job-IDs eines Laufs auf den überlebenden Lauf legen.""" + if not source_id or not target_id or source_id == target_id: + return + conn.execute( + """ + DELETE FROM run_jobs AS src + WHERE src.run_id = %s + AND src.printer_host = %s + AND EXISTS ( + SELECT 1 FROM run_jobs AS dst + WHERE dst.printer_host = src.printer_host + AND dst.job_id = src.job_id + AND dst.run_id = %s + ) + """, + (source_id, host, target_id), + ) + conn.execute( + """ + UPDATE run_jobs + SET run_id = %s + WHERE run_id = %s AND printer_host = %s + """, + (target_id, source_id, host), + ) + + +# Alias für alte Aufrufer. +_move_run_jobs = move_run_jobs + + +def set_printer_active_run(conn, host, run_id): + """Letzten aktiven Lauf am Drucker merken (Pause/Runout überlebt Neustart).""" + host = (host or "").strip() + if not host: + return + conn.execute( + "UPDATE printers SET active_run_id = %s WHERE host = %s", + (run_id, host), + ) + + +def printer_active_run_id(conn, host): + host = (host or "").strip() + if not host: + return None + row = conn.execute( + "SELECT active_run_id FROM printers WHERE host = %s", + (host,), + ).fetchone() + return (row or {}).get("active_run_id") + + +def list_stale_live_runs(conn, older_than_s=600): + """Geschlossene Live-Läufe ohne History-Abzug nach Frist.""" + return conn.execute( + """ + SELECT * FROM runs + WHERE moonraker_job_id LIKE 'live:%%' + AND deducted = false + AND state IN ('completed', 'cancelled', 'interrupted', 'error') + AND ended_at IS NOT NULL + AND ended_at < now() - make_interval(secs => %s) + ORDER BY ended_at + LIMIT 50 + """, + (float(older_than_s),), + ).fetchall() + + def upsert_run(conn, item): payload = { "weight_planned_g": float(item.get("weight_planned_g") or item.get("weight_g") or 0), @@ -737,7 +1254,68 @@ def upsert_run(conn, item): payload["order_item_id"] = item.get("order_item_id") if "weight_g" not in payload or payload.get("weight_g") is None: payload["weight_g"] = payload["weight_planned_g"] - return conn.execute( + known = find_run_by_job(conn, payload.get("printer_host"), payload.get("moonraker_job_id")) + if known and str(known.get("moonraker_job_id") or "") != str(payload.get("moonraker_job_id") or ""): + payload["run_id"] = known["id"] + row = conn.execute( + """ + UPDATE runs SET + printer_name = %(printer_name)s, + filename = CASE WHEN %(filename)s <> '' THEN %(filename)s ELSE filename END, + file_id = COALESCE(file_id, %(file_id)s), + project_id = COALESCE(project_id, %(project_id)s), + spool_id = COALESCE(spool_id, %(spool_id)s), + order_item_id = COALESCE(order_item_id, %(order_item_id)s), + state = CASE + WHEN COALESCE(%(duration_s)s, 0) <= 0 + AND %(ended_at)s::timestamptz IS NULL + AND COALESCE(duration_s, 0) > 0 + THEN state + ELSE %(state)s + END, + started_at = COALESCE(started_at, %(started_at)s), + ended_at = COALESCE(%(ended_at)s::timestamptz, ended_at), + duration_s = GREATEST(COALESCE(duration_s, 0), COALESCE(%(duration_s)s, 0)), + weight_g = CASE WHEN %(weight_g)s > 0 THEN %(weight_g)s ELSE weight_g END, + weight_planned_g = CASE + WHEN %(weight_planned_g)s > 0 THEN %(weight_planned_g)s + ELSE weight_planned_g + END, + weight_used_g = CASE + WHEN %(weight_used_g)s > 0 THEN %(weight_used_g)s + ELSE weight_used_g + END, + weight_source = CASE + WHEN %(weight_source)s <> '' THEN %(weight_source)s + ELSE weight_source + END, + filament_used_mm = GREATEST(COALESCE(filament_used_mm, 0), %(filament_used_mm)s), + filament_total_mm = CASE + WHEN %(filament_total_mm)s > 0 THEN %(filament_total_mm)s + ELSE filament_total_mm + END, + progress_last = GREATEST(COALESCE(progress_last, 0), %(progress_last)s), + estimated_s = CASE + WHEN %(estimated_s)s > 0 THEN %(estimated_s)s + ELSE estimated_s + END, + message = CASE + WHEN %(message)s <> '' THEN %(message)s + ELSE message + END, + needs_spool = CASE WHEN deducted THEN false ELSE %(needs_spool)s END, + material_type = CASE + WHEN %(material_type)s <> '' THEN %(material_type)s + ELSE material_type + END + WHERE id = %(run_id)s + RETURNING * + """, + payload, + ).fetchone() + remember_run_job(conn, payload.get("printer_host"), payload.get("moonraker_job_id"), known["id"]) + return row + row = conn.execute( """ INSERT INTO runs ( printer_host, printer_name, filename, file_id, project_id, spool_id, @@ -761,10 +1339,16 @@ def upsert_run(conn, item): project_id = COALESCE(runs.project_id, EXCLUDED.project_id), spool_id = COALESCE(runs.spool_id, EXCLUDED.spool_id), order_item_id = COALESCE(runs.order_item_id, EXCLUDED.order_item_id), - state = EXCLUDED.state, + state = CASE + WHEN COALESCE(EXCLUDED.duration_s, 0) <= 0 + AND EXCLUDED.ended_at IS NULL + AND COALESCE(runs.duration_s, 0) > 0 + THEN runs.state + ELSE EXCLUDED.state + END, started_at = COALESCE(runs.started_at, EXCLUDED.started_at), - ended_at = EXCLUDED.ended_at, - duration_s = EXCLUDED.duration_s, + ended_at = COALESCE(EXCLUDED.ended_at, runs.ended_at), + duration_s = GREATEST(COALESCE(runs.duration_s, 0), COALESCE(EXCLUDED.duration_s, 0)), weight_g = CASE WHEN EXCLUDED.weight_g > 0 THEN EXCLUDED.weight_g ELSE runs.weight_g END, weight_planned_g = CASE WHEN EXCLUDED.weight_planned_g > 0 THEN EXCLUDED.weight_planned_g @@ -806,6 +1390,10 @@ def upsert_run(conn, item): """, payload, ).fetchone() + if row and row.get("id"): + remember_run_job(conn, row.get("printer_host"), row.get("moonraker_job_id"), row["id"]) + remember_run_job(conn, payload.get("printer_host"), payload.get("moonraker_job_id"), row["id"]) + return row def mark_deducted(conn, run_id, spool_id, filament_cost, energy_cost, weight_used_g=None, weight_source=""): @@ -885,8 +1473,9 @@ def list_runs(conn, limit=200, since=None, until=None, host="", material="", bra values.append(int(limit)) rows = conn.execute( """ - SELECT DISTINCT r.* + SELECT DISTINCT r.*, oi.order_id AS order_id FROM runs r + LEFT JOIN order_items oi ON oi.id = r.order_item_id %s %s ORDER BY r.started_at DESC NULLS LAST, r.id DESC @@ -894,7 +1483,7 @@ def list_runs(conn, limit=200, since=None, until=None, host="", material="", bra """ % (spool_join, clause), values, ).fetchall() - return [run_out(row) for row in rows] + return enrich_runs(conn, [run_out(row) for row in rows]) def get_run_row(conn, run_id): @@ -1009,9 +1598,18 @@ def assign_run_spool(conn, run_id, spool_id): ).fetchone() -def next_druck_chargen(conn): +def chargen_day(started_at, today=None): + """Kalendertag der Charge: Startdatum des Drucks, sonst heute.""" + if started_at is None: + return today or date.today() + if getattr(started_at, "tzinfo", None) is not None: + started_at = started_at.astimezone() + return started_at.date() + + +def next_druck_chargen(conn, on_date=None): """Nächste Druck-Charge des Tages. Objekt-Etiketten (`D-…-01`) blockieren die Nummer.""" - day = date.today().strftime("%Y%m%d") + day = (on_date or date.today()).strftime("%Y%m%d") prefix = "D-%s-" % day batch_re = "^D-%s-[0-9]{4}$" % day object_re = "^D-%s-[0-9]{4}-[0-9]{2}$" % day @@ -1059,7 +1657,7 @@ def ensure_run_chargen(conn, run_id, weight_used_g=None): "completed", "cancelled", "error", "interrupted", ): return None - code = next_druck_chargen(conn) + code = next_druck_chargen(conn, chargen_day(row.get("started_at"))) updated = conn.execute( """ UPDATE runs SET chargen_nr = %s @@ -1265,6 +1863,91 @@ def spool_usage_stats(conn, spool_id, days=28): } +def find_resume_predecessor(conn, host, filename, job_id, start_dt, watcher_resume=False): + """Lauf derselben Datei, der endet wo der neue History-Job beginnt. + + Ohne Watcher-Signal nur enge Lücke (Anycubic setzt end_time beim Fortsetzen). + Mit Watcher-Signal auch der gemerkte active_run_id. + """ + host = (host or "").strip() + filename = filename or "" + if not host or not filename or not start_dt or not job_id: + return None + if watcher_resume: + active_id = printer_active_run_id(conn, host) + if active_id: + row = conn.execute( + """ + SELECT * FROM runs + WHERE id = %s + AND printer_host = %s + AND filename = %s + AND moonraker_job_id <> %s + """, + (active_id, host, filename, str(job_id)), + ).fetchone() + if row: + return row + # Enge Lücke: Anycubic schließt den alten Job erst beim Resume (~0,04 s). + return conn.execute( + """ + SELECT * FROM runs + WHERE printer_host = %s + AND filename = %s + AND moonraker_job_id <> %s + AND moonraker_job_id NOT LIKE 'live:%%' + AND state IN ('cancelled', 'interrupted', 'error') + AND ended_at IS NOT NULL + AND ended_at >= %s - interval '5 seconds' + AND ended_at <= %s + interval '30 seconds' + ORDER BY ended_at DESC + LIMIT 1 + """, + (host, filename, str(job_id), start_dt, start_dt), + ).fetchone() + + +def history_job_already_folded(conn, host, filename, job_id, end_dt): + """True, wenn ein längerer Lauf derselben Datei diesen Job schon enthält.""" + host = (host or "").strip() + filename = filename or "" + if not host or not filename or not end_dt or not job_id: + return False + row = conn.execute( + """ + SELECT id FROM runs + WHERE printer_host = %s + AND filename = %s + AND moonraker_job_id <> %s + AND started_at IS NOT NULL + AND started_at <= %s + AND ended_at IS NOT NULL + AND ended_at > %s + interval '2 minutes' + LIMIT 1 + """, + (host, filename, str(job_id), end_dt, end_dt), + ).fetchone() + return bool(row) + + +def history_job_retired(conn, host, job_id): + """True, wenn die Job-ID an einem Lauf hängt, der inzwischen eine neuere Job-ID trägt.""" + host = (host or "").strip() + job_id = str(job_id or "") + if not host or not job_id: + return False + row = conn.execute( + """ + SELECT r.moonraker_job_id + FROM run_jobs j + JOIN runs r ON r.id = j.run_id + WHERE j.printer_host = %s AND j.job_id = %s + """, + (host, job_id), + ).fetchone() + return bool(row) and str(row["moonraker_job_id"] or "") != job_id + + def find_open_history_run(conn, host, filename=""): """Offener Moonraker-History-Job (nicht live:) für Host/Datei.""" host = (host or "").strip() @@ -1447,9 +2130,45 @@ def complete_open_run(conn, run_id, state="completed"): ).fetchone() +def find_adoptable_live_run(conn, host, filename=""): + """Offener Live-Lauf, sonst frisch geschlossener (interrupted/completed) derselben Datei.""" + live = find_live_run(conn, host, filename) + if live: + return live + host = (host or "").strip() + if not host: + return None + if filename: + return conn.execute( + """ + SELECT * FROM runs + WHERE printer_host = %s + AND filename = %s + AND moonraker_job_id LIKE 'live:%%' + AND state IN ('interrupted', 'completed', 'cancelled', 'error') + AND COALESCE(ended_at, started_at) > now() - interval '2 hours' + ORDER BY COALESCE(ended_at, started_at) DESC + LIMIT 1 + """, + (host, filename), + ).fetchone() + return conn.execute( + """ + SELECT * FROM runs + WHERE printer_host = %s + AND moonraker_job_id LIKE 'live:%%' + AND state IN ('interrupted', 'completed', 'cancelled', 'error') + AND COALESCE(ended_at, started_at) > now() - interval '2 hours' + ORDER BY COALESCE(ended_at, started_at) DESC + LIMIT 1 + """, + (host,), + ).fetchone() + + def adopt_live_run(conn, host, filename, real_run_id): """Proben/Segmente vom Live-Lauf auf den History-Lauf umhängen.""" - live = find_live_run(conn, host, filename) + live = find_adoptable_live_run(conn, host, filename) if not live or live["id"] == real_run_id: return real = get_run_row(conn, real_run_id) @@ -1463,9 +2182,13 @@ def adopt_live_run(conn, host, filename, real_run_id): real_state = (real.get("state") or "") if real_state in ("completed", "cancelled", "error", "interrupted"): if live_start and real.get("started_at") and live_start > real["started_at"]: - # Live begann nach dem History-Start und History ist schon durch — anderer Lauf. if not real_end or live_start > real_end: return + # Live-Abzug zurücknehmen, bevor die Zeile verschwindet (Fertig vor History). + from grotti import jobs as _jobs + + if live.get("deducted"): + _jobs.undo_deduction(conn, live) live_id = live["id"] live_chargen = (live.get("chargen_nr") or "").strip() for table in ("run_samples", "run_segments", "run_spools"): @@ -1491,7 +2214,7 @@ def adopt_live_run(conn, host, filename, real_run_id): """, (real_run_id, live_id), ) - if live_chargen and real_state in ("completed", "cancelled", "error", "interrupted"): + if live_chargen: conn.execute("UPDATE runs SET chargen_nr = NULL WHERE id = %s", (live_id,)) conn.execute( """ @@ -1500,11 +2223,6 @@ def adopt_live_run(conn, host, filename, real_run_id): """, (live_chargen, real_run_id), ) - else: - # Live-Charge war verfrüht — nicht übernehmen - conn.execute("UPDATE runs SET chargen_nr = NULL WHERE id = %s", (live_id,)) - # Etiketten nur mit derselben Charge. Sonst bleiben sie am Live-Lauf und - # fallen beim Löschen weg, statt eine fremde Nummer zu blockieren. real_after = get_run_row(conn, real_run_id) real_chargen = ((real_after or {}).get("chargen_nr") or "").strip() if live_chargen and real_chargen == live_chargen: @@ -1524,6 +2242,14 @@ def adopt_live_run(conn, host, filename, real_run_id): (real_run_id, live_id), ) conn.execute("DELETE FROM runs WHERE id = %s", (live_id,)) + try: + conn.execute( + "UPDATE print_queue SET run_id = %s WHERE run_id = %s", + (real_run_id, live_id), + ) + except Exception: + pass + set_printer_active_run(conn, host, real_run_id) def update_run_progress(conn, run_id, fields): @@ -2473,6 +3199,7 @@ def print_queue_out(row, file_name=None): "order_item_id": row.get("order_item_id"), "retry_count": int(row.get("retry_count") or 0), "wait_reason": row.get("wait_reason") or "", + "run_id": row.get("run_id"), "created_at": iso(row["created_at"]), "updated_at": iso(row["updated_at"]), } @@ -2543,13 +3270,33 @@ def enqueue_print( return print_queue_out(row, file_name) -def list_print_queue(conn, limit=40): +def list_print_queue(conn, limit=40, offset=0, host="", status=""): + where = [] + values = [] + host = (host or "").strip() + status = (status or "").strip() + if host: + where.append("q.host = %s") + values.append(host) + if status: + where.append("q.status = %s") + values.append(status) + clause = (" WHERE " + " AND ".join(where)) if where else "" + total_row = conn.execute( + "SELECT COUNT(*)::int AS n FROM print_queue q" + clause, + values, + ).fetchone() + total = int(total_row["n"] or 0) if total_row else 0 + limit = max(1, min(200, int(limit or 40))) + offset = max(0, int(offset or 0)) + values_page = list(values) + [limit, offset] rows = conn.execute( """ SELECT q.*, f.name AS filename, p.name AS printer_name FROM print_queue q LEFT JOIN files f ON f.id = q.file_id LEFT JOIN printers p ON p.host = q.host AND q.host <> '' + %s ORDER BY CASE q.status WHEN 'running' THEN 0 @@ -2561,11 +3308,16 @@ def list_print_queue(conn, limit=40): CASE WHEN q.status IN ('waiting', 'running', 'error') THEN q.id ELSE 0 END ASC, q.updated_at DESC NULLS LAST, q.id DESC - LIMIT %s - """, - (int(limit),), + LIMIT %%s OFFSET %%s + """ % clause, + values_page, ).fetchall() - return [print_queue_out(row, row.get("filename")) for row in rows] + return { + "items": [print_queue_out(row, row.get("filename")) for row in rows], + "total": total, + "limit": limit, + "offset": offset, + } def queue_by_host(conn, printers): @@ -2650,6 +3402,7 @@ def update_print_queue(conn, item_id, fields): "override_machine": "override_machine", "retry_count": "retry_count", "wait_reason": "wait_reason", + "run_id": "run_id", } sets = [] values = [] @@ -2843,22 +3596,17 @@ def _match_spools_for_need(need, have, used_ids=None): by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))] if by_profile: return _prefer_color(by_profile, need.get("color")) - # Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht - # (Sunlu PLA ↔ Eryone PLA Lite; Slice-Hex #26A69A blockiert nicht „Grau“). - if not _profile_has_variant(profile): - kind = _profile_base_type(profile) or (need.get("material_type") or "") - if kind: - typed = [ - h for h in _by_type(kind) - if not _profile_has_variant(h.get("material_name") or "") - ] - return _prefer_color(typed, need.get("color")) + # Typ reicht: PLA = PLA. Marke und Variante (Meta/Matte/Silk) halten nicht an. + # Liegt die genannte Sorte, gewinnt sie oben; sonst jede Spule desselben Typs. + kind = _profile_base_type(profile) or (need.get("material_type") or "") + if kind: + return _prefer_color(_by_type(kind), need.get("color")) return [] return _prefer_color(_by_type(need.get("material_type") or ""), need.get("color")) def material_fit_score(conn, host, file_id): - """Höher = besser. -1 = warten (falsche Sorte mit Profil). + """Höher = besser. -1 = warten (anderer Typ, z. B. PETG statt PLA). Zu wenig Rest blockiert nicht; genug Rest gibt Zusatzpunkte. """ diff --git a/grotti/version.py b/grotti/version.py index e76cd41..d119336 100644 --- a/grotti/version.py +++ b/grotti/version.py @@ -1,3 +1,3 @@ """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" -__version__ = "3.5.2" +__version__ = "3.6.0" diff --git a/grotti/watcher.py b/grotti/watcher.py index 299feae..a73fe30 100644 --- a/grotti/watcher.py +++ b/grotti/watcher.py @@ -16,6 +16,21 @@ SAMPLE_EVERY_S = 30 SAMPLE_HEAT_S = 10 +def contact_gap_keeps_run(prev_filename, next_filename, next_state): + """Nach einem Kontaktverlust bleibt der Lauf, solange kein anderer Auftrag läuft. + + Ein Timeout ist kein Druckende. Ohne Dateiname gibt es keinen Beweis + für einen Wechsel, der offene Lauf bleibt. + """ + if next_state not in ("printing", "paused"): + return False + prev_filename = prev_filename or "" + next_filename = next_filename or "" + if not prev_filename or not next_filename: + return True + return prev_filename == next_filename + + class StateWatcher: def __init__(self, database_url, cfg, interval=10): self.database_url = database_url @@ -23,6 +38,7 @@ class StateWatcher: self.interval = max(5, int(interval)) self._prev = {} self._offline_hits = {} + self._cancel_hits = {} self.latest = [] self._lock = threading.Lock() self._updated_at = 0.0 @@ -95,40 +111,35 @@ class StateWatcher: if offline: hits = self._offline_hits.get(host, 0) + 1 self._offline_hits[host] = hits - if hits >= 2 and not prev_offline: + # Ein Aussetzer ist noch kein Offline. Erst der zweite Poll. + if hits < 2: + return + if not prev_offline: self._emit(conn, host, name, "offline", "%s ist offline." % name, filename) - # Nach Crash/Shutdown (503/gklib) bleibt sonst „druckt“ im Protokoll. - if hits >= 2: - dangling = store.find_live_run(conn, host) - if dangling: - closed = store.complete_open_run( - conn, dangling["id"], state="interrupted" - ) - if closed: - label = dangling.get("filename") or filename or "offline" - self._emit( - conn, - host, - name, - "abbruch", - "%s: Druck unterbrochen (Drucker offline)." % name, - label, - closed["id"], - ) - try: - from grotti import jobs - - jobs.apply_deduction(conn, closed, self.cfg["energy"]) - except Exception as exc: - log.warning("Watcher Abzug offline %s: %s", host, exc) - self._prev[host] = {"state": "offline", "offline": True, "filename": filename} + # Kontakt weg ist kein Druckende. Der letzte echte Zustand bleibt + # gemerkt, damit fertig/Abbruch/Fehler beim nächsten Poll greifen. + remembered = prev_state if prev_state and prev_state != "offline" else "" + self._prev[host] = { + "state": remembered, + "offline": True, + "heating": bool(prev.get("heating")), + "filename": prev.get("filename") or filename or "", + "spool_warn": bool(prev.get("spool_warn")) if remembered == "printing" else False, + "run_id": prev.get("run_id"), + } return self._offline_hits[host] = 0 + file_changed = False + if prev_offline and state in ("printing", "paused"): + file_changed = self._release_replaced_run(conn, host, name, filename) starting = ( - bool(prev_state) - and prev_state not in ("printing", "paused") - and state == "printing" + ( + bool(prev_state) + and prev_state not in ("printing", "paused") + and state == "printing" + ) + or (file_changed and state == "printing") ) if starting: # Aufheiz-Proben behalten: Live-Lauf gleicher Datei nicht verwerfen @@ -154,9 +165,21 @@ class StateWatcher: force = starting or (heating and not prev.get("heating")) run = self._track_print(conn, host, name, filename, live, state, force_sample=force) run_id = run["id"] if run else None + if run_id: + store.set_printer_active_run(conn, host, run_id) + + if state not in ("cancelled", "standby"): + self._cancel_hits[host] = 0 if prev_offline: self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename, run_id) + if file_changed and state == "printing": + self._emit( + conn, host, name, "start", + "%s druckt %s" % (name, filename or "einen Auftrag"), + filename, run_id, + ) + self._ensure_start_segment(conn, host, filename, live, run_id) elif starting: self._emit( conn, host, name, "start", @@ -177,9 +200,16 @@ class StateWatcher: self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id) elif prev_state == "paused" and state == "printing": self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id) + try: + jobs.sync_history(conn, self.cfg, host=host, watcher_resume=True) + except Exception as exc: + log.warning("Watcher Abgleich Resume %s: %s", host, exc) + # Nach Fold: aktiven History-Lauf weiter tracken. + open_run = store.find_active_run(conn, host, filename) + if open_run: + run_id = open_run["id"] + store.set_printer_active_run(conn, host, run_id) elif prev_state == "printing" and state == "complete": - # Live-Übergang: immer belegen (ensure_bed_after_print ohne Endzeit - # würde nach Quittierung eines älteren Drucks stehen bleiben). store.set_bed_needs_clear(conn, host, True, filename or "fertig") self._emit( conn, @@ -191,53 +221,72 @@ class StateWatcher: run_id or (prev.get("run_id")), ) try: - from grotti import jobs - - jobs.sync_history(conn, self.cfg) + jobs.sync_history(conn, self.cfg, host=host) except Exception as exc: log.warning("Watcher Abgleich %s: %s", host, exc) - # History oft ohne Endzeit (interrupted) — Live trotzdem schließen. + # Live schließen ohne Abzug — History bucht; Fallback nach Frist. dangling = store.find_live_run(conn, host) if dangling: - closed = store.complete_open_run(conn, dangling["id"], state="completed") - if closed: - try: - from grotti import jobs - - jobs.apply_deduction(conn, closed, self.cfg["energy"]) - except Exception as exc: - log.warning("Watcher Abzug %s: %s", host, exc) + store.complete_open_run(conn, dangling["id"], state="completed") + store.set_printer_active_run(conn, host, None) elif state in ("cancelled", "standby"): - # Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart - # (prev wird „offline“, der Übergang paused→standby geht sonst verloren). dangling = store.find_live_run(conn, host) from_active = prev_state in ("printing", "paused") recover = bool(dangling) and (prev_offline or not prev_state) - if from_active or recover: + hits = self._cancel_hits.get(host, 0) + 1 + self._cancel_hits[host] = hits + # Anycubic flackert kurz cancelled/standby während Pause/Resume — + # erst nach 3 Polls aufgeben. Nach Offline-Lücke sofort. + need_hits = 1 if recover else 3 + if (from_active or recover) and hits >= need_hits: file_label = filename or (dangling or {}).get("filename") or state - store.set_bed_needs_clear(conn, host, True, file_label) - store.abandon_live_runs(conn, host) - self._emit( - conn, - host, - name, - "abbruch", - "%s: Druck abgebrochen (%s)" % ( - name, file_label if file_label != state else "ohne Datei", - ), - file_label if file_label != state else "", - run_id or prev.get("run_id") or (dangling or {}).get("id"), - ) try: - from grotti import jobs - - jobs.sync_history(conn, self.cfg) + jobs.sync_history(conn, self.cfg, host=host) except Exception as exc: log.warning("Watcher Abgleich %s: %s", host, exc) + # Nach Sync evtl. schon Fortsetzung — dann nicht aufgeben. + still = store.find_live_run(conn, host) or store.find_open_history_run( + conn, host, filename or (dangling or {}).get("filename") or "" + ) + if still and (still.get("state") or "") in ("in_progress", "printing", "paused"): + self._cancel_hits[host] = 0 + run_id = still["id"] + store.set_printer_active_run(conn, host, run_id) + else: + store.set_bed_needs_clear(conn, host, True, file_label) + store.abandon_live_runs(conn, host) + store.set_printer_active_run(conn, host, None) + self._emit( + conn, + host, + name, + "abbruch", + "%s: Druck abgebrochen (%s)" % ( + name, file_label if file_label != state else "ohne Datei", + ), + file_label if file_label != state else "", + run_id or prev.get("run_id") or (dangling or {}).get("id"), + ) + # prev-Zustand behalten, bis die Hits reichen. + if hits < 3 and from_active: + self._prev[host] = { + "state": prev_state, + "offline": False, + "heating": heating, + "filename": prev.get("filename") or filename, + "spool_warn": bool(prev.get("spool_warn")), + "run_id": run_id or prev.get("run_id"), + } + return elif state == "error" and prev_state != "error": if prev_state == "printing" or prev_state == "paused": store.set_bed_needs_clear(conn, host, True, filename or "Fehler") + try: + jobs.sync_history(conn, self.cfg, host=host) + except Exception as exc: + log.warning("Watcher Abgleich %s: %s", host, exc) store.abandon_live_runs(conn, host) + store.set_printer_active_run(conn, host, None) msg = (live.get("message") or "").strip() if not msg or msg.lower() in ("error", "fehler"): msg = "Ursache unbekannt — in Mainsail prüfen" @@ -251,18 +300,10 @@ class StateWatcher: filename, run_id or prev.get("run_id"), ) - try: - from grotti import jobs - - jobs.sync_history(conn, self.cfg) - except Exception as exc: - log.warning("Watcher Abgleich %s: %s", host, exc) elif state == "error": if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0: self._cool_heaters(host) elif state == "complete" and (not prev_state or prev_offline): - # Erster Blick nach Neustart: nur mit Endzeit des letzten Laufs, - # sonst wird bereits Quittiertes wieder „Platte belegt“. ended = conn.execute( """ SELECT ended_at FROM runs @@ -280,6 +321,10 @@ class StateWatcher: ended_at=ended["ended_at"] if ended else None, message=filename or "fertig", ) + try: + jobs.sync_history(conn, self.cfg, host=host) + except Exception as exc: + log.warning("Watcher Abgleich %s: %s", host, exc) spool_warn = bool(prev.get("spool_warn")) if state == "printing" and not spool_warn: @@ -294,6 +339,40 @@ class StateWatcher: "run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") or heating else None), } + def _release_replaced_run(self, conn, host, name, filename): + """Anderer Auftrag nach der Lücke: den offenen Lauf loslassen. + + History zuerst, damit Proben am echten Job hängen. Bleibt der alte + Live-Lauf, wird er beendet ohne Abzug — der History-Abgleich bucht + das Filament, sobald der Job da ist. + """ + existing = store.find_live_run(conn, host) + if not existing: + return False + if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"): + return False + try: + jobs.sync_history(conn, self.cfg) + except Exception as exc: + log.warning("Watcher Abgleich nach Offline %s: %s", host, exc) + existing = store.find_live_run(conn, host) + if not existing: + return True + if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"): + return False + label = existing.get("filename") or "ohne Datei" + store.abandon_live_runs(conn, host) + self._emit( + conn, + host, + name, + "abbruch", + "%s: Druck unterbrochen (%s)" % (name, label), + label, + existing["id"], + ) + return True + def _should_sample(self, conn, host, live, state): """Proben bei Druck/Pause und beim Aufheizen mit Bezug zu einem Auftrag.""" if state in ("printing", "paused"): diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index 89b2ae4..f851c24 100644 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -37,6 +37,12 @@ rsync -a --delete \ 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" +mkdir -p "$STAGE/usr/lib/grotti/scripts" +cp "$ROOT/scripts/pfade_umziehen.py" "$STAGE/usr/lib/grotti/scripts/pfade_umziehen.py" +# Symlink-Pfad für die Anleitung +mkdir -p "$STAGE/opt/grotti" +ln -sfn /usr/lib/grotti/scripts "$STAGE/opt/grotti/scripts" +ln -sfn /usr/lib/grotti/venv "$STAGE/opt/grotti/venv" 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" diff --git a/requirements.txt b/requirements.txt index e941eb7..4b93454 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,5 @@ psycopg-pool==3.3.3 paho-mqtt==2.1.0 cryptography==50.0.2 markdown==3.11.1 -mcp>=1.2 +mcp==2.0.0 waitress==3.0.2 diff --git a/scripts/laeufe_reparieren.py b/scripts/laeufe_reparieren.py new file mode 100644 index 0000000..7affbe7 --- /dev/null +++ b/scripts/laeufe_reparieren.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Alt-Paare und verwaiste Live-Läufe finden. Standard: Probelauf. + + python scripts/laeufe_reparieren.py + python scripts/laeufe_reparieren.py --apply +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from grotti import jobs, store # noqa: E402 +from grotti.config import load # noqa: E402 +from grotti.db import connect, prepare # noqa: E402 + + +def find_pairs(conn): + return conn.execute( + """ + SELECT a.id AS pred_id, b.id AS succ_id, + a.printer_host, a.filename, + a.state AS pred_state, b.state AS succ_state, + a.duration_s AS pred_dur, b.duration_s AS succ_dur, + a.ended_at, b.started_at, + a.deducted AS pred_deducted, b.deducted AS succ_deducted, + a.chargen_nr AS pred_chargen, b.chargen_nr AS succ_chargen + FROM runs a + JOIN runs b + ON a.printer_host = b.printer_host + AND a.filename = b.filename + AND a.id <> b.id + AND b.started_at BETWEEN a.ended_at - interval '30 seconds' + AND a.ended_at + interval '5 seconds' + WHERE a.state IN ('cancelled', 'interrupted', 'error') + AND a.ended_at IS NOT NULL + AND a.moonraker_job_id NOT LIKE 'live:%%' + AND b.moonraker_job_id NOT LIKE 'live:%%' + AND COALESCE(b.duration_s, 0) > 60 + AND COALESCE(b.duration_s, 0) >= COALESCE(a.duration_s, 0) - 30 + ORDER BY a.ended_at DESC + """ + ).fetchall() + + +def find_orphan_live(conn): + return conn.execute( + """ + SELECT id, printer_host, filename, state, started_at, ended_at, duration_s, chargen_nr + FROM runs + WHERE moonraker_job_id LIKE 'live:%%' + AND state IN ('interrupted', 'completed', 'cancelled') + ORDER BY COALESCE(ended_at, started_at) DESC + LIMIT 100 + """ + ).fetchall() + + +def main(): + parser = argparse.ArgumentParser(description="Läufe nach Filament-Resume reparieren") + parser.add_argument("--apply", action="store_true", help="Wirklich zusammenlegen") + args = parser.parse_args() + cfg = load() + prepare(cfg["database_url"]) + with connect(cfg["database_url"]) as conn: + pairs = find_pairs(conn) + orphans = find_orphan_live(conn) + print("=== Verdachtspaare (Beweis: Dauer fortgesetzt, Lücke ≤ 5 s) ===") + for row in pairs: + print(json.dumps({k: str(v) for k, v in dict(row).items()}, ensure_ascii=False)) + print("%d Paare" % len(pairs)) + print("=== Verwaiste live:-Läufe ===") + for row in orphans: + print(json.dumps({k: str(v) for k, v in dict(row).items()}, ensure_ascii=False)) + print("%d orphan" % len(orphans)) + if not args.apply: + print("Probelauf. Mit --apply werden Paare verbunden.") + conn.rollback() + return + merged = 0 + for row in pairs: + try: + jobs.manual_merge_runs( + conn, row["pred_id"], row["succ_id"], confirm=True + ) + merged += 1 + print("verbunden", row["pred_id"], "+", row["succ_id"]) + except Exception as exc: + print("Fehler", row["pred_id"], row["succ_id"], exc) + conn.rollback() + # Orphans nur an History derselben Datei hängen (kein Host-Fallback). + # adopt_live_run nimmt geschlossene live: nur ≤ 2 h — ältere bleiben liegen. + cleaned = 0 + skipped = 0 + for row in orphans: + fname = (row.get("filename") or "").strip() + if not fname: + skipped += 1 + continue + hist = conn.execute( + """ + SELECT * FROM runs + WHERE printer_host = %s + AND filename = %s + AND moonraker_job_id NOT LIKE 'live:%%' + AND state IN ('in_progress', 'printing', 'paused') + ORDER BY id DESC + LIMIT 1 + """, + (row["printer_host"], fname), + ).fetchone() + if not hist: + skipped += 1 + continue + store.adopt_live_run(conn, row["printer_host"], fname, hist["id"]) + still = conn.execute( + "SELECT 1 FROM runs WHERE id = %s", (row["id"],) + ).fetchone() + if still: + skipped += 1 + else: + cleaned += 1 + conn.commit() + print( + "verbunden=%d orphan_adopted=%d orphan_skipped=%d" + % (merged, cleaned, skipped) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/pfade_umziehen.py b/scripts/pfade_umziehen.py new file mode 100644 index 0000000..0df156f --- /dev/null +++ b/scripts/pfade_umziehen.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Absolute Windows-Pfade in files.stored_path und projects.folder auf Linux umschreiben. + +Beispiel (Dry-Run): + python scripts/pfade_umziehen.py \\ + --database-url postgresql:///grotti?host=/var/run/postgresql \\ + --from "D:/Projekte/3DP/Slice" --to /var/lib/grotti/slice + +Ohne --apply nur Anzeige. Mit --apply werden die Zeilen geschrieben. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def normalize_slash(path: str) -> str: + return (path or "").replace("\\", "/").rstrip("/") + + +def rewrite(value: str, old: str, new: str) -> str | None: + if not value: + return None + cur = value.replace("\\", "/") + old_n = normalize_slash(old) + new_n = normalize_slash(new) + # case-insensitive für Windows-Laufwerke + if cur.lower().startswith(old_n.lower()): + rest = cur[len(old_n) :] + if rest and not rest.startswith("/"): + rest = "/" + rest + return new_n + rest + return None + + +def main() -> int: + parser = argparse.ArgumentParser(description="Slice-Pfade Windows → Linux umschreiben") + parser.add_argument("--database-url", required=True) + parser.add_argument("--from", dest="old", required=True, help="Alter Prefix (Windows)") + parser.add_argument("--to", dest="new", required=True, help="Neuer Prefix (Linux)") + parser.add_argument("--apply", action="store_true", help="Wirklich schreiben") + args = parser.parse_args() + + import psycopg + from psycopg.rows import dict_row + + old = args.old + new = args.new + changed_files = 0 + changed_projects = 0 + + with psycopg.connect(args.database_url, row_factory=dict_row) as conn: + files = conn.execute("SELECT id, stored_path FROM files ORDER BY id").fetchall() + for row in files: + nxt = rewrite(row["stored_path"] or "", old, new) + if nxt is None or nxt == row["stored_path"]: + continue + changed_files += 1 + print("file %s: %s → %s" % (row["id"], row["stored_path"], nxt)) + if args.apply: + conn.execute("UPDATE files SET stored_path = %s WHERE id = %s", (nxt, row["id"])) + + projects = conn.execute("SELECT id, folder FROM projects ORDER BY id").fetchall() + for row in projects: + folder = row["folder"] or "" + # folder ist relativ — nur absolute Reste bereinigen + nxt = rewrite(folder, old, new) + if nxt is None: + continue + # relativ zum neuen Slice-Root speichern, falls möglich + new_n = normalize_slash(new) + if nxt.lower().startswith(new_n.lower()): + rel = nxt[len(new_n) :].lstrip("/") + nxt = rel + changed_projects += 1 + print("project %s: %s → %s" % (row["id"], folder, nxt)) + if args.apply: + conn.execute("UPDATE projects SET folder = %s WHERE id = %s", (nxt, row["id"])) + + if args.apply: + conn.commit() + print("Geschrieben: %s Dateien, %s Projekte." % (changed_files, changed_projects)) + else: + print( + "Dry-Run: %s Dateien, %s Projekte würden sich ändern. Mit --apply ausführen." + % (changed_files, changed_projects) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_history_shell.py b/tests/test_history_shell.py new file mode 100644 index 0000000..bce098a --- /dev/null +++ b/tests/test_history_shell.py @@ -0,0 +1,107 @@ +"""Leere History-Hülle, Resume-Beweis und Chargen-Tag.""" + +import unittest +from datetime import date, datetime, timedelta, timezone +from unittest.mock import MagicMock + +from grotti.jobs import ( + history_in_order, + history_shell_is_empty, + is_filament_resume, +) +from grotti.store import chargen_day, history_job_retired + + +class HistoryShellTests(unittest.TestCase): + def test_pause_shell_is_empty(self): + self.assertTrue(history_shell_is_empty({"duration_s": 0, "ended_at": None})) + + def test_recorded_cancel_is_not_a_shell(self): + self.assertFalse(history_shell_is_empty({ + "duration_s": 653, + "ended_at": datetime(2026, 10, 18, 13, 18, tzinfo=timezone.utc), + })) + + def test_missing_row_is_not_a_shell(self): + self.assertFalse(history_shell_is_empty(None)) + + +class FilamentResumeProofTests(unittest.TestCase): + def test_duration_continued_with_tiny_gap(self): + # 2026-10-25 Kobra 3 A1: 000040 → 000041, 0,04 s Lücke, gleiche print_duration + end = datetime(2026, 10, 25, 5, 48, 27, 569483, tzinfo=timezone.utc) + start = end + timedelta(milliseconds=40) + self.assertTrue(is_filament_resume( + end, start, prev_duration_s=11711, new_duration_s=11711 + )) + + def test_fresh_restart_after_cancel_is_not_resume(self): + # Lauf 27941: 8 s abgebrochen, 45 s später Neustart mit Dauer ~0 + end = datetime(2026, 10, 24, 11, 46, 8, tzinfo=timezone.utc) + start = end + timedelta(seconds=45) + self.assertFalse(is_filament_resume( + end, start, prev_duration_s=8, new_duration_s=5 + )) + + def test_two_arg_call_without_proof_is_false(self): + end = datetime(2026, 10, 21, 19, 32, 20, tzinfo=timezone.utc) + start = end + timedelta(milliseconds=40) + self.assertFalse(is_filament_resume(end, start)) + + def test_watcher_signal_allows_longer_gap(self): + end = datetime(2026, 10, 25, 1, 0, 0, tzinfo=timezone.utc) + start = end + timedelta(hours=4) + self.assertTrue(is_filament_resume( + end, start, prev_duration_s=1000, new_duration_s=1000, watcher_resume=True + )) + + def test_duration_reset_is_not_resume(self): + end = datetime(2026, 10, 25, 5, 48, 27, tzinfo=timezone.utc) + start = end + timedelta(milliseconds=40) + self.assertFalse(is_filament_resume( + end, start, prev_duration_s=11711, new_duration_s=12 + )) + + +class HistoryOrderTests(unittest.TestCase): + def test_cancelled_part_before_resume(self): + items = [ + {"job_id": "000041", "start_time": 1792907307.607}, + {"job_id": "000040", "start_time": 1792879062.392}, + ] + self.assertEqual([j["job_id"] for j in history_in_order(items)], ["000040", "000041"]) + + def test_missing_start_first(self): + items = [{"job_id": "b", "start_time": 5}, {"job_id": "a"}] + self.assertEqual([j["job_id"] for j in history_in_order(items)], ["a", "b"]) + + +class HistoryJobRetiredTests(unittest.TestCase): + def _conn(self, row): + conn = MagicMock() + conn.execute.return_value.fetchone.return_value = row + return conn + + def test_folded_predecessor_is_retired(self): + conn = self._conn({"moonraker_job_id": "000041"}) + self.assertTrue(history_job_retired(conn, "h", "000040")) + + def test_own_job_is_not_retired(self): + conn = self._conn({"moonraker_job_id": "000041"}) + self.assertFalse(history_job_retired(conn, "h", "000041")) + + def test_unknown_job_is_not_retired(self): + self.assertFalse(history_job_retired(self._conn(None), "h", "000042")) + + +class ChargenDayTests(unittest.TestCase): + def test_old_print_keeps_its_day(self): + started = datetime(2026, 10, 18, 13, 4, 16, tzinfo=timezone.utc) + self.assertEqual(chargen_day(started, today=date(2026, 10, 24)), date(2026, 10, 18)) + + def test_without_start_uses_today(self): + self.assertEqual(chargen_day(None, today=date(2026, 10, 24)), date(2026, 10, 24)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_jobs_spool.py b/tests/test_jobs_spool.py index 19ad6ea..c2d6648 100644 --- a/tests/test_jobs_spool.py +++ b/tests/test_jobs_spool.py @@ -69,6 +69,16 @@ class ChooseSpoolTests(unittest.TestCase): ): self.assertEqual(jobs.resolve_tracking_spool(conn, "h", 4), 14) + def test_ace_target_skips_spool_created_after_end(self): + end = datetime(2026, 10, 15, 22, 0, tzinfo=timezone.utc) + future = {"id": 18, "created_at": end + timedelta(days=6)} + conn = MagicMock() + with patch("grotti.jobs.store.spool_at_slot", return_value=future): + self.assertEqual( + jobs._targets_from_ace(conn, "h", [{"ams_index": 3}], ended_at=end), + [], + ) + def test_spool_valid_for_end_naive(self): end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc) spool = {"created_at": datetime(2026, 9, 1, 12, 0)} diff --git a/tests/test_material_routing.py b/tests/test_material_routing.py index a9e6bfa..c5f62ae 100644 --- a/tests/test_material_routing.py +++ b/tests/test_material_routing.py @@ -53,13 +53,29 @@ class MatchSpoolsTests(unittest.TestCase): }, ] - def test_base_sunlu_pla_matches_plain_pla_not_meta(self): + def test_base_sunlu_pla_matches_any_pla(self): need = {"profile": "Sunlu PLA", "material_type": "pla", "color": "", "paint_index": 0} matched = store._match_spools_for_need(need, self.have) ids = {m["spool_id"] for m in matched} - self.assertEqual(ids, {3}) # Eryone PLA; Meta/Matte nicht + self.assertEqual(ids, {1, 2, 3}) - def test_pla_lite_hex_matches_plain_pla(self): + def test_base_pla_matches_matte_alone(self): + need = {"profile": "Sunlu PLA", "material_type": "pla", "color": "#f2a900", "paint_index": 0} + have = [self.have[1]] + matched = store._match_spools_for_need(need, have) + self.assertEqual([m["spool_id"] for m in matched], [2]) + + def test_meta_falls_back_to_any_pla(self): + need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0} + have = [self.have[1]] + matched = store._match_spools_for_need(need, have) + self.assertEqual([m["spool_id"] for m in matched], [2]) + + def test_petg_does_not_match_pla(self): + need = {"profile": "Sunlu PETG", "material_type": "petg", "color": "", "paint_index": 0} + self.assertEqual(store._match_spools_for_need(need, self.have), []) + + def test_pla_lite_hex_matches_any_pla(self): # Orca: Profil „Eryone PLA Lite“, Farbe #26A69A — Lager: Eryone/Sunlu PLA „Grau“ need = { "profile": "Eryone PLA Lite", @@ -86,7 +102,7 @@ class MatchSpoolsTests(unittest.TestCase): }, ] matched = store._match_spools_for_need(need, have) - self.assertEqual({m["spool_id"] for m in matched}, {15}) + self.assertEqual({m["spool_id"] for m in matched}, {15, 1}) def test_picks_meta_not_matte(self): need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0} diff --git a/tests/test_print_queue.py b/tests/test_print_queue.py index 07d140c..4680826 100644 --- a/tests/test_print_queue.py +++ b/tests/test_print_queue.py @@ -8,7 +8,15 @@ from unittest import mock ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) -from grotti.print_queue import PRINT_START_TIMEOUT_S, PrintQueueDispatcher, _cool_wait_text +from datetime import datetime, timedelta, timezone + +from grotti.jobs import is_filament_resume +from grotti.print_queue import ( + PRINT_START_TIMEOUT_S, + TERMINAL_CONFIRM_HITS, + PrintQueueDispatcher, + _cool_wait_text, +) class CoolTextTests(unittest.TestCase): @@ -20,9 +28,26 @@ class CoolTextTests(unittest.TestCase): self.assertNotIn(" · ", text) +class FilamentResumeTests(unittest.TestCase): + def test_same_moment_with_duration_is_resume(self): + end = datetime(2026, 10, 21, 19, 32, 20, tzinfo=timezone.utc) + start = end + timedelta(milliseconds=40) + self.assertTrue(is_filament_resume( + end, start, prev_duration_s=500, new_duration_s=520 + )) + + def test_later_reprint_is_not_resume(self): + end = datetime(2026, 10, 21, 19, 32, 20, tzinfo=timezone.utc) + start = end + timedelta(minutes=10) + self.assertFalse(is_filament_resume( + end, start, prev_duration_s=500, new_duration_s=10 + )) + + class DispatcherHelpers(unittest.TestCase): def test_timeout_constant(self): self.assertGreaterEqual(PRINT_START_TIMEOUT_S, 300) + self.assertGreaterEqual(TERMINAL_CONFIRM_HITS, 2) def test_worker_busy_empty(self): d = PrintQueueDispatcher("postgresql://x", {}, watcher=None) @@ -34,6 +59,21 @@ class DispatcherHelpers(unittest.TestCase): d.cfg = {"auto_queue": False} self.assertFalse(d._auto_enabled()) + def test_no_retry_after_abort(self): + d = PrintQueueDispatcher("postgresql://x", {}, watcher=None) + self.assertFalse(d._retryable_message("Druck abgebrochen.")) + self.assertTrue(d._retryable_message("Start-Timeout")) + + def test_advance_simple_ignores_single_cancelled(self): + d = PrintQueueDispatcher("postgresql://x", {}, watcher=None) + item = {"id": 1, "phase": "print_active", "filename": "a.gcode"} + printer = {"host": "h", "name": "P"} + live = {"state": "cancelled", "print_active": False} + with mock.patch("grotti.print_queue.store.update_print_queue") as upd: + d._advance_simple(mock.Mock(), item, printer, live) + upd.assert_not_called() + self.assertEqual(d._term_hits.get("1:end"), 1) + class ResolveSpoolTests(unittest.TestCase): def test_material_match(self): diff --git a/tests/test_run_identity.py b/tests/test_run_identity.py new file mode 100644 index 0000000..4960a46 --- /dev/null +++ b/tests/test_run_identity.py @@ -0,0 +1,79 @@ +"""Szenarien für einen physischen Druck = ein Lauf (ohne DB, mit Mocks).""" + +import os +import unittest +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +from grotti import jobs + + +class SyncOneJobShellTests(unittest.TestCase): + def test_empty_shell_skipped(self): + conn = MagicMock() + printer = {"host": "h", "name": "P"} + job = { + "job_id": "000041", + "filename": "a.gcode", + "status": "in_progress", + "start_time": 1000, + "end_time": None, + "print_duration": 0, + "total_duration": 0, + "metadata": {}, + } + self.assertEqual(jobs._sync_one_job(conn, {"energy": {}}, printer, job), 0) + conn.execute.assert_not_called() + + +class FoldProofIntegrationLike(unittest.TestCase): + def test_fold_requires_duration_proof(self): + end = datetime(2026, 10, 25, 5, 48, 27, tzinfo=timezone.utc) + start = end + timedelta(milliseconds=40) + pred = { + "id": 1, + "ended_at": end, + "duration_s": 11711, + "moonraker_job_id": "000040", + "verdict": "", + } + conn = MagicMock() + conn.execute.return_value.fetchone.return_value = None + with patch("grotti.jobs.store.find_resume_predecessor", return_value=pred), \ + patch("grotti.jobs.store.remember_run_job"), \ + patch("grotti.jobs.store.move_run_jobs"), \ + patch("grotti.jobs.store.set_printer_active_run"), \ + patch("grotti.jobs.undo_deduction"): + # Frischer Neustart (Dauer 12) — kein Fold + self.assertIsNone(jobs.fold_filament_resume( + conn, "h", "a.gcode", "000041", start, print_duration_s=12 + )) + # Fortgesetzte Dauer — Fold + self.assertEqual( + jobs.fold_filament_resume( + conn, "h", "a.gcode", "000041", start, print_duration_s=11711 + ), + 1, + ) + + +@unittest.skipUnless(os.environ.get("GROTTI_TEST_DB"), "GROTTI_TEST_DB nicht gesetzt") +class LiveDbScenarios(unittest.TestCase): + """Gegen echte Test-DB: GROTTI_TEST_DB=postgresql://…""" + + def setUp(self): + import psycopg + from psycopg.rows import dict_row + + self.conn = psycopg.connect(os.environ["GROTTI_TEST_DB"], row_factory=dict_row) + + def tearDown(self): + self.conn.rollback() + self.conn.close() + + def test_connection(self): + self.assertTrue(self.conn.execute("SELECT 1").fetchone()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_watcher_offline.py b/tests/test_watcher_offline.py new file mode 100644 index 0000000..60f6439 --- /dev/null +++ b/tests/test_watcher_offline.py @@ -0,0 +1,142 @@ +"""Kontaktverlust beendet einen Druck nicht.""" + +import unittest +from unittest import mock + +from grotti.watcher import StateWatcher, contact_gap_keeps_run + +HOST = "10.10.1.88" +FILE = "boden_PETG_0.2_2h15m.gcode" + + +class ContactGapTests(unittest.TestCase): + def test_same_file_stays(self): + self.assertTrue(contact_gap_keeps_run(FILE, FILE, "printing")) + self.assertTrue(contact_gap_keeps_run(FILE, FILE, "paused")) + + def test_other_file_does_not_stay(self): + self.assertFalse(contact_gap_keeps_run(FILE, "andere.gcode", "printing")) + + def test_terminal_state_does_not_stay(self): + self.assertFalse(contact_gap_keeps_run(FILE, FILE, "standby")) + self.assertFalse(contact_gap_keeps_run(FILE, FILE, "complete")) + self.assertFalse(contact_gap_keeps_run(FILE, FILE, "error")) + + def test_missing_name_is_not_a_change(self): + self.assertTrue(contact_gap_keeps_run("", FILE, "printing")) + self.assertTrue(contact_gap_keeps_run(FILE, "", "paused")) + + +class WatcherOfflineTests(unittest.TestCase): + def setUp(self): + self.watcher = StateWatcher("postgresql://x", {}) + self.watcher._prev[HOST] = { + "state": "printing", + "offline": False, + "filename": FILE, + "run_id": 42, + } + + def test_two_misses_keep_the_run(self): + live = { + "host": HOST, + "name": "Kobra S1", + "state": "offline", + "ok": False, + "filename": "", + } + with mock.patch.object(self.watcher, "_emit") as emit, \ + mock.patch("grotti.watcher.store.complete_open_run") as close, \ + mock.patch("grotti.watcher.jobs.apply_deduction") as deduct: + self.watcher._one(mock.Mock(), live) + self.watcher._one(mock.Mock(), live) + close.assert_not_called() + deduct.assert_not_called() + remembered = self.watcher._prev[HOST] + self.assertEqual(remembered["state"], "printing") + self.assertTrue(remembered["offline"]) + self.assertEqual(remembered["run_id"], 42) + self.assertEqual(remembered["filename"], FILE) + self.assertEqual([call.args[3] for call in emit.call_args_list], ["offline"]) + + def test_same_print_after_gap_is_the_same_run(self): + self.watcher._prev[HOST]["offline"] = True + live = { + "host": HOST, + "name": "Kobra S1", + "state": "printing", + "ok": True, + "filename": FILE, + "weight": 0, + } + run = {"id": 42, "filename": FILE} + with mock.patch.object(self.watcher, "_emit") as emit, \ + mock.patch.object(self.watcher, "_track_print", return_value=run) as track, \ + mock.patch.object(self.watcher, "_check_spool", return_value=False), \ + mock.patch("grotti.watcher.store.find_live_run", return_value=run), \ + mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \ + mock.patch("grotti.watcher.store.complete_open_run") as close, \ + mock.patch("grotti.watcher.jobs.sync_history") as sync, \ + mock.patch("grotti.watcher.jobs.apply_deduction") as deduct: + self.watcher._one(mock.Mock(), live) + close.assert_not_called() + deduct.assert_not_called() + abandon.assert_not_called() + sync.assert_not_called() + track.assert_called_once() + self.assertEqual([call.args[3] for call in emit.call_args_list], ["offline"]) + self.assertIn("wieder erreichbar", emit.call_args_list[0].args[4]) + self.assertEqual(self.watcher._prev[HOST]["state"], "printing") + self.assertFalse(self.watcher._prev[HOST]["offline"]) + + def test_standby_after_gap_closes(self): + self.watcher._prev[HOST]["offline"] = True + live = { + "host": HOST, + "name": "Kobra S1", + "state": "standby", + "ok": True, + "filename": "", + } + run = {"id": 42, "filename": FILE} + with mock.patch.object(self.watcher, "_emit") as emit, \ + mock.patch.object(self.watcher, "_track_print", return_value=None), \ + mock.patch.object(self.watcher, "_check_spool", return_value=False), \ + mock.patch("grotti.watcher.store.find_live_run", return_value=run), \ + mock.patch("grotti.watcher.store.running_print_on_host", return_value=None), \ + mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \ + mock.patch("grotti.watcher.store.set_bed_needs_clear"), \ + mock.patch("grotti.watcher.jobs.sync_history") as sync: + self.watcher._one(mock.Mock(), live) + abandon.assert_called_once() + sync.assert_called_once() + self.assertIn("abbruch", [call.args[3] for call in emit.call_args_list]) + + def test_other_file_after_gap_releases_the_old_run(self): + self.watcher._prev[HOST]["offline"] = True + live = { + "host": HOST, + "name": "Kobra S1", + "state": "printing", + "ok": True, + "filename": "andere.gcode", + "weight": 0, + } + old = {"id": 42, "filename": FILE} + new = {"id": 99, "filename": "andere.gcode"} + with mock.patch.object(self.watcher, "_emit") as emit, \ + mock.patch.object(self.watcher, "_track_print", return_value=new), \ + mock.patch.object(self.watcher, "_ensure_start_segment"), \ + mock.patch.object(self.watcher, "_check_spool", return_value=False), \ + mock.patch("grotti.watcher.store.find_live_run", side_effect=[old, old, None]), \ + mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \ + mock.patch("grotti.watcher.jobs.sync_history") as sync: + self.watcher._one(mock.Mock(), live) + sync.assert_called_once() + self.assertGreaterEqual(abandon.call_count, 1) + kinds = [call.args[3] for call in emit.call_args_list] + self.assertEqual(kinds, ["abbruch", "offline", "start"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/app.py b/web/app.py index 8d55c48..9979fa9 100644 --- a/web/app.py +++ b/web/app.py @@ -48,6 +48,8 @@ TOKEN_QUERY_PATHS = ( "/api/v1/kamera", "/api/v1/kamera/stream", "/api/v1/vorschau", + "/api/v1/dateien/", + "/api/v1/projekte/", ) STEER_ACTIONS = { "nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error", @@ -99,6 +101,7 @@ def create_app(cfg): "/setup", "/api/v1/auth/status", "/api/v1/auth/login", + "/api/v1/auth/token", "/api/v1/auth/setup", "/api/v1/version", } @@ -454,6 +457,39 @@ def create_app(cfg): store.add_event(conn(), "", "", "auth", "Login %s" % row["username"]) return jsonify({"user": grotti_auth.public_user(row), "csrf": session["csrf"]}) + @app.post("/api/v1/auth/token") + def auth_token(): + """Passwort → API-Token (für Android/App). Keine Session, Rate-Limit wie Login.""" + if store.user_count(conn()) == 0: + raise GrottiError("Zuerst Admin anlegen.", "setup", 503) + key = request.remote_addr or "unknown" + if not grotti_auth.login_rate_ok(key): + raise GrottiError("Zu viele Versuche. Bitte warten.", "rate", 429) + data = body() + username = (data.get("username") or "").strip() + password = data.get("password") or "" + device = (data.get("device_name") or data.get("name") or "App").strip()[:80] or "App" + row = store.get_user_by_name(conn(), username) + if not row or not row.get("active") or not grotti_auth.verify_password(row["password_hash"], password): + grotti_auth.login_rate_hit(key) + raise GrottiError("Benutzer oder Passwort falsch.", "auth", 401) + store.touch_user_login(conn(), row["id"]) + raw = grotti_auth.new_api_token() + role = grotti_auth.clamp_token_role(row["role"], data.get("role")) + token_row = store.create_api_token( + conn(), + row["id"], + device, + grotti_auth.hash_token(raw), + grotti_auth.token_prefix(raw), + role, + ) + store.add_event(conn(), "", "", "auth", "Token „%s“ für %s" % (device, row["username"])) + item = grotti_auth.public_token(token_row) + item["token"] = raw + item["user"] = grotti_auth.public_user(row) + return jsonify(item), 201 + @app.post("/api/v1/auth/logout") def auth_logout(): session.clear() @@ -811,7 +847,22 @@ def create_app(cfg): @app.get("/api/v1/version") def version_info(): - return jsonify({"version": __version__}) + return jsonify({ + "name": "Grotti", + "version": __version__, + "api": 1, + "features": [ + "auth_token", + "sse", + "queue", + "projects", + "orders", + "spools", + "camera", + "confirm", + "ntfy", + ], + }) @app.get("/api/v1/einstellungen") def settings(): @@ -969,6 +1020,8 @@ def create_app(cfg): @app.post("/api/v1/drucker//") def printer_action(host, action): require_host(host) + aliases = {"resume": "fortsetzen", "cancel": "abbrechen"} + action = aliases.get(action, action) if action not in ("pause", "fortsetzen", "abbrechen"): raise GrottiError("Unbekannter Befehl.", "befehl") return jsonify(jobs.control(conn(), host, action)) @@ -989,7 +1042,7 @@ def create_app(cfg): host = require_host(request.args.get("host", "")) image = moonraker.camera_image(host) if not image: - abort(404) + raise GrottiError("Kein Kamerabild.", "kamera", 404) return image[1], 200, {"Content-Type": image[0], "Cache-Control": "no-store"} @app.get("/api/v1/kamera/stream") @@ -1048,7 +1101,8 @@ def create_app(cfg): @app.get("/api/v1/projekte") def project_list(): - return jsonify(store.list_projects(conn())) + status = (request.args.get("status") or "").strip() or None + return jsonify(store.list_projects(conn(), status=status)) @app.post("/api/v1/projekte") def project_create(): @@ -1083,11 +1137,83 @@ def create_app(cfg): @app.get("/api/v1/projekte/") def project_get(project_id): - row = store.get_project(conn(), project_id) + row = store.get_project(conn(), project_id, watch_dir=cfg.get("watch_dir")) if not row: raise GrottiError("Das Projekt fehlt.", "projekt", 404) + # Status „druckt“, wenn ein Lauf aktiv ist (nur Anzeige — kein Auto-Write ohne Wunsch) return jsonify(row) + @app.get("/api/v1/projekte//laeufe") + def project_runs(project_id): + if not store.get_project(conn(), project_id): + raise GrottiError("Das Projekt fehlt.", "projekt", 404) + try: + limit = max(1, min(200, int(request.args.get("limit") or 40))) + except (TypeError, ValueError): + limit = 40 + try: + offset = max(0, int(request.args.get("offset") or 0)) + except (TypeError, ValueError): + offset = 0 + data = store.list_project_runs(conn(), project_id, limit=limit, offset=offset) + resp = jsonify(data["items"]) + resp.headers["X-Total-Count"] = str(data["total"]) + return resp + + @app.post("/api/v1/projekte//einreihen") + def project_einreihen(project_id): + data = body() + if not bool(data.get("confirm")): + raise GrottiError("Bestätigung fehlt.", "bestaetigung") + project = store.get_project(conn(), project_id, watch_dir=cfg.get("watch_dir")) + if not project: + raise GrottiError("Das Projekt fehlt.", "projekt", 404) + wanted = data.get("file_ids") + if isinstance(wanted, list) and wanted: + try: + wanted_ids = {int(x) for x in wanted} + except (TypeError, ValueError) as exc: + raise GrottiError("Ungültige Datei-IDs.", "datei") from exc + files = [f for f in (project.get("files") or []) if f["id"] in wanted_ids] + else: + files = list(project.get("files") or []) + if not files: + raise GrottiError("Keine Druckdateien zum Einreihen.", "datei") + try: + menge = max(1, min(50, int(data.get("menge") or 1))) + except (TypeError, ValueError): + menge = 1 + try: + priority = int(data.get("priority", 100)) + except (TypeError, ValueError): + priority = 100 + host = optional_host(data.get("host") or "") + if host: + require_host(host) + bed = bool(data.get("bed_leveling")) + override = bool(data.get("override_machine")) + created = [] + for file_row in files: + family = (data.get("family") or file_row.get("printer_family") or "").strip() + for _ in range(menge): + created.append( + store.enqueue_print( + conn(), + file_row["id"], + host, + family if not host else "", + priority, + optional_id(data.get("spool_id")), + data.get("ace_map") if isinstance(data.get("ace_map"), list) else [], + bed, + override, + ) + ) + # Status auf „druckt“ heben, wenn noch Idee/bereit + if project.get("status") in ("idee", "bereit"): + store.update_project(conn(), project_id, {"status": "druckt"}) + return jsonify({"jobs": created, "count": len(created)}), 201 + @app.patch("/api/v1/projekte/") def project_patch(project_id): data = body() @@ -1182,7 +1308,39 @@ def create_app(cfg): @app.get("/api/v1/dateien") def file_list(): library.sync_watch(conn(), cfg, force=False) - return jsonify(store.list_files(conn(), watch_dir=cfg.get("watch_dir"))) + family = (request.args.get("family") or "").strip() + material = (request.args.get("material") or "").strip() + q = (request.args.get("q") or "").strip() + host = optional_host(request.args.get("host") or "") + project_id = optional_id(request.args.get("project_id") or request.args.get("project")) + limit_raw = request.args.get("limit") + offset_raw = request.args.get("offset") + limit = None + offset = 0 + if limit_raw is not None and str(limit_raw).strip() != "": + try: + limit = max(1, min(500, int(limit_raw))) + except (TypeError, ValueError): + limit = 40 + if offset_raw is not None: + try: + offset = max(0, int(offset_raw)) + except (TypeError, ValueError): + offset = 0 + data = store.list_files( + conn(), + watch_dir=cfg.get("watch_dir"), + family=family, + material=material, + project_id=project_id, + q=q, + host=host, + limit=limit, + offset=offset, + ) + resp = jsonify(data["items"]) + resp.headers["X-Total-Count"] = str(data["total"]) + return resp @app.post("/api/v1/dateien//meta") def file_meta_refresh(file_id): @@ -1267,10 +1425,10 @@ def create_app(cfg): def file_preview(file_id): row = store.get_file_row(conn(), file_id) if not row: - abort(404) + raise GrottiError("Vorschau fehlt.", "vorschau", 404) png = library.preview_png(row, conn=conn(), data_dir=cfg["data_dir"]) if not png: - abort(404) + raise GrottiError("Vorschau fehlt.", "vorschau", 404) return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"} @app.get("/api/v1/dateien//pfad") @@ -1411,9 +1569,12 @@ def create_app(cfg): file_id = int(request.args.get("file_id") or 0) except (TypeError, ValueError): file_id = 0 - if not host or not file_id: - raise GrottiError("Host und Datei nötig.", "senden") - row = store.latest_send_for_file(conn(), host, file_id) + if not host: + raise GrottiError("Host nötig.", "senden") + if file_id: + row = store.latest_send_for_file(conn(), host, file_id) + else: + row = store.latest_send_for_host(conn(), host) if not row: return jsonify(None) return jsonify(row) @@ -1508,7 +1669,18 @@ def create_app(cfg): limit = max(1, min(200, int(request.args.get("limit") or 40))) except (TypeError, ValueError): limit = 40 - return jsonify(store.list_print_queue(conn(), limit=limit)) + try: + offset = max(0, int(request.args.get("offset") or 0)) + except (TypeError, ValueError): + offset = 0 + host = optional_host(request.args.get("host") or "") + status = (request.args.get("status") or "").strip() + data = store.list_print_queue( + conn(), limit=limit, offset=offset, host=host, status=status + ) + resp = jsonify(data["items"]) + resp.headers["X-Total-Count"] = str(data["total"]) + return resp @app.get("/api/v1/laeuft") def running_now(): @@ -1590,7 +1762,10 @@ def create_app(cfg): for item in running: rem = float(item.get("remain") or 0) item["free_at"] = jetzt_mod._iso_remain(rem) if rem > 0 else None - waiting = [j for j in store.list_print_queue(conn(), limit=80) if j.get("status") == "waiting"] + waiting = [ + j for j in store.list_print_queue(conn(), limit=80)["items"] + if j.get("status") == "waiting" + ] return jsonify({ "running": running, "waiting_count": len(waiting), @@ -1694,15 +1869,14 @@ def create_app(cfg): except ValueError as exc: raise GrottiError(str(exc), "urteil") from exc run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n) - # Live-Lauf schließen: Moonraker-History bleibt oft ohne Endzeit hängen, - # sonst bleibt der Druck im Protokoll ewig „in_progress“ trotz Urteil. + # Live-Lauf schließen ohne Abzug — History bucht; Fallback nach Frist. if run_row and (run_row.get("state") or "") in ("in_progress", "printing", "paused"): closed = store.complete_open_run(c, run_row["id"], state="completed") if closed: try: - jobs.apply_deduction(c, closed, cfg["energy"]) + jobs.sync_history(c, cfg, host=host) except Exception as exc: - log.warning("Abräumen Abzug %s: %s", host, exc) + log.warning("Abräumen Abgleich %s: %s", host, exc) row = store.get_run_row(c, closed["id"]) or closed run_json = store.run_out(row) reprint = None @@ -2196,7 +2370,28 @@ def create_app(cfg): @app.get("/api/v1/spulen") def spool_list(): - return jsonify(store.list_spools(conn())) + aktiv_raw = (request.args.get("aktiv") or "").strip().lower() + aktiv = None + if aktiv_raw in ("1", "true", "yes"): + aktiv = True + elif aktiv_raw in ("0", "false", "no"): + aktiv = False + limit_raw = request.args.get("limit") + limit = None + offset = 0 + if limit_raw is not None and str(limit_raw).strip() != "": + try: + limit = max(1, min(500, int(limit_raw))) + except (TypeError, ValueError): + limit = 100 + try: + offset = max(0, int(request.args.get("offset") or 0)) + except (TypeError, ValueError): + offset = 0 + data = store.list_spools(conn(), aktiv=aktiv, limit=limit, offset=offset) + resp = jsonify(data["items"]) + resp.headers["X-Total-Count"] = str(data["total"]) + return resp @app.get("/api/v1/spulen/by-code/") def spool_by_code(code): @@ -2495,8 +2690,18 @@ def create_app(cfg): run = store.run_out(row) if run.get("chargen_nr") and run.get("objects"): store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=1) + if run.get("order_item_id") and not run.get("order_id"): + oi = conn().execute( + "SELECT order_id FROM order_items WHERE id = %s", + (run["order_item_id"],), + ).fetchone() + if oi: + run["order_id"] = oi["order_id"] file_row = store.get_file_row(conn(), run["file_id"]) if run.get("file_id") else None - project = store.get_project(conn(), run["project_id"]) if run.get("project_id") else None + project = ( + store.get_project(conn(), run["project_id"], watch_dir=cfg.get("watch_dir")) + if run.get("project_id") else None + ) samples = store.list_run_samples(conn(), run_id) from grotti.cost import energy_estimate @@ -2512,7 +2717,7 @@ def create_app(cfg): "samples": samples, "labels": store.list_run_labels(conn(), run_id), "events": store.list_run_events(conn(), run_id), - "file": store.file_out(file_row) if file_row else None, + "file": store.file_out(file_row, watch_dir=cfg.get("watch_dir")) if file_row else None, "project": project, "energy": energy, }) @@ -2522,6 +2727,18 @@ def create_app(cfg): library.sync_watch(conn(), cfg, force=False) return jsonify(jobs.sync_history(conn(), cfg)) + @app.post("/api/v1/laeufe//verbinden") + def run_merge(run_id): + """Zwei Protokoll-Zeilen desselben physischen Drucks verbinden (admin).""" + require_role("admin") + data = body() + other = int(data.get("other_id") or data.get("mit") or 0) + if not other: + raise GrottiError("other_id fehlt.", "lauf") + return jsonify(jobs.manual_merge_runs( + conn(), run_id, other, confirm=bool(data.get("confirm")) + )) + @app.patch("/api/v1/laeufe/") def run_patch(run_id): data = body() diff --git a/web/static/grotti.css b/web/static/grotti.css index 98cfd7e..09c8c03 100644 --- a/web/static/grotti.css +++ b/web/static/grotti.css @@ -396,6 +396,9 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; } min-width: 0; } .live-page { + margin-left: auto; + margin-right: auto; + max-width: 36rem; } .jetzt-eta { @@ -548,7 +551,16 @@ button.warn:hover { background: var(--st-error-soft); filter: none; } background: rgba(255, 255, 255, 0.025); overflow: hidden; } -.queue-box.is-empty { display: none; } +.live-pop .queue-box.is-empty { display: none; } +.live-page .queue-box.is-empty { display: block; } +.queue-empty { + list-style: none; + margin: 0; + padding: 0.85rem 0.7rem 1rem; + color: var(--muted); + font-size: var(--t-sm); + text-align: center; +} .queue-box[open] { border-color: var(--line); } .run-stack { flex: none; @@ -613,6 +625,8 @@ h2.queue-title { font-size: var(--t-xs); } } .icon-btn:hover { color: var(--ink); background: rgba(255, 255, 255, 0.06); filter: none; } .icon-btn.danger:hover { color: var(--st-error); background: var(--st-error-soft); } +.ghost.tiny-btn.danger { color: var(--st-error); } +.ghost.tiny-btn.danger:hover { background: var(--st-error-soft); } .queue-list { list-style: none; margin: 0; padding: 0 0.4rem 0.45rem; display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.35rem; } .queue-list > li { min-width: 0; } .queue-empty { @@ -895,6 +909,22 @@ h2 { font-size: 0.95rem; margin: 0 0 0.65rem; letter-spacing: -0.02em; font-weig flex: 0 1 auto; width: auto; } +.stat-more { + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.35rem 0.65rem 0.55rem; + background: rgba(255, 255, 255, 0.02); +} +.stat-more > summary { + list-style: none; + cursor: pointer; + color: var(--muted); + font-size: var(--t-sm); + font-weight: 650; + padding: 0.25rem 0; +} +.stat-more > summary::-webkit-details-marker { display: none; } +.stat-more[open] > summary { margin-bottom: 0.45rem; } .stat-filters { display: flex; flex-wrap: wrap; @@ -1328,6 +1358,15 @@ article.printer { } main > .card { margin-bottom: 0.85rem; } .split { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); gap: 1rem; align-items: start; } +.edit-split { grid-template-columns: minmax(0, 1.15fr) minmax(18rem, 0.95fr); } +.edit-split.is-idle { grid-template-columns: 1fr; } +.edit-panel { position: sticky; top: calc(var(--topbar-h) + 0.75rem); } +.cell-path { + max-width: 14rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.55rem 0.75rem; } .field { display: grid; gap: 0.28rem; min-width: 0; } .field > label, .field-label { @@ -2291,6 +2330,45 @@ body.desk-on main { .strip.stopped .strip-actions button.ghost { color: #fecaca; border-color: rgba(248, 113, 113, 0.4); } .waiting-note { font-size: var(--t-xs); color: var(--muted); display: flex; align-items: center; gap: 0.35rem; } .waiting-note b { color: var(--accent); } +.desk-next { + margin-top: 0.65rem; + padding: 0.55rem 0.6rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.02); + display: grid; + gap: 0.45rem; +} +.desk-next-head { + display: flex; + align-items: center; + gap: 0.35rem; + font-weight: 650; + font-size: var(--t-sm); +} +.desk-next-row { + display: flex; + align-items: center; + gap: 0.35rem; +} +.desk-next-row .grow { + display: flex; + flex-direction: column; + gap: 0.05rem; + min-width: 0; +} +.desk-next-row .grow span, +.desk-next-row .grow b { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.desk-next-files { + display: grid; + gap: 0.3rem; + padding-top: 0.25rem; + border-top: 1px solid var(--line); +} .segmented { display: inline-grid; @@ -2461,7 +2539,7 @@ article.machine.flash { animation: flash 2s var(--ease); } margin: 0; padding: 0.35rem 0.5rem; } -@media (min-width: 1100px) { +@media (min-width: 1280px) { .lib-tools { flex-wrap: nowrap; } .lib-tools .search-wrap { max-width: 12rem; } } @@ -2638,11 +2716,49 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); } text-decoration: none; color: inherit; } -.proj-file { flex-direction: column; align-items: flex-start; gap: 0.15rem; } +.proj-file { flex-direction: column; align-items: flex-start; gap: 0.15rem; flex: 1 1 auto; min-width: 0; } .proj-file:hover, .proj-asset:hover { border-color: var(--line-strong); } .proj-asset a { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); } .proj-asset .tiny { flex: none; color: var(--muted); } +.proj-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr)); + gap: 0.45rem; + margin: 0.75rem 0 0.35rem; +} +.proj-stat { + padding: 0.45rem 0.55rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.02); + display: flex; + flex-direction: column; + gap: 0.1rem; +} +.proj-stat strong { font-size: var(--t-sm); } +.proj-file-row { + display: flex; + align-items: center; + gap: 0.45rem; +} +.proj-file-row .ghost { flex: none; } +.focus-flash { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-sm); + animation: focus-flash-fade 2.4s var(--ease) forwards; +} +@keyframes focus-flash-fade { + 0% { outline-color: var(--accent); } + 70% { outline-color: var(--accent); } + 100% { outline-color: transparent; } +} +a.lib-proj { + color: var(--accent); + text-decoration: none; +} +a.lib-proj:hover { text-decoration: underline; } .lib-card { position: relative; transition: box-shadow 0.2s var(--ease); } .lib-card.drop { box-shadow: 0 0 0 2px var(--accent), var(--shadow); } .drop-hint { margin: 0.6rem 0 0; font-size: var(--t-xs); color: var(--faint); text-align: center; } @@ -2790,11 +2906,17 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); } @media (max-width: 1080px) { .send-layout { grid-template-columns: 1fr; } .send-panel { position: static; } + .split, .edit-split, .mat-split { grid-template-columns: 1fr; } + .edit-panel, .mat-form { position: static; } .doc-layout { grid-template-columns: 1fr; } .doc-toc { position: static; display: flex; flex-wrap: wrap; gap: 0.3rem; padding-bottom: 0.9rem; border-bottom: 1px solid var(--line); } .doc-toc p { display: none; } .doc-toc a { border: 1px solid var(--line-strong); border-radius: 99px; padding: 0.22rem 0.65rem; font-size: var(--t-xs); } .doc-toc a.on { border-radius: 99px; border-color: rgba(249, 115, 22, 0.55); } + .proto-table th:nth-child(4), + .proto-table th:nth-child(5), + .proto-table td:nth-child(4), + .proto-table td:nth-child(5) { display: none; } } @media (max-width: 960px) { @@ -2832,7 +2954,11 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); } .send-panel, .doc-toc { top: 1rem; } .sidebar { border-right: 0; border-bottom: 1px solid var(--line); } .live-bar { display: none; } - .split, .grid-2 { grid-template-columns: 1fr; } + .grid-2 { grid-template-columns: 1fr; } + .proto-table th:nth-child(6), + .proto-table th:nth-child(8), + .proto-table td:nth-child(6), + .proto-table td.proto-spool { display: none; } .fleet { grid-template-columns: 1fr; } main { padding: 1rem 0.85rem 2rem; } .hero-main { font-size: 1.4rem; } @@ -3049,6 +3175,16 @@ ul.plain li:last-child { border-bottom: 0; } gap: 0.35rem; margin-bottom: 0.75rem; } +.mobile-more-sheet .nav-label { + margin: 0.45rem 0 0.1rem; + padding: 0 0.25rem; + font-size: var(--t-xs); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 700; +} +.mobile-more-sheet .nav-label:first-child { margin-top: 0; } .mobile-more-sheet a { display: block; padding: 0.65rem 0.75rem; @@ -3480,6 +3616,36 @@ td .cell-file { margin-top: 0.15rem; opacity: 0.85; } +.proto-table td.proto-file .warn-text { + color: var(--warn, #b45309); + margin-top: 0.2rem; +} +.proto-actions { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + justify-content: flex-end; +} +.proto-help { + margin: 0.35rem 0 0; + max-width: 36rem; +} +.proto-help summary { + cursor: pointer; + color: var(--muted); +} +.proto-help p { margin: 0.35rem 0 0; color: var(--muted); } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} .proto-table td.proto-spool { overflow: hidden; text-overflow: ellipsis; @@ -3493,6 +3659,13 @@ td.actions-cell { text-align: right; } td.actions-cell .icon-btn { vertical-align: middle; } +td.actions-cell .act-label { margin-left: 0.15rem; font-size: var(--t-xs); } +td.actions-cell .ghost.tiny-btn.with-label { + display: inline-flex; + align-items: center; + gap: 0.2rem; + vertical-align: middle; +} button.is-busy { position: relative; color: transparent !important; pointer-events: none; } button.is-busy::after { diff --git a/web/static/ui.js b/web/static/ui.js index 58840bd..61ff25a 100644 --- a/web/static/ui.js +++ b/web/static/ui.js @@ -374,6 +374,56 @@ function renderPager(el, state, onPage) { el.querySelector(".pager-next").onclick = () => onPage(s.page + 1); } +/** + * Einheitliche Querverlinkung zwischen Seiten. + * @param {"datei"|"projekt"|"spule"|"auftrag"|"lauf"|"drucker"} kind + * @param {string|number} id + * @param {string} [label] + * @returns {string} HTML-Anker oder escaped Text + */ +function linkTo(kind, id, label) { + const text = escapeHtml(label == null || label === "" ? String(id ?? "") : String(label)); + if (id == null || id === "") return text; + const enc = encodeURIComponent(String(id)); + let href = ""; + switch (kind) { + case "datei": + href = "/dateien?file=" + enc; + break; + case "projekt": + href = "/projekte#" + enc; + break; + case "spule": + href = "/material?spule=" + enc; + break; + case "auftrag": + href = "/auftraege#" + enc; + break; + case "lauf": + href = "/protokoll/" + enc; + break; + case "drucker": + href = "/#" + enc; + break; + default: + return text; + } + return '' + text + ""; +} + +/** Hash/Query-Fokus: Element hervorheben und scrollen. */ +function focusById(id, opts) { + if (!id) return null; + const el = document.getElementById(id) || document.querySelector('[data-id="' + CSS.escape(String(id)) + '"]'); + if (!el) return null; + el.classList.add("focus-flash"); + el.scrollIntoView({ behavior: (opts && opts.behavior) || "smooth", block: "center" }); + setTimeout(() => el.classList.remove("focus-flash"), 2400); + return el; +} + window.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE; window.pageSlice = pageSlice; window.renderPager = renderPager; +window.linkTo = linkTo; +window.focusById = focusById; diff --git a/web/templates/_live_panel.html b/web/templates/_live_panel.html index e950f9f..7a734db 100644 --- a/web/templates/_live_panel.html +++ b/web/templates/_live_panel.html @@ -14,5 +14,5 @@

diff --git a/web/templates/aktivitaet.html b/web/templates/aktivitaet.html index e14276c..7c5a2db 100644 --- a/web/templates/aktivitaet.html +++ b/web/templates/aktivitaet.html @@ -19,7 +19,14 @@
-
+
+ +

@@ -67,11 +74,13 @@ function renderFilters() { KINDS.map(([k, label]) => `` ).join(""); - document.getElementById("f-host").innerHTML = - `` + + const hostSel = document.getElementById("f-host"); + const cur = filter.host; + hostSel.innerHTML = '' + printers.map((p) => - `` + `` ).join(""); + if ([...hostSel.options].some((o) => o.value === cur)) hostSel.value = cur; } function sinceIso() { @@ -165,8 +174,6 @@ document.addEventListener("click", (e) => { if (!k) filter.kinds.clear(); else if (filter.kinds.has(k)) filter.kinds.delete(k); else filter.kinds.add(k); - } else if (e.target.closest("#f-host [data-host]")) { - filter.host = e.target.closest("[data-host]").dataset.host; } else { return; } @@ -175,6 +182,13 @@ document.addEventListener("click", (e) => { load(); }); +document.getElementById("f-host").addEventListener("change", () => { + filter.host = document.getElementById("f-host").value || ""; + syncUrl(); + renderFilters(); + load(); +}); + document.getElementById("tl-refresh").addEventListener("click", load); document.getElementById("tl-read").addEventListener("click", async () => { await api("/ereignisse/gelesen", { method: "POST", json: {} }); diff --git a/web/templates/auftraege.html b/web/templates/auftraege.html index b50cae7..77b84fc 100644 --- a/web/templates/auftraege.html +++ b/web/templates/auftraege.html @@ -7,9 +7,10 @@
+
-
+
@@ -32,9 +33,14 @@
-
-

Neuer Auftrag

-
+
{% endblock %} @@ -107,6 +131,43 @@ let orders = []; let customers = []; let files = []; let current = null; +/** idle | create | view | edit */ +let panelMode = "idle"; +let orderPage = 1; + +document.getElementById("order-close").innerHTML = icon("x"); + +function openPanel() { + document.getElementById("order-panel").hidden = false; + document.getElementById("order-layout").classList.remove("is-idle"); + document.getElementById("order-panel").scrollIntoView({ block: "nearest", behavior: "smooth" }); +} + +function closePanel() { + panelMode = "idle"; + current = null; + document.getElementById("order-panel").hidden = true; + document.getElementById("order-layout").classList.add("is-idle"); + document.getElementById("order-form").hidden = true; + document.getElementById("order-detail").hidden = true; + document.getElementById("order-edit-btn").hidden = true; + document.getElementById("order-cancel-edit").hidden = true; + document.getElementById("order-delete").hidden = true; + document.getElementById("lieferschein-link").hidden = true; + document.getElementById("item-edit-form").hidden = true; + document.getElementById("order-note").textContent = ""; + history.replaceState(null, "", location.pathname); + renderList(); +} + +function setFormValues(order) { + document.getElementById("order-id").value = order ? order.id : ""; + document.getElementById("order-title-in").value = order ? order.title : ""; + document.getElementById("order-status").value = order ? order.status : "idee"; + document.getElementById("order-due").value = order && order.due_at ? order.due_at.slice(0, 10) : ""; + document.getElementById("order-customer").value = order && order.customer_id ? order.customer_id : ""; + document.getElementById("order-notes").value = order ? (order.notes || "") : ""; +} function fillCustomers() { const sel = document.getElementById("order-customer"); @@ -146,27 +207,64 @@ function paintActuals(order) { el.textContent = bits.join(" · "); } -function fillOrder(order) { +function showCreate() { + panelMode = "create"; + current = null; + setFormValues(null); + document.getElementById("order-title").textContent = "Neuer Auftrag"; + document.getElementById("order-form").hidden = false; + document.getElementById("order-detail").hidden = true; + document.getElementById("order-edit-btn").hidden = true; + document.getElementById("order-cancel-edit").hidden = true; + document.getElementById("order-delete").hidden = true; + document.getElementById("lieferschein-link").hidden = true; + document.getElementById("order-note").textContent = ""; + document.getElementById("item-edit-form").hidden = true; + history.replaceState(null, "", location.pathname); + openPanel(); + renderList(); + document.getElementById("order-title-in").focus(); +} + +function showView(order) { + panelMode = "view"; current = order; - document.getElementById("order-id").value = order ? order.id : ""; - document.getElementById("order-title-in").value = order ? order.title : ""; - document.getElementById("order-status").value = order ? order.status : "idee"; - document.getElementById("order-due").value = order && order.due_at ? order.due_at.slice(0, 10) : ""; - document.getElementById("order-customer").value = order && order.customer_id ? order.customer_id : ""; - document.getElementById("order-notes").value = order ? order.notes : ""; - document.getElementById("order-title").textContent = order ? order.title : "Neuer Auftrag"; - document.getElementById("order-delete").hidden = !order; + setFormValues(order); + document.getElementById("order-title").textContent = order.title || "Auftrag"; + document.getElementById("order-form").hidden = true; + document.getElementById("order-detail").hidden = false; + document.getElementById("order-edit-btn").hidden = false; + document.getElementById("order-cancel-edit").hidden = true; + document.getElementById("order-delete").hidden = true; + document.getElementById("item-edit-form").hidden = true; const link = document.getElementById("lieferschein-link"); - if (order) { - link.href = "/auftraege/" + order.id + "/lieferschein"; - link.hidden = false; - } else { - link.hidden = true; - } - const prog = order && order.progress; + link.href = "/auftraege/" + order.id + "/lieferschein"; + link.hidden = false; + history.replaceState(null, "", "#" + order.id); + const prog = order.progress; document.getElementById("order-progress").textContent = prog ? "Fortschritt: " + prog.label : ""; - paintActuals(order || {}); - renderItems(order && order.items ? order.items : []); + paintActuals(order); + const notes = (order.notes || "").trim(); + document.getElementById("order-notes-view").textContent = notes ? "Notiz: " + notes : ""; + renderItems(order.items || []); + openPanel(); + renderList(); +} + +function showEdit(order) { + panelMode = "edit"; + current = order; + setFormValues(order); + document.getElementById("order-title").textContent = "Bearbeiten"; + document.getElementById("order-form").hidden = false; + document.getElementById("order-detail").hidden = true; + document.getElementById("order-edit-btn").hidden = true; + document.getElementById("order-cancel-edit").hidden = false; + document.getElementById("order-delete").hidden = false; + document.getElementById("item-edit-form").hidden = true; + openPanel(); + renderList(); + document.getElementById("order-title-in").focus(); } function renderItems(items) { @@ -179,7 +277,11 @@ function renderItems(items) { items.forEach((item) => { const tr = document.createElement("tr"); tr.innerHTML = ""; - tr.children[0].textContent = item.file_name || ("#" + (item.file_id || "—")); + if (item.file_id) { + tr.children[0].innerHTML = linkTo("datei", item.file_id, item.file_name || ("#" + item.file_id)); + } else { + tr.children[0].textContent = item.file_name || "—"; + } tr.children[1].textContent = item.qty; tr.children[2].textContent = item.done_ok + (item.done_bad ? " (−" + item.done_bad + ")" : ""); const tools = document.createElement("div"); @@ -194,7 +296,7 @@ function renderItems(items) { edit.type = "button"; edit.className = "ghost tiny-btn"; edit.textContent = "Ändern"; - edit.addEventListener("click", () => editItem(item)); + edit.addEventListener("click", () => beginEditItem(item)); const del = document.createElement("button"); del.type = "button"; del.className = "btn-danger tiny-btn"; @@ -204,7 +306,7 @@ function renderItems(items) { await api("/auftraege/positionen/" + item.id, { method: "DELETE" }); toast("Position gelöscht."); const full = await api("/auftraege/" + current.id); - fillOrder(full); + showView(full); }); }); tools.append(btn, edit, del); @@ -213,22 +315,12 @@ function renderItems(items) { }); } -async function editItem(item) { - const qty = prompt("Stückzahl", String(item.qty || 1)); - if (qty == null) return; - const price = prompt("Preis/Stk €", String(item.unit_price || 0)); - if (price == null) return; - try { - await api("/auftraege/positionen/" + item.id, { - method: "PATCH", - json: { qty: Number(qty) || 1, unit_price: Number(price) || 0 }, - }); - const full = await api("/auftraege/" + current.id); - fillOrder(full); - toast("Position gespeichert."); - } catch (error) { - showNote("order-note", error); - } +function beginEditItem(item) { + document.getElementById("item-edit-id").value = item.id; + document.getElementById("item-edit-qty").value = item.qty || 1; + document.getElementById("item-edit-price").value = item.unit_price || 0; + document.getElementById("item-edit-form").hidden = false; + document.getElementById("item-edit-qty").focus(); } async function einreihen(itemId) { @@ -240,42 +332,40 @@ async function einreihen(itemId) { }); toast("In Warteschlange."); const full = await api("/auftraege/" + current.id); - fillOrder(full); + showView(full); await loadList(); } catch (error) { showNote("order-note", error); } } -let orderPage = 1; - function renderList() { const body = document.getElementById("order-rows"); body.innerHTML = ""; const pager = document.getElementById("order-pager"); if (!orders.length) { - body.innerHTML = '

Noch kein Auftrag.

'; - document.getElementById("order-empty-new")?.addEventListener("click", () => fillOrder(null)); + body.innerHTML = '

Noch kein Auftrag.

'; + document.getElementById("order-empty-new")?.addEventListener("click", showCreate); renderPager(pager, null, () => {}); return; } - const selected = document.getElementById("order-id").value; + const selected = current ? String(current.id) : ""; const paged = pageSlice(orders, orderPage, DEFAULT_PAGE_SIZE); orderPage = paged.page; paged.items.forEach((order) => { const tr = document.createElement("tr"); tr.className = "pick" + (selected && String(order.id) === selected ? " on" : ""); - tr.innerHTML = ""; + tr.innerHTML = ""; tr.children[0].textContent = order.title; const st = order.status || ""; const chipState = st === "druckt" ? "printing" : (st === "fertig" || st === "geliefert" || st === "bezahlt") ? "done" : st === "bestaetigt" ? "waiting" : "idle"; tr.children[1].innerHTML = chipHtml(chipState, STATUS_LABEL[st] || st); tr.children[2].textContent = (order.progress && order.progress.label) || ""; + tr.children[2].title = (order.progress && order.progress.label) || ""; tr.addEventListener("click", async () => { try { const full = await api("/auftraege/" + order.id); - fillOrder(full); - renderList(); + showView(full); } catch (error) { showNote("order-note", error); } @@ -304,11 +394,28 @@ async function loadBase() { fillCustomers(); fillFiles(); await loadList(); - fillOrder(null); } document.getElementById("filter-status").addEventListener("change", () => loadList().catch((e) => showNote("order-note", e))); +document.getElementById("order-new-head").addEventListener("click", showCreate); +document.getElementById("order-close").addEventListener("click", closePanel); +document.getElementById("order-edit-btn").addEventListener("click", () => { + if (current) showEdit(current); +}); +document.getElementById("order-cancel-edit").addEventListener("click", async () => { + if (!current) { + closePanel(); + return; + } + try { + const full = await api("/auftraege/" + current.id); + showView(full); + } catch (error) { + showNote("order-note", error); + } +}); + document.getElementById("order-form").addEventListener("submit", async (event) => { event.preventDefault(); const id = document.getElementById("order-id").value; @@ -324,22 +431,22 @@ document.getElementById("order-form").addEventListener("submit", async (event) = ? await api("/auftraege/" + id, { method: "PATCH", json: payload }) : await api("/auftraege", { method: "POST", json: payload }); showNote("order-note", null); - document.getElementById("order-note").textContent = "Gespeichert."; + toast("Gespeichert.", "good"); await loadList(); - fillOrder(saved); + const full = await api("/auftraege/" + saved.id); + showView(full); } catch (error) { showNote("order-note", error); } }); -document.getElementById("order-new").addEventListener("click", () => fillOrder(null)); document.getElementById("order-delete").addEventListener("click", (event) => { const id = document.getElementById("order-id").value; if (!id) return; armButton(event.currentTarget, "Auftrag wirklich löschen?", async () => { await api("/auftraege/" + id, { method: "DELETE" }); toast("Gelöscht."); - fillOrder(null); + closePanel(); loadList(); }); }); @@ -359,12 +466,36 @@ document.getElementById("item-form").addEventListener("submit", async (event) => try { await api("/auftraege/" + current.id + "/positionen", { method: "POST", json: payload }); const full = await api("/auftraege/" + current.id); - fillOrder(full); + showView(full); } catch (error) { showNote("order-note", error); } }); +document.getElementById("item-edit-form").addEventListener("submit", async (event) => { + event.preventDefault(); + if (!current) return; + const itemId = document.getElementById("item-edit-id").value; + try { + await api("/auftraege/positionen/" + itemId, { + method: "PATCH", + json: { + qty: Number(document.getElementById("item-edit-qty").value) || 1, + unit_price: Number(document.getElementById("item-edit-price").value) || 0, + }, + }); + document.getElementById("item-edit-form").hidden = true; + const full = await api("/auftraege/" + current.id); + showView(full); + toast("Position gespeichert."); + } catch (error) { + showNote("order-note", error); + } +}); +document.getElementById("item-edit-cancel").addEventListener("click", () => { + document.getElementById("item-edit-form").hidden = true; +}); + document.getElementById("customer-form").addEventListener("submit", async (event) => { event.preventDefault(); const id = document.getElementById("cust-id").value; @@ -421,6 +552,16 @@ document.getElementById("cust-delete").addEventListener("click", (event) => { }); }); -loadBase().catch((error) => showNote("order-note", error)); +loadBase().then(async () => { + const hash = (location.hash || "").replace(/^#/, ""); + if (hash && /^\d+$/.test(hash)) { + try { + const full = await api("/auftraege/" + hash); + showView(full); + } catch (error) { + showNote("order-note", error); + } + } +}).catch((error) => showNote("order-note", error)); {% endblock %} diff --git a/web/templates/bericht.html b/web/templates/bericht.html index 30ca71e..7135384 100644 --- a/web/templates/bericht.html +++ b/web/templates/bericht.html @@ -110,9 +110,14 @@ function costBreakdownHtml(energy, filamentCost) { function render(data) { const run = data.run; const name = (run.filename || "").split("/").pop() || "ohne Datei"; - document.getElementById("bericht-sub").textContent = - (run.printer_name || run.printer_host) + " · " + name - + (run.chargen_nr ? " · " + run.chargen_nr : ""); + const subBits = [ + run.printer_host + ? linkTo("drucker", run.printer_host, run.printer_name || run.printer_host) + : escapeHtml(run.printer_name || ""), + run.file_id ? linkTo("datei", run.file_id, name) : escapeHtml(name), + run.chargen_nr ? escapeHtml(run.chargen_nr) : "", + ].filter(Boolean); + document.getElementById("bericht-sub").innerHTML = subBits.join(" · "); const planned = Number(run.weight_planned_g) || 0; const used = Number(run.weight_used_g) || 0; @@ -128,7 +133,7 @@ function render(data) { const spoolRows = (data.spools || []).map((s) => ` - ${escapeHtml(s.name)}${s.ace_slot ? " · ACE " + s.ace_slot : ""} + ${s.spool_id ? linkTo("spule", s.spool_id, s.name) : escapeHtml(s.name)}${s.ace_slot ? " · ACE " + s.ace_slot : ""} ${escapeHtml(s.material_type || "")} ${grams(s.weight_planned_g)} ${grams(s.weight_g)}${s.is_estimate ? ' Schätzwert' : ""} @@ -161,7 +166,8 @@ function render(data) {

Kosten ${euro(cost)} (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})${run.cost_per_part != null ? " · " + euro(run.cost_per_part) + "/Teil" : ""}

${run.pieces_ok || run.pieces_bad ? `

Stück ${run.pieces_ok || 0} gut${run.pieces_bad ? " · " + run.pieces_bad + " schlecht" : ""}

` : ""} ${run.message ? "

" + escapeHtml(run.message) + "

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

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

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

Projekt: " + linkTo("projekt", data.project.id, data.project.name) + "

" : ""} + ${run.order_id ? "

Auftrag: " + linkTo("auftrag", run.order_id, "#" + run.order_id) + "

" : ""}
${costBreakdownHtml(energy, run.filament_cost)} diff --git a/web/templates/dateien.html b/web/templates/dateien.html index e721846..55cb430 100644 --- a/web/templates/dateien.html +++ b/web/templates/dateien.html @@ -604,6 +604,20 @@ function folderLabel(folder) { return "Wurzel"; } +function folderMatches(fileFolder, selected) { + if (!selected) return true; + if (selected === "root") return !fileFolder; + if (!fileFolder) return false; + return fileFolder === selected || fileFolder.startsWith(selected + "/"); +} + +function releaseRootForProject() { + const project = document.getElementById("file-filter-project").value; + const folder = document.getElementById("file-filter-folder"); + if (!folder || !project || project === "none" || folder.value !== "root") return; + folder.value = ""; +} + function saveLibMemory() { try { localStorage.setItem(LIB_KEY, JSON.stringify({ @@ -650,8 +664,7 @@ function filteredFiles() { const types = ((file.filaments) || []).map((f) => f.type).filter(Boolean); if (!types.includes(material)) return false; } - if (folder === "root" && folderKey(file)) return false; - if (folder && folder !== "root" && folderKey(file) !== folder) return false; + if (!folderMatches(folderKey(file), folder)) return false; if (!q) return true; const hay = [ file.name || "", @@ -809,10 +822,13 @@ function alwaysMeta(file, project) { file.weight_g ? grams(file.weight_g) : "", layerHeightText(file), families[file.printer_family] || "", - project ? project.name : "", ]; if (!file.weight_g && !file.estimated_s) bits.push(sizeText(file.size_bytes)); - return bits.filter(Boolean).map((t) => `${escapeHtml(t)}`).join("") + swatchesHtml(file); + const spans = bits.filter(Boolean).map((t) => `${escapeHtml(t)}`); + if (project) { + spans.push(`${escapeHtml(project.name)}`); + } + return spans.join("") + swatchesHtml(file); } function detailMeta(file) { @@ -1154,6 +1170,12 @@ async function load() { fillProjectFilter(); fillMaterialFilter(); fillFolderFilter(); + const deepProject = new URLSearchParams(location.search).get("project"); + if (deepProject) { + const pf = document.getElementById("file-filter-project"); + if (pf && [...pf.options].some((o) => o.value === deepProject)) pf.value = deepProject; + } + releaseRootForProject(); renderLibrary(); renderTabs(); refreshFlow(); @@ -1689,6 +1711,7 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => { const node = document.getElementById(id); if (!node) return; const onChange = () => { + if (id === "file-filter-project") releaseRootForProject(); libPage = 1; saveLibMemory(); renderLibrary(); diff --git a/web/templates/einstellungen.html b/web/templates/einstellungen.html index feeeb14..02a0533 100644 --- a/web/templates/einstellungen.html +++ b/web/templates/einstellungen.html @@ -257,6 +257,10 @@

+ @@ -293,6 +297,7 @@ {% endblock %} {% block script %} + -{% endblock %} diff --git a/web/templates/layout.html b/web/templates/layout.html index 957dab3..27d6657 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -9,7 +9,7 @@ - + diff --git a/web/templates/projekte.html b/web/templates/projekte.html index ac52878..f6c7938 100644 --- a/web/templates/projekte.html +++ b/web/templates/projekte.html @@ -6,11 +6,16 @@

Arbeitsmappen mit Ordner unter dem Slice-Pfad — G-code und Konstruktionsdaten.

+
+ + + +
-
+
@@ -19,16 +24,21 @@
- +
NameStatusOrdnerDateien
NameStatusOrdnerDateienKosten
-
-

Neues Projekt

-
+
@@ -74,51 +107,117 @@ {% block script %} {% endblock %} diff --git a/web/templates/protokoll.html b/web/templates/protokoll.html index 8494d92..8752bbc 100644 --- a/web/templates/protokoll.html +++ b/web/templates/protokoll.html @@ -4,6 +4,10 @@

Protokoll

Abgeschlossene und laufende Drucke mit Dauer, Material und Kosten.

+
+ Filament-Pause / doppelte Zeilen +

Nach Runout kann der Drucker kurz zwei History-Zeilen erzeugen. „Läufe holen“ legt sie zusammen; sonst „Verbinden“.

+
@@ -96,7 +100,6 @@ function render() { runPage = paged.page; paged.items.forEach((run) => { const tr = document.createElement("tr"); - tr.className = "pick"; tr.innerHTML = ""; tr.children[0].textContent = run.printer_name; tr.children[0].className = "proto-printer"; @@ -109,19 +112,54 @@ function render() { link.title = fullName; link.textContent = shortRunName(run.filename) || fullName; fileCell.appendChild(link); + if (run.file_id) { + const fileLink = document.createElement("a"); + fileLink.href = "/dateien?file=" + encodeURIComponent(run.file_id); + fileLink.className = "tiny"; + fileLink.textContent = "Datei"; + fileLink.style.marginLeft = "0.35rem"; + fileCell.appendChild(fileLink); + } + if (run.project_id) { + const projLink = document.createElement("a"); + projLink.href = "/projekte#" + encodeURIComponent(run.project_id); + projLink.className = "tiny"; + projLink.textContent = "Projekt"; + projLink.style.marginLeft = "0.35rem"; + fileCell.appendChild(projLink); + } if (run.chargen_nr) { const code = document.createElement("div"); code.className = "mono-badge"; code.textContent = run.chargen_nr; fileCell.appendChild(code); } + if ((run.spool_names || []).length > 1 || (run.segment_count || 0) > 1) { + const swap = document.createElement("div"); + swap.className = "tiny warn-text"; + swap.textContent = "Rollenwechsel: " + (run.spool_names || []).join(" → "); + fileCell.appendChild(swap); + } + if (run.merge_with_id) { + const hint = document.createElement("div"); + hint.className = "tiny warn-text"; + hint.textContent = "Vermutlich Fortsetzung von/zu #" + run.merge_with_id; + fileCell.appendChild(hint); + } const stateKey = run.state === "in_progress" || run.state === "printing" ? "printing" + : run.state === "paused" ? "paused" : run.state === "completed" ? "complete" : run.state === "error" ? "error" : run.state === "cancelled" || run.state === "interrupted" ? "cancelled" : "idle"; - tr.children[2].innerHTML = chipHtml(stateKey, states[run.state] || run.state); - tr.children[3].textContent = hours(run.duration_s); + let stateLabel = states[run.state] || run.state; + if (run.state === "cancelled" && run.merge_with_id) stateLabel = "Teilabbruch (Runout?)"; + tr.children[2].innerHTML = chipHtml(stateKey, stateLabel); + let durationText = hours(run.duration_s); + if (run.pause_s && run.pause_s > 60) { + durationText += " (Pause " + hours(run.pause_s) + ")"; + } + tr.children[3].textContent = durationText; tr.children[3].className = "num"; const used = Number(run.weight_used_g) || Number(run.weight_g) || 0; const planned = Number(run.weight_planned_g) || Number(run.weight_g) || 0; @@ -134,7 +172,11 @@ function render() { } const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0); tr.children[5].textContent = cost ? euro(cost) : ""; + const verdictLabel = document.createElement("label"); + verdictLabel.className = "sr-only"; + verdictLabel.textContent = "Urteil"; const verdict = document.createElement("select"); + verdict.setAttribute("aria-label", "Urteil"); [["", "offen"], ["gut", "gut"], ["ausschuss", "Ausschuss"]].forEach(([value, label]) => { const option = document.createElement("option"); option.value = value; @@ -145,10 +187,12 @@ function render() { verdict.addEventListener("change", async () => { try { await api("/laeufe/" + run.id, { method: "PATCH", json: { verdict: verdict.value } }); + toast("Urteil gespeichert."); } catch (error) { showNote("run-note", error); } }); + tr.children[6].appendChild(verdictLabel); tr.children[6].appendChild(verdict); if (run.needs_spool || !run.deducted) { const select = document.createElement("select"); @@ -183,14 +227,49 @@ function render() { } }); tr.children[7].appendChild(select); + } else if ((run.spool_names || []).length > 1) { + tr.children[7].className = "proto-spool"; + tr.children[7].textContent = run.spool_names.join(" → "); + tr.children[7].title = run.spool_names.join(" → "); } else { const spool = spools.find((item) => item.id === run.spool_id); - tr.children[7].textContent = spool ? spool.name : "abgezogen"; - tr.children[7].title = spool ? spool.name : ""; tr.children[7].className = "proto-spool"; + if (spool) { + tr.children[7].innerHTML = linkTo("spule", spool.id, spool.name); + tr.children[7].title = spool.name; + } else if ((run.spool_names || []).length) { + tr.children[7].textContent = run.spool_names[0]; + } else { + tr.children[7].textContent = "abgezogen"; + } } if (!tr.children[7].className) tr.children[7].className = "proto-spool"; - if (run.file_id) { + const actions = tr.children[8]; + actions.className = "proto-actions"; + if (run.merge_with_id) { + const mergeBtn = document.createElement("button"); + mergeBtn.type = "button"; + mergeBtn.className = "ghost tiny-btn"; + mergeBtn.textContent = "Verbinden"; + mergeBtn.title = "Mit Lauf #" + run.merge_with_id + " verbinden"; + mergeBtn.addEventListener("click", () => { + armButton(mergeBtn, "Mit #" + run.merge_with_id + " verbinden?", async () => { + try { + await api("/laeufe/" + run.id + "/verbinden", { + method: "POST", + json: { other_id: run.merge_with_id, confirm: true }, + }); + toast("Läufe verbunden."); + await load(); + } catch (error) { + toast(error.message, "bad"); + } + }); + }); + actions.appendChild(mergeBtn); + } + const running = ["in_progress", "printing", "paused"].includes(run.state); + if (run.file_id && !running) { const again = document.createElement("button"); again.type = "button"; again.className = "ghost tiny-btn"; @@ -235,7 +314,7 @@ function render() { } }); }); - tr.children[8].appendChild(again); + actions.appendChild(again); } body.appendChild(tr); }); @@ -250,6 +329,8 @@ async function load() { runPage = 1; render(); } +window.onDruckerLive = () => { load().catch(() => {}); }; +window.onQueueChanged = () => { load().catch(() => {}); }; ["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => { document.getElementById(id).addEventListener("change", () => { runPage = 1; render(); }); diff --git a/web/templates/statistik.html b/web/templates/statistik.html index d03a67c..fdde69d 100644 --- a/web/templates/statistik.html +++ b/web/templates/statistik.html @@ -19,14 +19,18 @@
-
- - - - - - -
+
+ Weitere Filter +
+ + + + + + + +
+

@@ -231,17 +235,37 @@ function render(data) { `; } +async function fillProjects() { + const select = document.getElementById("project"); + if (!select || select.dataset.filled) return; + try { + const rows = await api("/projekte?status="); + const current = select.value; + rows.forEach((p) => { + const opt = document.createElement("option"); + opt.value = p.id; + opt.textContent = p.name; + select.appendChild(opt); + }); + select.dataset.filled = "1"; + if ([...select.options].some((o) => o.value === current)) select.value = current; + } catch (error) { /* optional */ } +} + async function load() { + await fillProjects(); const params = new URLSearchParams(); const since = document.getElementById("since").value; const until = document.getElementById("until").value; const host = document.getElementById("host").value; + const project = document.getElementById("project").value; const material = document.getElementById("material").value.trim(); const brand = document.getElementById("brand").value.trim(); const color = document.getElementById("color").value.trim(); if (since) params.set("since", since); if (until) params.set("until", until); if (host) params.set("host", host); + if (project) params.set("project_id", project); if (material) params.set("material", material); if (brand) params.set("brand", brand); if (color) params.set("color", color); @@ -278,6 +302,7 @@ document.getElementById("period").addEventListener("click", (event) => { }); }); document.getElementById("host").addEventListener("change", scheduleLoad); +document.getElementById("project").addEventListener("change", scheduleLoad); ["material", "brand", "color"].forEach((id) => { const node = document.getElementById(id); if (!node) return; diff --git a/web/templates/wand.html b/web/templates/wand.html index dd46764..3e2049c 100644 --- a/web/templates/wand.html +++ b/web/templates/wand.html @@ -49,6 +49,10 @@
+
@@ -367,10 +371,15 @@ function stripsHtml(printer, state) { } const filamentHint = state === "paused" ? filamentPauseHint(printer) : ""; if (filamentHint) { + const slot = (printer.slots || []).find((s) => s && (s.empty || s.runout)) || (printer.slots || [])[0]; + const slotN = slot && (slot.index != null ? Number(slot.index) + 1 : slot.slot); + const matHref = "/material?host=" + encodeURIComponent(printer.host || "") + + (slotN ? "&slot=" + encodeURIComponent(slotN) : ""); + const slotTxt = slotN ? "ACE Platz " + slotN + ": " : ""; bits.push(`
-
${icon("alert")}Neue Rolle in Material dem Drucker zuweisen
- ${escapeHtml(filamentHint)} — sonst zählt der Verbrauch weiter zur alten Spule. Ort = dieser Drucker, speichern, dann Weiter. - +
${icon("alert")}${slotTxt}Neue Rolle in Material zuweisen
+ ${escapeHtml(filamentHint)} — 1) Rolle dem Drucker zuweisen, 2) speichern, 3) Weiter. Verbrauch bleibt dieselbe Charge. +
`); } const failed = !queueBusy && printer.queue_failed; @@ -1065,6 +1074,156 @@ function patchDesk(printer) { // Kamera nur über Timer aktualisieren — sonst doppelter Reload + Layout-Ruckler syncDeskViewer(printer).catch(() => {}); refreshDeskVerlauf(false); + refreshDeskNext(printer, state); +} + +let deskNextAt = 0; +let deskNextHost = ""; +let deskNextKey = ""; + +function deskCanOfferStart(printer, state) { + if (!printer) return false; + if (["printing", "paused", "heating", "offline", "error"].includes(state)) return false; + if (printer.queue_running || startingJob(printer)) return false; + if (needsClear(printer)) return false; + return true; +} + +function deskMaterialTypes(printer) { + const types = new Set(); + (printer.spools || []).forEach((s) => { + if (s && s.material_type) types.add(String(s.material_type).toLowerCase()); + }); + (printer.ace_slots || printer.slots || []).forEach((s) => { + const t = (s && (s.type || s.material_type)) || ""; + if (t) types.add(String(t).toLowerCase()); + }); + return types; +} + +function fileMatchesPrinter(file, printer) { + if (!file) return false; + const family = printer.family || ""; + if (file.printer_family && family && file.printer_family !== family) return false; + const types = deskMaterialTypes(printer); + if (!types.size) return true; + const mats = (file.filaments || []).map((f) => String(f.type || f.material_type || "").toLowerCase()).filter(Boolean); + if (!mats.length) return true; + return mats.some((m) => [...types].some((t) => t.includes(m) || m.includes(t))); +} + +async function refreshDeskNext(printer, state, force) { + const box = deskEl.querySelector('[data-role="next"]'); + const body = deskEl.querySelector('[data-role="next-body"]'); + if (!box || !body) return; + if (!deskCanOfferStart(printer, state)) { + box.hidden = true; + body.innerHTML = ""; + deskNextKey = ""; + return; + } + const host = printer.host; + const key = host + "|" + (printer.queue_waiting || 0) + "|" + (printer.filename || ""); + const now = Date.now(); + if (!force && deskNextHost === host && deskNextKey === key && now - deskNextAt < 25000) { + box.hidden = false; + return; + } + deskNextAt = now; + deskNextHost = host; + deskNextKey = key; + box.hidden = false; + body.innerHTML = '

Lade Vorschläge …

'; + try { + const [last, queue, files] = await Promise.all([ + api("/senden-zuletzt?host=" + encodeURIComponent(host)).catch(() => null), + api("/druck-queue?host=" + encodeURIComponent(host) + "&status=waiting&limit=5").catch(() => []), + api("/dateien?family=" + encodeURIComponent(printer.family || "") + "&limit=40").catch(() => []), + ]); + if (deskHost !== host) return; + const bits = []; + if (last && last.file_id) { + const name = jobLabel(last.filename) || ("#" + last.file_id); + bits.push(`
+
Nochmal${escapeHtml(name)}
+ + Anpassen +
`); + } + const waiting = (Array.isArray(queue) ? queue : []).filter((j) => j.status === "waiting"); + if (waiting.length) { + const job = waiting[0]; + bits.push(`
+
Warteschlange${escapeHtml(jobLabel(job.filename) || "Job")}
+ Zur Queue +
`); + } + const matched = (Array.isArray(files) ? files : []) + .filter((f) => fileMatchesPrinter(f, printer)) + .slice(0, 5); + if (matched.length) { + bits.push('
Passende Dateien' + matched.map((f) => { + const mats = [...new Set((f.filaments || []).map((x) => x.type || x.material_type).filter(Boolean))].join(" / "); + return `
+
${escapeHtml(jobLabel(f.name) || f.name)}${escapeHtml(mats || "")}
+ + Anpassen +
`; + }).join("") + "
"); + } + if (!bits.length) { + body.innerHTML = `

Keine Vorschläge. Datei wählen →

`; + } else { + body.innerHTML = bits.join(""); + } + } catch (error) { + if (deskHost !== host) return; + body.innerHTML = '

' + escapeHtml(error.message || "Vorschläge fehlen") + "

"; + } +} + +async function deskStartFile(fileId, fromLast) { + if (!deskHost || !fileId) return; + const host = deskHost; + let aceMap = []; + let bedLevel = false; + let spoolId = null; + try { + const last = await api( + "/senden-zuletzt?host=" + encodeURIComponent(host) + + "&file_id=" + encodeURIComponent(fileId) + ); + if (last) { + aceMap = last.ace_map || []; + bedLevel = !!last.bed_leveling; + spoolId = last.spool_id || null; + } + } catch (error) { /* Defaults */ } + if (fromLast && !aceMap.length) { + try { + const lastHost = await api("/senden-zuletzt?host=" + encodeURIComponent(host)); + if (lastHost && String(lastHost.file_id) === String(fileId)) { + aceMap = lastHost.ace_map || []; + bedLevel = !!lastHost.bed_leveling; + spoolId = lastHost.spool_id || null; + } + } catch (error) { /* ignore */ } + } + const printer = printerCache.get(host); + const canNow = printer && deskCanOfferStart(printer, shownState(printer)); + const payload = { + file_id: Number(fileId), + hosts: [host], + confirm: true, + when_free: !canNow, + bed_leveling: bedLevel, + }; + if (spoolId) payload.spool_id = spoolId; + if (aceMap.length) payload.ace_maps = { [host]: aceMap }; + await api("/senden", { method: "POST", json: payload }); + toast(canNow ? "Druck startet…" : "In die Warteschlange.", "good"); + deskNextAt = 0; + if (printer) refreshDeskNext(printer, shownState(printer), true); } async function refreshDeskVerlauf(force) { @@ -1102,8 +1261,12 @@ async function refreshDeskVerlauf(force) { kwh, ].filter(Boolean); if (caption) caption.textContent = bits.join(" · "); + const fileBit = run.file_id + ? `Datei` + : ""; const head = `
${escapeHtml(jobLabel(run.filename) || "Druck")}${run.chargen_nr ? " · " + escapeHtml(run.chargen_nr) : ""} + ${fileBit} Bericht →
`; const html = head + tempChartHtml(data.samples || []); @@ -1515,6 +1678,34 @@ window.addEventListener("keydown", (event) => { }); async function handleWallClick(event) { + const nextAgain = event.target.closest("[data-next-again]"); + if (nextAgain) { + armButton(nextAgain, "Nochmal starten?", async () => { + nextAgain.disabled = true; + try { + await deskStartFile(nextAgain.dataset.nextAgain, true); + } catch (error) { + toast(error.message || String(error), "bad"); + } finally { + nextAgain.disabled = false; + } + }); + return; + } + const nextStart = event.target.closest("[data-next-start]"); + if (nextStart) { + armButton(nextStart, "Starten?", async () => { + nextStart.disabled = true; + try { + await deskStartFile(nextStart.dataset.nextStart, false); + } catch (error) { + toast(error.message || String(error), "bad"); + } finally { + nextStart.disabled = false; + } + }); + return; + } const pill = event.target.closest("[data-desk-host]"); if (pill) { openDesk(pill.dataset.deskHost); diff --git a/web/templates/warteschlange.html b/web/templates/warteschlange.html index a1b82a9..09098ff 100644 --- a/web/templates/warteschlange.html +++ b/web/templates/warteschlange.html @@ -111,7 +111,7 @@ function render() { if (job.direct) { return ` ${chipHtml("printing", "läuft")} - ${escapeHtml(jobLabel(job.filename))} + ${job.file_id ? `${escapeHtml(jobLabel(job.filename))}` : `${escapeHtml(jobLabel(job.filename))}`} ${escapeHtml(targetOf(job))} — ${escapeHtml(job.message || "")} @@ -125,16 +125,24 @@ function render() { const rep = repeatText(job); const tools = []; if (st === "waiting" && waitIdx > 0) { - tools.push(``); + tools.push(``); } if (st === "waiting" && waitIdx >= 0 && waitIdx < waiting.length - 1) { - tools.push(``); + tools.push(``); } if (st === "error" && job.is_ablauf) { - tools.push(``); - tools.push(``); + tools.push(``); + tools.push(``); + } else if (st === "error") { + if (job.host) { + tools.push(`${icon("printer")}Wand`); + } + if (job.file_id) { + tools.push(``); + } + tools.push(``); } else if (st === "waiting" || st === "running") { - tools.push(``); + tools.push(``); } const phase = String(job.phase || "").toLowerCase(); const starting = st === "running" && (phase === "starting" || phase === "await_print"); @@ -142,7 +150,7 @@ function render() { const chipLabel = starting ? "startet" : (STATUS_LABEL[st] || st); return ` ${chipHtml(chipState, chipLabel)} - ${escapeHtml(jobLabel(job.filename))}${rep ? " · " + escapeHtml(rep) : ""} + ${job.file_id ? `${escapeHtml(jobLabel(job.filename))}` : `${escapeHtml(jobLabel(job.filename))}`}${rep ? " · " + escapeHtml(rep) : ""} ${job.host ? `${escapeHtml(targetOf(job))}` : escapeHtml(targetOf(job))} ${steps || "—"} ${escapeHtml(job.message || "")} @@ -217,6 +225,38 @@ document.getElementById("q-rows").addEventListener("click", async (event) => { }); return; } + const requeue = event.target.closest("[data-requeue]"); + if (requeue) { + const job = jobs.find((j) => j.id === Number(requeue.dataset.requeue)); + if (!job || !job.file_id) return; + armButton(requeue, "Erneut einreihen?", async () => { + try { + await api("/druck-queue/" + job.id, { + method: "PATCH", json: { status: "waiting", undo: true }, + }); + toast("Wieder in der Warteschlange."); + load(); + if (typeof refreshPrintQueue === "function") refreshPrintQueue(); + } catch (error) { + try { + await api("/senden", { + method: "POST", + json: { + file_id: job.file_id, + hosts: job.host ? [job.host] : [], + confirm: true, + when_free: true, + ace_maps: job.host && (job.ace_map || []).length ? { [job.host]: job.ace_map } : {}, + bed_leveling: !!job.bed_leveling, + }, + }); + toast("Erneut eingereiht."); + load(); + } catch (err2) { toast(err2.message, "bad"); } + } + }); + return; + } const prio = event.target.closest("[data-prio]"); if (prio) { const id = Number(prio.dataset.prio);