Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ddffbbeb1 | ||
|
|
6e59df22e5 | ||
|
|
8e88efc094 | ||
|
|
0236278929 | ||
|
|
9e22cebb81 |
No files matched your search
@@ -9,7 +9,7 @@ Grotti ist ein eigenständiges Repo. Alles, was das Tool betrifft, bleibt hier.
|
||||
|
||||
- Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits.
|
||||
- Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
|
||||
- Version in `grotti/version.py` nach SemVer: Patch für Fehlerbehebung, Minor für Funktion, Major für Bruch an API oder Datenbankschema. Beim Release `[Unreleased]` in `[x.y.z] – Datum` umbenennen. Git-Tag `vX.Y.Z`.
|
||||
- **Version/`version.py` nur erhöhen, wenn der Nutzer explizit Release sagt.** Keine Zwischen-Patches von allein. Beim Release: SemVer (Patch/Minor/Major), `[Unreleased]` → `[x.y.z] – Datum`, Git-Tag `vX.Y.Z`.
|
||||
- Nutzer-Anleitung: `docs/Anleitung-*.md` unter `/doku`. Technik/Debug: `docs/internal/` — nicht in der App.
|
||||
- CSS-Version in `web/templates/layout.html` (`grotti.css?v=`) erhöhen, wenn sich `grotti.css` ändert.
|
||||
- Keine freie G-code-Konsole, kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Druckstarts brauchen `confirm: true`.
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
config.json
|
||||
data/
|
||||
|
||||
# Slice-/Watch-Ordner: nur der Platzhalter im Git, Inhalt lokal
|
||||
slice/*
|
||||
!slice/.gitkeep
|
||||
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -4,7 +4,7 @@ Grotti ist ein eigenständiges Repo. Alles, was das Tool betrifft, bleibt hier.
|
||||
|
||||
- Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits.
|
||||
- Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
|
||||
- Version in `grotti/version.py` nach SemVer: Patch für Fehlerbehebung, Minor für Funktion, Major für Bruch an API oder Datenbankschema. Beim Release `[Unreleased]` in `[x.y.z] – Datum` umbenennen. Git-Tag `vX.Y.Z`.
|
||||
- **Version/`version.py` nur erhöhen, wenn der Nutzer explizit Release sagt.** Keine Zwischen-Patches von allein. Beim Release: SemVer (Patch/Minor/Major), `[Unreleased]` → `[x.y.z] – Datum`, Git-Tag `vX.Y.Z`.
|
||||
- Nutzer-Anleitung: `docs/Anleitung-*.md`, gerendert unter `/doku`. Technik und Debug: `docs/internal/` — erscheint **nicht** in der App (inkl. MCP.md, Anycubic-Stack, …).
|
||||
- CSS-Version in `web/templates/layout.html` (`grotti.css?v=`) erhöhen, wenn sich `grotti.css` ändert.
|
||||
- Keine freie G-code-Konsole, kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Druckstarts brauchen `confirm: true`.
|
||||
@@ -4,6 +4,132 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.2.0] – 2026-10-18
|
||||
|
||||
### Neu
|
||||
|
||||
- **Jetzt-Liste** / Topbar-Chips / Mobile-Tab `/jetzt`: Platte räumen, Queue-Fehler, Bedarf, Wartung, Aufträge, Material-Wartegründe; Farm-ETA; Filament-Pause → „Material nachlegen“.
|
||||
- **`GET /api/v1/jetzt`**, **`GET /api/v1/prognose`**, **`GET /api/v1/suche`** (Ctrl+K).
|
||||
- **Filament-Pause:** Pause + ACE/`mmu`/Runout-Hinweis → Event `spule`, Jetzt-Eintrag, Alert.
|
||||
- **Druckbericht:** Strom-Aufschlüsselung (Grundlast / Düse / Bett / Trockner) mit Tortendiagramm.
|
||||
- **Material-Routing:** „Nächster freier“ bevorzugt passende Rolle/Farbe/Rest; sonst Wartegrund in der Queue.
|
||||
- **Termine** (`due_at`) sortieren die Warteschlange vor der Priorität.
|
||||
- **Auto-Retry** (einmal, Einstellung) bei Queue-/Ablauf-Fehler.
|
||||
- **Gleich nochmal drucken** im Abräumen-Dialog.
|
||||
- **Tagesbericht** 7 Uhr (Telegram/ntfy) und Push bei Wartung fällig, Bedarf, Auftrag überfällig.
|
||||
- **Kosten pro Teil** und Stück-Yield in Bericht, Auftrag und Statistik.
|
||||
- **Undo-Toast** beim Entfernen aus der Warteschlange; **Ctrl+K**-Suche.
|
||||
- **Design-Polish:** Lucide-Icons (`icons.svg`), Schrift Inter, Tokens/Komponenten; Topbar und Sidebar-Gruppen.
|
||||
- **Kamera-Stream:** Snapshot oder MJPEG in Einstellungen und auf der Kameras-Seite.
|
||||
- **Community-Screenshots** unter `docs/community/`.
|
||||
- Einstellungen: Abschnitts-Navigation, Autosave, Drucker als Karten mit Dialog.
|
||||
- Cockpit: Alltag oben, **Technik**-Fold (Steuerung, Verlauf, 3D, Bettnetz) standardmäßig zu.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Live-Status** in der Topbar (Chips + Popover); Mobile-Tab **Jetzt**.
|
||||
- **PA12-CF** in Grotti als PA12-CF; am ACE als PA6-CF (ACE kennt den Typ nicht).
|
||||
- Falsche Sorte warnt in Dateien, hält die Queue nicht an; zu wenig Rest blockiert weiter.
|
||||
- Alert-Hygiene: `spule`/`start`/`auth` quiet; Sidebar-Label „Meldungen“.
|
||||
- Status als Chip; Einstellungen-Nav mit Icons; Autosave; Light-Theme; `tabular-nums`.
|
||||
- Begriffe: Queue-Eintrag = Druck; Empty-States mit Handlung; Primär-/Danger-Buttons.
|
||||
- Cool-Park blockiert den Queue-Tick nicht mehr (Worker).
|
||||
- Kunden und Positionen bearbeiten/löschen unter Aufträge.
|
||||
- `projekte.html` / `ideen.html` entfernt (Redirect bleibt).
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Bedarf/Wartung/Auftrag-Spam:** Ops-Alerts Dedup-Art und Event-`kind` stimmten nicht überein (jede Minute derselbe Hinweis).
|
||||
- **Platte belegt nach Neustart:** Watcher setzte bereits Quittiertes wieder auf `needs_clear`.
|
||||
- **„Pausiert“ nach Abbruch/Offline:** Live-Lauf blieb offen bei Offline→standby.
|
||||
- **Regal-Modus:** Sidebar-Menü unter Desktop-Breite ausgeblendet.
|
||||
|
||||
## [3.1.0] – 2026-10-18
|
||||
|
||||
### Neu
|
||||
|
||||
- **Bedarf in der UI:** Sidebar-Warnung, Karte unter Material, Chip in der Warteschlange.
|
||||
- **Start-Zeitfenster** je Drucker in den Einstellungen (Queue nur im Fenster).
|
||||
- **HT/NT-Tarif** in den Energie-Einstellungen.
|
||||
- **ntfy-Test** wie bei Telegram.
|
||||
- **Helles Theme** (Dunkel / Hell / System) pro Browser.
|
||||
- Statistik: Balkendiagramme Druckzeit/Verbrauch, Empty-State ohne Daten.
|
||||
- Wartung: KPIs, Empty-State, Reset mit Bestätigung (`armButton`).
|
||||
|
||||
### Geändert
|
||||
|
||||
- Cockpit und Warteschlange nutzen SSE/`createPoller` statt hartem 5‑s-Intervall.
|
||||
- Shared-`config.token` wird nicht mehr als Auth akzeptiert — nur noch Session und `grt_…`-Tokens.
|
||||
- Legacy-Alias `GET /api/drucker` entfernt (nur `/api/v1/drucker`).
|
||||
|
||||
### Behoben
|
||||
|
||||
- SSE unter waitress: Hop-by-Hop-Header `Connection` entfernt (sonst Fehler auf `/api/v1/stream`).
|
||||
- Bedarf: Material/Farbe aus `files.filaments` statt nicht existierender Spalten.
|
||||
- Chrome: Service-Worker lieferte bei Fehlern die Startseite statt der angeklickten Route; „Mehr“-Dialog blockierte die Navigation; SSE und Poller liefen parallel und stauten Verbindungen.
|
||||
|
||||
## [3.0.0] – 2026-10-18
|
||||
|
||||
Major: neue Tabellen (Aufträge, Wartung), Projekte/Ideen → Aufträge, Navigation umgebaut, Dev-Server → waitress, Schema über Migrationen.
|
||||
|
||||
**Vor dem Update:** `pg_dump` der Datenbank. Danach Grotti starten (Migrationen 003–009 laufen automatisch).
|
||||
|
||||
### Neu
|
||||
|
||||
- **Aufträge:** Kunden, Positionen (Datei × Menge), Fortschritt, Einreihen, Lieferschein; Migration aus Projekten/Ideen; API `/api/v1/kunden`, `/api/v1/auftraege`; MCP-Lesetools.
|
||||
- **Ausschuss-Pflicht:** Dialog beim „Platte geräumt“ (Urteil, Grund, Stückzahlen).
|
||||
- **Wartung:** Laufzähler und Verschleißteile, Seite `/wartung`.
|
||||
- **Bedarf:** Queue vs. Spulenrest (`GET /api/v1/bedarf`).
|
||||
- **ntfy** neben Telegram; **Backup**-ZIP (pg_dump + data/); **Zeitfenster** je Drucker; optional HT/NT-Tarif.
|
||||
- **SSE** Live-Stream `/api/v1/stream`, Verbindungsbanner, PWA-Manifest/Service-Worker.
|
||||
- Migrationen unter `grotti/migrations/`, Connection-Pool, Indizes, Sample-Retention.
|
||||
- Mobile-Nav mit „Mehr“-Sheet; Anleitungen Aufträge und Wartung.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Server: waitress; Threads starten in `server.main()`.
|
||||
- Navigation: Aufträge statt Projekte/Ideen; Wartung; Dateien-/Statistik-Icons.
|
||||
- Kontrast `--faint`, `prefers-reduced-motion`, Touch-Ziele, Print-CSS.
|
||||
- Upload-Whitelist, Host-Validierung, ProxyFix, Settings-Lock, `?token=` nur Bilder.
|
||||
- Spulenabzug History ohne blinde Rate; Chargen-Locks; MCP `temp`/`percent`.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Hängende Queue-Starts nach Neustart; Worker-Rollback; `snapshot()`-Exceptions.
|
||||
- Toast-Dedupe; ACE-Map bei Send-Vorbereitung; Spulenwarnung mit ACE.
|
||||
|
||||
## [2.10.0] – 2026-10-18
|
||||
|
||||
### Neu
|
||||
|
||||
- Einstellungen: Slice-Ordner (`watch_dir`) setzen; Repo-Ordner `slice/` als Standard (Inhalt gitignored).
|
||||
- Orca-Nachbearbeitung: `scripts/orca_senden.py` lädt den Export nach Grotti und öffnet `/dateien?file=…` (Drucker, ACE, Einreihen in der UI); Anleitung unter Doku.
|
||||
- Bibliothek: Ordner unter dem Slice-Pfad anlegen und Watch-Dateien verschieben (ID/Projekt bleiben); API `…/dateien/ordner`, `…/verschieben`.
|
||||
- Bibliothek: Filter Material und Ordner, Sortierung (Neueste / Name / Material / Ordner / Druckzeit / Gewicht), Suche auch in Material und Projekt; Filter bleiben im Browser; Gruppenköpfe bei Sort Material/Ordner.
|
||||
- Cockpit: Verlauf (Temp/ED) des aktuellen oder letzten Drucks, Link zum Bericht; gemeinsame Chart-Hilfe in `api.js`.
|
||||
- Druckbericht-Verlauf: Zeitachse (links °C, rechts ED %), gestrichelte Soll-Linien, Einschaltdauer Düse/Bett; Aufheizen wird dichter erfasst.
|
||||
- Stromschätzung aus ED: Einstellungen → Düse-/Bett-Watt; Bericht zeigt kWh (ED oder pauschal).
|
||||
- Einstellungen → Dieser Browser: Zeitzone für Anzeigezeiten (Browser automatisch oder z. B. Europe/Berlin).
|
||||
|
||||
### Geändert
|
||||
|
||||
- Energie-Defaults: Grundlast 40 W (Elektronik), Düse 60 W, Bett 220 W; Hinweis in den Einstellungen, wenn Düse/Bett 0 (nur pauschal).
|
||||
- Druckbericht: Start/Ende als lesbare Ortszeit (`17.10.2026, 15:09 – 15:46`) statt ISO.
|
||||
- Sidebar **Läuft jetzt**: eine Zeile pro Drucker (Name · % · Fertig-Uhr), Fortschritt in der Zeile; Klick öffnet die Warteschlange, × bricht ab.
|
||||
- Spulenakte: KPI-Kacheln statt Fließtext; Druckzeilen mit Datum, Charge `D-…`, Kurzname, Gramm und Status.
|
||||
- Material-Spulenformular: Marke, Chargen und Technik unter „Weitere Angaben“ eingeklappt.
|
||||
- Druck-Etikett: „Soll … g“ statt „Ziel“ (Slicer-Filamentmenge, wie im Bericht).
|
||||
- Tasten-Grenze im Ablauf: dokumentierter Default 3 mm (kalt an der Düse sonst Fehlalarm); Anleitung und Technik-Notiz angepasst.
|
||||
- `requirements.txt` mit festen Versionen; `requirements-dev.txt` mit pytest.
|
||||
- Unbenutzte Helfer entfernt (`printers.load_printers`, Mesh-Vergleich 3×3, `fetch_bed_mesh`, `last_sample_at`, …).
|
||||
|
||||
### Behoben
|
||||
|
||||
- Druckbericht: Objekt-Etiketten kollidieren nicht mehr, wenn dieselbe Chargennummer schon an einem anderen Lauf hängt (Seite `/protokoll/<id>` lieferte 500).
|
||||
- Orca-Nachbearbeitung: Export unter dem Slice-Ordner (inkl. Unterordner) wird als Watch-Datei übernommen statt flachem Upload; Temp-Dateien `.gcode.pp` erkannt.
|
||||
- Material: Rest-Gramm im Spulenformular akzeptiert Dezimalwerte (Abzug lieferte z. B. 501,34 g — Browser blockierte bei `step=1`).
|
||||
|
||||
|
||||
## [2.5.1] – 2026-10-17
|
||||
|
||||
### Neu
|
||||
|
||||
@@ -30,7 +30,7 @@ copy config.example.json config.json
|
||||
| `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. |
|
||||
| `database_url` | PostgreSQL-Verbindung. |
|
||||
| `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. |
|
||||
| `watch_dir` | Slice-Ordner, der beobachtet wird. Leer = nur Upload. |
|
||||
| `watch_dir` | Slice-Ordner (unter Einstellungen setzen). Leer = nur Upload. |
|
||||
| `token` | Wenn gesetzt, braucht die API `Authorization: Bearer <token>`. |
|
||||
| `fqdn` | Öffentliche Basis-URL für Spulen-Etiketten-QR. Leer = Request-Host. |
|
||||
| `energy` | Watt und Strompreis für die Kosten im Protokoll. |
|
||||
@@ -39,6 +39,8 @@ copy config.example.json config.json
|
||||
|
||||
Die Drucker stehen beim ersten Start in `drucker.json` und landen dann in der Datenbank. Danach werden sie unter Einstellungen gepflegt.
|
||||
|
||||
Schema-Änderungen laufen beim Start über `grotti/migrations/` (Tabelle `schema_version`). Vor Major-Updates die Datenbank sichern (`pg_dump`).
|
||||
|
||||
```json
|
||||
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
|
||||
```
|
||||
@@ -75,17 +77,27 @@ Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bear
|
||||
| `POST /auth/setup`, `/auth/login`, `/auth/logout` | Ersten Admin, Login, Logout |
|
||||
| `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens |
|
||||
| `GET /version` | Version |
|
||||
| `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe |
|
||||
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag |
|
||||
| `POST /drucker` · `PATCH`/`DELETE /drucker/<id>` | Drucker anlegen/ändern (Admin) |
|
||||
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
|
||||
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter |
|
||||
| `GET /dateien`, `POST /dateien` | Bibliothek, Upload |
|
||||
| `POST /senden` | Datei an Drucker. `confirm: true` ist Pflicht. `when_free: true` reiht ein. |
|
||||
| `GET /druck-queue`, `POST /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange (`confirm` beim Einreihen) |
|
||||
| `POST /spulen/<id>/einlegen` | Spule an Drucker/Platz; alte Rolle dort ins Regal |
|
||||
| `POST /ablauf` | Ablauf für Kobra 3 einreihen |
|
||||
| `GET /ereignisse?host=&kind=&since=&limit=` | Aktivität |
|
||||
| `POST /ereignisse/gelesen` | Alerts als gelesen |
|
||||
| `GET /spulen`, `GET /laeufe`, `GET /projekte` | Material, Protokoll, Projekte |
|
||||
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE |
|
||||
| `POST /drucker/<host>/abraeumen` | Platte geräumt (`confirm`) |
|
||||
| `GET /kamera/<host>`, `/vorschau/<host>` | Snapshot / Job-Thumbnail |
|
||||
| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek, Upload, Metadaten |
|
||||
| `POST /dateien/neu-lesen`, `/dateien/ordner`, `/dateien/<id>/verschieben` | Watch-Sync, Ordner, Verschieben |
|
||||
| `POST /senden`, `GET /sende-auftraege` | Sofort senden (`confirm`), Fortschritt |
|
||||
| `GET`/`POST /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange (`confirm` beim Einreihen) |
|
||||
| `GET /laeuft`, `/aufgaben` | Direktstarts und Platte-belegt |
|
||||
| `POST /ablauf`, `/schieben-testen` | Ablauf Kobra 3 / Schieben-Test |
|
||||
| `GET /spulen`, `POST /spulen`, `POST /spulen/<id>/einlegen` | Spulen und Einlegen |
|
||||
| `GET /spulen/<id>/akte`, `/spulen/by-code` | Spulenakte, Scan `S-…` |
|
||||
| `GET`/`POST /materialien`, `/farben` | Sorten und Farbpalette |
|
||||
| `GET /laeufe`, `/laeufe/aktuell`, `/laeufe/<id>` | Protokoll und Druckbericht |
|
||||
| `GET /statistik`, `/statistik.csv` | Auslastung und CSV |
|
||||
| `GET`/`POST /projekte`, `/ideen` | Projekte und Ideen-Backlog |
|
||||
| `GET /ereignisse`, `POST /ereignisse/gelesen` | Aktivität und Alerts |
|
||||
| `POST /kalkulation` | Vorkalkulation Material+Strom |
|
||||
|
||||
MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN` (siehe `docs/internal/MCP.md`).
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
"host": "127.0.0.1",
|
||||
"port": 8770,
|
||||
"data_dir": "data",
|
||||
"watch_dir": "",
|
||||
"watch_dir": "slice",
|
||||
"database_url": "postgresql://[email protected]:5432/grotti",
|
||||
"token": "",
|
||||
"secret_key": "",
|
||||
"fqdn": "",
|
||||
"auto_queue": true,
|
||||
"auto_retry": true,
|
||||
"oauth": {
|
||||
"enabled": false,
|
||||
"issuer": "",
|
||||
@@ -16,23 +17,38 @@
|
||||
"redirect_uri": ""
|
||||
},
|
||||
"energy": {
|
||||
"watts": 120,
|
||||
"watts": 40,
|
||||
"nozzle_watts": 60,
|
||||
"bed_watts": 220,
|
||||
"euro_per_kwh": 0.35,
|
||||
"dryer_watts": 0,
|
||||
"dry_hours": 0,
|
||||
"waste_percent": 0,
|
||||
"utilization": 50
|
||||
"utilization": 50,
|
||||
"tariff": {
|
||||
"ht_euro": 0.35,
|
||||
"nt_euro": 0.22,
|
||||
"nt_from": "22:00",
|
||||
"nt_to": "06:00"
|
||||
}
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"bot_token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"ntfy": {
|
||||
"enabled": false,
|
||||
"server": "https://ntfy.sh",
|
||||
"topic": "",
|
||||
"token": ""
|
||||
},
|
||||
"alerts": {
|
||||
"fertig": true,
|
||||
"fehler": true,
|
||||
"offline": true,
|
||||
"queue": true,
|
||||
"spule": true,
|
||||
"ablauf": false,
|
||||
"start": false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ACE und Spulen
|
||||
|
||||
Stand **17.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
|
||||
Stand **18.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
|
||||
|
||||

|
||||
|
||||
@@ -30,7 +30,7 @@ Externe Spule / keine ACE-Slots: keine `.acm` nötig.
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| „cannot parse the file“ | ACE-Zuordnung vollständig? Farben richtig? |
|
||||
| Material-Warnung | Eingelegte Sorte ≠ Datei — Spule wechseln oder Slice prüfen |
|
||||
| Material-Warnung | Eingelegte Sorte ≠ Datei. Der Start geht trotzdem. PA12-CF steht in Grotti so, an den ACE geht PA6-CF. |
|
||||
| Rest zu gering | Andere Spule oder leichteres Teil |
|
||||
| ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten |
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Aufträge
|
||||
|
||||
Stand **18.10.2026**. Kundenaufträge mit Positionen (Datei × Menge), Fortschritt und Lieferschein.
|
||||
|
||||
## Seite Aufträge
|
||||
|
||||
Unter **Aufträge** legst du Kunden und Aufträge an. Status: Idee → Angebot → Bestätigt → Druckt → Fertig → Geliefert → Bezahlt.
|
||||
|
||||
Pro Position: Datei, Stückzahl, optional Preis und Material. **Einreihen** schickt die Position in die Warteschlange (`confirm`). Fertige gute Stücke aus dem Abräumen-Dialog zählen auf den Fortschritt „n von m“.
|
||||
|
||||
## Lieferschein
|
||||
|
||||
Im Auftrag **Lieferschein** öffnen: Positionen, Chargen `D-…`, druckbar. Danach Status auf **Geliefert** setzen.
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Warteschlange](Anleitung-Warteschlange.md)
|
||||
- [Material](Anleitung-Material.md)
|
||||
- [Protokoll / Bericht](Anleitung-Oberflaeche.md)
|
||||
@@ -1,18 +1,22 @@
|
||||
# Dateien senden
|
||||
|
||||
Stand **15.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
|
||||
Stand **17.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
|
||||
|
||||

|
||||
|
||||
## Bibliothek
|
||||
|
||||
- Suche, Filter nach Maschine und Projekt
|
||||
- Suche nach Name, Material und Projekt
|
||||
- Filter: Maschine, Projekt, Material, Ordner (Unterordner unter dem Slice-Ordner; Uploads als „Upload“)
|
||||
- Sortierung: Neueste, Name, Material, Ordner, Druckzeit, Gewicht — bei Material/Ordner mit Gruppenköpfen
|
||||
- Auswahl der Filter bleibt im Browser merken
|
||||
- Vorschaubild, Material, Zeit, Gewicht, Schichthöhe
|
||||
- Info-Popup mit Slice-Metadaten (Kopf und Footer)
|
||||
- **Hochladen** per Knopf oder Drag & Drop
|
||||
- **Ordner neu lesen**, wenn `watch_dir` in der Config auf den Slice-Ordner zeigt
|
||||
- Mit gesetztem Slice-Ordner ([Einstellungen](Anleitung-Einstellungen.md)): **Ordner anlegen**, Watch-Dateien per Auswahl **verschieben** (Datei bleibt in der Bibliothek dieselbe, inkl. Projekt)
|
||||
- **Ordner neu lesen**, wenn unter Einstellungen ein Slice-Ordner gesetzt ist
|
||||
|
||||
Ohne `watch_dir` geht nur manueller Upload — das reicht für den Alltag.
|
||||
Ohne Slice-Ordner geht nur manueller Upload — das reicht für den Alltag. Uploads liegen flach unter „Upload“ und lassen sich in der Bibliothek nicht in Slice-Ordner schieben.
|
||||
|
||||
## Senden: drei Schritte
|
||||
|
||||
@@ -43,7 +47,12 @@ Große Dateien brauchen oft **eine Minute**. In der Sidebar unter **Senden** sie
|
||||
- Bei „Wenn frei“: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md)
|
||||
- Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map)
|
||||
|
||||
## Aus Orca
|
||||
|
||||
Nach dem G-code-Export kann Orca die Datei direkt nach Grotti laden und diese Seite mit der Datei öffnen — siehe [Orca an Grotti](Anleitung-Orca.md).
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Orca an Grotti](Anleitung-Orca.md)
|
||||
- [ACE und Spulen](Anleitung-ACE.md)
|
||||
- [Warteschlange](Anleitung-Warteschlange.md)
|
||||
@@ -27,7 +27,7 @@ copy config.example.json config.json
|
||||
| `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. |
|
||||
| `database_url` | PostgreSQL-Verbindung. |
|
||||
| `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. |
|
||||
| `watch_dir` | Slice-Ordner, den Grotti beobachtet. Leer = nur manueller Upload. |
|
||||
| `watch_dir` | Slice-Ordner (meist unter **Einstellungen** setzen). Leer = nur manueller Upload. Standard-Vorschlag: Ordner `slice` im Repo. |
|
||||
| `token` | Optionaler Shared-API-Token (Übergang). Mit Benutzerverwaltung nutzt du API-Tokens unter Einstellungen. |
|
||||
| `fqdn` | Öffentliche Basis-URL für QR auf Spulen-Etiketten. Leer = Request-Host. |
|
||||
| `auto_queue` | `false` stoppt neue „wenn frei“-Starts. |
|
||||
|
||||
@@ -1,42 +1,49 @@
|
||||
# Einstellungen
|
||||
|
||||
Stand **15.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie.
|
||||
Stand **18.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram/ntfy, Energie.
|
||||
|
||||

|
||||
|
||||
## Dieser Browser
|
||||
|
||||
- **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche.md).
|
||||
- **Darstellung** — Dunkel, Hell oder System (folgt dem Gerät).
|
||||
- **Zeitzone** — Browser automatisch oder fest (z. B. Europe/Berlin) für Protokoll, Bericht und Aktivität.
|
||||
|
||||
## Automatik
|
||||
|
||||
Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen „wenn frei“-Starts. Laufende Abläufe und manuelle „Jetzt“-Starts bleiben möglich.
|
||||
|
||||
## Slice-Ordner
|
||||
|
||||
Pfad zum Ordner, den die Bibliothek beobachtet (Export aus Orca/Anycubic, **Ordner neu lesen**, Unterordner). **Standardordner** setzt `slice` im Grotti-Repo. Leer = nur Hochladen. Nur Admin; der Ordner wird beim Speichern angelegt, falls er fehlt.
|
||||
|
||||
## Drucker
|
||||
|
||||
Tabelle: Name, Host, Familie, **gesperrt**.
|
||||
Pro Drucker: Name, Host, Familie, **gesperrt**, optional **Start-Zeitfenster**.
|
||||
|
||||
- Seed beim allerersten Start aus `drucker.json`, danach nur noch hier (Datenbank).
|
||||
- Familie aus dem Namen (Kobra 3 / S1) oder gesetzt.
|
||||
- **Gesperrt:** keine neuen Queue-Starts auf diesem Host.
|
||||
- **Zeitfenster:** Queue startet nur zwischen Von/Bis (z. B. 22:00–06:00). Optional Max.-Stunden für Nachtjobs.
|
||||
|
||||
## FQDN
|
||||
|
||||
Öffentliche Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host aus dem aktuellen Request. Wichtig, wenn du Etiketten von einem Handy scannst, das nicht `127.0.0.1` kennt.
|
||||
|
||||
## Telegram und Alerts
|
||||
## Telegram, ntfy und Alerts
|
||||
|
||||
Bot-Token und Chat-ID. Pro Ereignisart an/aus (Fertig, Fehler, Offline, Queue, …). Test-Knopf zum Prüfen. Wichtige Ereignisse stehen auch in der Sidebar unter Alerts.
|
||||
Telegram: Bot-Token und Chat-ID, pro Ereignisart an/aus, Test-Knopf. **ntfy:** Server, Topic, optional Token — gleiche Meldungen, unabhängig schaltbar, ebenfalls mit Test. Ereignisse stehen auch in der Sidebar unter Alerts.
|
||||
|
||||
## 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_…`).
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
## Energie
|
||||
|
||||
Watt und Strompreis für die Kosten im **Protokoll**. Optional Trockner/Ausschuss/Auslastung für die Kalkulation.
|
||||
**Grundlast W** (Elektronik/Motion, nicht die Heizer) und Strompreis für die Kosten im **Protokoll**. **Düse W** / **Bett W** zusammen mit ED-Proben im Verlauf → Schätzung „aus ED“; stehen sie auf 0, bleibt es bei Dauer × Grundlast („pauschal“). Typisch Kobra: Grundlast ~40 W, Düse ~60 W, Bett ~200–300 W. Optional **HT/NT-Tarif** mit Zeiten und Preisen — Grotti gewichtet dann die Stromkosten nach Uhrzeit. Beim Speichern warnt die Seite, wenn Düse oder Bett noch 0 sind.
|
||||
|
||||
## Weiter
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Material und mehr
|
||||
|
||||
Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras, Aktivität und Ideen.
|
||||
Stand **18.10.2026**. Spulen, Etiketten, Aufträge, Protokoll, Statistik, Kameras, Aktivität.
|
||||
|
||||

|
||||
|
||||
@@ -8,6 +8,8 @@ Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras
|
||||
|
||||
Zwei Reiter: **Spulen** und **Sorten**.
|
||||
|
||||
Oben die Karte **Bedarf aus Warteschlange**, wenn wartende Jobs Filament brauchen: Queue-Gewicht vs. Spulenrest je Sorte/Farbe. Fehlmengen erscheinen auch in der Sidebar und als Chip in der Warteschlange.
|
||||
|
||||
**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest. Formular bei Auswahl oder „Neue Spule“. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in.
|
||||
|
||||
**Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
||||
@@ -18,23 +20,25 @@ Pro Spule ein Etikett mit Chargennummer `S-…` und QR. Der Code öffnet Materia
|
||||
|
||||
Druck-Etiketten (`D-…`) kommen vom Druckbericht: ein Etikett für den ganzen Lauf oder je Objekt. Scan von `D-…` öffnet den Bericht.
|
||||
|
||||
## Projekte
|
||||
## Aufträge
|
||||
|
||||
Unter **Projekte** Aufträge mit Notiz und Kalkulation. Dateien lassen sich einem Projekt zuweisen (Bibliothek / Senden).
|
||||
Unter **Aufträge** Kunden, Positionen und Fortschritt. Dateien lassen sich einem Auftrag zuweisen; siehe [Aufträge](Anleitung-Auftraege.md).
|
||||
|
||||
## Protokoll und Statistik
|
||||
|
||||
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, 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, Strom-Aufschlüsselung mit Tortendiagramm, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
|
||||
|
||||
Unter **Statistik** Auslastung, Erfolg, Verbrauch und CSV-Export. Zeitraum per Knopf (Tag, Woche, Monat, Gestern, Letzte Woche, Letzter Monat) oder eigene Von/Bis-Daten.
|
||||
Nach Filament-Ende: neue Spule vom Regal dem Drucker/ACE-Platz zuweisen, am Gerät laden, an der Wand **Weiter**.
|
||||
|
||||
Druck-Etiketten (`D-…`) schon während des Drucks: an der Wand **Etikett**, im Druckbericht **Etikett Druck** / **Etiketten Objekte**. Auf dem Bogen steht das Zielgewicht. Bei Ausschuss die gedruckten Bögen wegwerfen; die Charge bleibt im Protokoll.
|
||||
Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis.
|
||||
|
||||
Druck-Etiketten (`D-…`) schon während des Drucks: an der Wand **Etikett**, im Druckbericht **Etikett Druck** / **Etiketten Objekte**. Auf dem Bogen steht das Sollgewicht aus dem Slicer (geplante Filamentmenge). Bei Ausschuss die gedruckten Bögen wegwerfen; die Charge bleibt im Protokoll.
|
||||
|
||||
Abzug nach dem Druck nimmt das Ist aus `filament_used` (Abbruch nur anteilig). Mehrfarb-Anteile sind als Schätzwert gekennzeichnet. Restgramm darf unter 0 gehen.
|
||||
|
||||
## Kameras
|
||||
|
||||
`/kameras` — alle Snapshots nebeneinander mit Live-Zustand. Praktisch auf dem Regal-Tablet.
|
||||
`/kameras` — alle Kameras nebeneinander mit Live-Zustand. Oben **Snapshot** oder **Stream** wählbar (gilt für diesen Browser, auch im Cockpit). Stream ist flüssiger, hält aber je sichtbarer Kamera eine Verbindung.
|
||||
|
||||
## Aktivität
|
||||
|
||||
|
||||
@@ -1,36 +1,47 @@
|
||||
# Oberfläche
|
||||
|
||||
Stand **15.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop und Regal.
|
||||
Stand **18.10.2026**. Wie die App aufgebaut ist — Menü, Jetzt-Liste, Desktop und Regal.
|
||||
|
||||
## Navigation
|
||||
|
||||
Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Aktivität**, **Warteschlange**, **Protokoll**, **Statistik**, **Material**, **Projekte**. Darunter **Einstellungen**, **Ideen**, **Doku**. Fuß: Version (öffnet die Doku).
|
||||
Links (Desktop), gruppiert: **Live** (Drucker, Dateien, Kameras, Warteschlange, Aufträge), **Rückschau** (Protokoll, Statistik, Aktivität), **Verwalten** (Material, Wartung). Darüber im Inhaltsbereich die **Topbar**: Suche, Live-Chips, Einstellungen, Doku und Benutzer-Menü (Abmelden, Version).
|
||||
|
||||
Unter ca. 760 px: untere Leiste mit den wichtigsten Seiten. Das Seitenmenü lässt sich zur Icon-Rail einklappen.
|
||||
Unter ca. 760 px: untere Leiste (Drucker, Dateien, Warteschlange, **Jetzt**, Mehr) plus **Mehr**-Sheet (inkl. Aktivität und Abmelden). Das Seitenmenü lässt sich zur Icon-Rail einklappen.
|
||||
|
||||

|
||||
Shortcut **Ctrl+K** (Mac: Cmd+K) oder der Suche-Knopf in der Topbar: Suche über Drucker, Dateien, Spulen, Chargen und Aufträge.
|
||||
|
||||
## Live-Leiste
|
||||
## Jetzt
|
||||
|
||||
Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Browser):
|
||||
Desktop: Chips in der Topbar (**Jetzt**, laufende Drucker, Senden, Meldungen). Klick öffnet ein Popover mit Liste und Aktionen. Bei mehreren Läufen zusätzlich **Alles fertig um …**.
|
||||
|
||||
| Box | Inhalt |
|
||||
Mobile / Touch: Tab **Jetzt** in der unteren Leiste (mit Badge) öffnet `/jetzt` — dieselbe Liste als volle Seite. Aktivität liegt unter **Mehr**.
|
||||
|
||||
| Priorität | Inhalt |
|
||||
| --- | --- |
|
||||
| **Läuft jetzt** | Laufende Aufträge und Abläufe, Schrittleiste, Abbrechen. Auch Direktstarts ohne Queue-Zeile. |
|
||||
| **Wartet** | Wartende Aufträge: Priorität, Abbrechen. Gestoppte Abläufe mit Fortsetzen. |
|
||||
| **Aufgaben** | Platte belegt ohne laufenden Ablauf — **geräumt** (Zwei-Klick). |
|
||||
| **Senden** | Uploads mit MB-Fortschritt. |
|
||||
| **Alerts** | Ungelesene wichtige Ereignisse; als gelesen markieren oder zur Aktivität. |
|
||||
| Platte räumen | Quittieren mit Urteil (optional „gleich nochmal“) |
|
||||
| Material nachlegen | Druck pausiert, Filament vermutlich leer (ACE/Runout) — Spule zuweisen, dann an der Wand **Weiter** |
|
||||
| Druck fehlgeschlagen | Fortsetzen / Ausblenden |
|
||||
| Spule fehlt | Link zu Material / Bedarf |
|
||||
| Wartung fällig | Link zu Wartung |
|
||||
| Auftrag überfällig | Link zu Aufträgen |
|
||||
| Wartet auf Material | Wartegrund aus dem Routing |
|
||||
|
||||
Aktualisierung über Live-Stream (SSE), sonst Polling.
|
||||
|
||||
## Desktop und Regal
|
||||
|
||||
Unter **Einstellungen → Dieser Browser**:
|
||||
|
||||
- **Desktop** — normale Dichte
|
||||
- **Regal (Touch)** — größere Bedienelemente, kompaktere Nav, Sidebar oft eingeklappt
|
||||
- **Regal (Touch)** — größere Bedienelemente, Sidebar oft eingeklappt; auf schmalem Viewport die untere Leiste. Alle Menüpunkte bleiben in der Sidebar sichtbar.
|
||||
- **Darstellung** — Dunkel, Hell oder System
|
||||
- **Zeitzone** — Browser oder fest (z. B. Europe/Berlin) für Uhrzeiten in Protokoll und Bericht
|
||||
- **Kamera** — Snapshot (schonend, alle paar Sekunden) oder Stream (flüssig, hält eine Verbindung je Kamera). Auch umschaltbar unter Kameras.
|
||||
|
||||
Shortcut in der URL: `?shelf=1` bzw. `?shelf=0`. Nur dieser Browser (`localStorage`).
|
||||
|
||||
Drucker-Cockpit: Alltag (Job, ETA, Rolle, Steuerung der Aktionen) oben; **Technik** (Steuerung, Verlauf, 3D-Pfad, Bettnetz) zugeklappt.
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Druckerwand](Anleitung-Wand.md)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Orca an Grotti
|
||||
|
||||
Stand **17.10.2026**. Nach dem Export in OrcaSlicer landet der G-code in der Bibliothek und die Seite [Dateien senden](Anleitung-Dateien.md) öffnet sich mit der Datei schon gewählt. Drucker, ACE-Platz und Einreihen wählst du dort wie gewohnt.
|
||||
|
||||
## Einmal einrichten
|
||||
|
||||
1. Grotti starten und im Browser anmelden (die Session bleibt für die Senden-Seite).
|
||||
2. Unter **Einstellungen → API-Token** ein Token anlegen (Rolle Operator reicht). Den Wert `grt_…` nur einmal sichtbar.
|
||||
3. Datei `data/orca-senden.json` anlegen (gleicher Ordner wie `config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "http://127.0.0.1:8770",
|
||||
"token": "grt_…"
|
||||
}
|
||||
```
|
||||
|
||||
`url` ist die Adresse, unter der du Grotti im Browser öffnest.
|
||||
|
||||
4. Im **Prozessprofil** (Druckqualität, z. B. „0.20mm Standard“): **Sonstiges → Nachbearbeitungsskripte**, eine Zeile:
|
||||
|
||||
```text
|
||||
D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_senden.py
|
||||
```
|
||||
|
||||
Gilt für **OrcaSlicer** und **AnycubicSlicerNext**. Pfade anpassen. Der Slicer hängt den Export-Pfad selbst an.
|
||||
|
||||
Unter **Gerät / Drucker** keinen Print-Host (Moonraker) eintragen — sonst geht der Job am Grotti-Senden vorbei.
|
||||
|
||||
## Ablauf
|
||||
|
||||
1. In Orca slicen und **G-code exportieren** (Platte schreiben). Als Ziel den Slice-Ordner wählen — **Unterordner** (z. B. `slice\Gridfinity\…`) gehen: Grotti legt die Datei dort ab und liest den Watch neu. Export außerhalb des Slice-Ordners landet als Upload (ohne Ordner-Verschieben).
|
||||
2. Der Browser öffnet `/dateien?file=…` mit der neuen Datei.
|
||||
3. Drucker wählen, unter Optionen Farbe → ACE-Platz, **Jetzt** / **Wenn frei** / **Nächster freier**, dann **Starten**.
|
||||
|
||||
Test ohne Orca:
|
||||
|
||||
```powershell
|
||||
D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_senden.py C:\pfad\datei.gcode
|
||||
```
|
||||
|
||||
## Störungen
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| Meldungsfenster „Config fehlt“ | `data/orca-senden.json` anlegen |
|
||||
| „Grotti nicht erreichbar“ | Server läuft? `url` stimmt? |
|
||||
| „Token fehlt oder stimmt nicht“ | Token neu anlegen, in der JSON eintragen |
|
||||
| Browser ohne Datei / Login | Einmal anmelden; Deep-Link behält `?file=` nach dem Login |
|
||||
| Skript läuft nicht | Export wirklich speichern; Zeile im **Druckerprofil**; Python-Pfad prüfen |
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Dateien senden](Anleitung-Dateien.md)
|
||||
- [ACE und Spulen](Anleitung-ACE.md)
|
||||
@@ -1,6 +1,6 @@
|
||||
# Druckerwand
|
||||
|
||||
Stand **15.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
|
||||
Stand **18.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
|
||||
|
||||

|
||||
|
||||
@@ -18,7 +18,7 @@ Aktionen auf der Karte brauchen oft **zwei Klicks** (erster Klick „scharf“,
|
||||
|
||||
| Aktion | Wann |
|
||||
| --- | --- |
|
||||
| **Pause** / **Weiter** | Während des Drucks |
|
||||
| **Pause** / **Weiter** | Während des Drucks; nach Materialwechsel Spule in Grotti an den Drucker hängen, dann **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 |
|
||||
@@ -34,10 +34,11 @@ URL mit Hash: `/#<host>` (z. B. nach Klick auf die Karte).
|
||||
|
||||
Inhalt:
|
||||
|
||||
- **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg
|
||||
- **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg. Modus Snapshot/Stream unter Einstellungen → Dieser Browser (oder Kameras-Seite)
|
||||
- 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 Strom nach Grundlast/Düse/Bett)
|
||||
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
|
||||
- **Bettnetz**-Heatmap, wenn vorhanden
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Warteschlange und Ablauf
|
||||
|
||||
Stand **15.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
|
||||
Stand **18.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
|
||||
|
||||

|
||||
|
||||
@@ -16,11 +16,13 @@ Pro Auftrag:
|
||||
|
||||
Unter **Läuft** siehst du auch Direktstarts ohne Queue-Zeile (Link zur Wand).
|
||||
|
||||
Dieselbe Information steht kompakt in der Sidebar (**Läuft jetzt** / **Wartet**).
|
||||
Dieselbe Information steht kompakt in der Sidebar (**Läuft jetzt** / **Wartet**). Unter **Läuft jetzt** eine Zeile pro Drucker; Klick öffnet diese Seite, × bricht ab.
|
||||
|
||||
Reicht der Spulenrest nicht für wartende Jobs, erscheint oben ein **Fehlmenge**-Hinweis (Details unter Material → Bedarf) und in der Sidebar unter **Bedarf**.
|
||||
|
||||
## Wann startet ein wartender Auftrag?
|
||||
|
||||
Sobald der Drucker bereit ist (`standby` / fertig / abgebrochen), die **Platte frei** ist und der Drucker nicht gesperrt. Automatik aus (`auto_queue`) oder gesperrt → keine neuen Starts; laufende Abläufe laufen weiter.
|
||||
Sobald der Drucker bereit ist (`standby` / fertig / abgebrochen), die **Platte frei** ist und der Drucker nicht gesperrt. Automatik aus (`auto_queue`) oder gesperrt → keine neuen Starts; laufende Abläufe laufen weiter. Mit **Start-Zeitfenster** in den Einstellungen nur innerhalb von Von/Bis.
|
||||
|
||||
## Ablauf (nur Kobra 3)
|
||||
|
||||
@@ -34,7 +36,7 @@ Drucken → Abkühlen → Schieben → Tasten → (Wiederholung)
|
||||
| --- | --- |
|
||||
| **Abkühlen** | Wartet, bis Bett und Düse unter der Grenze sind. PLA löst sich oft von selbst. |
|
||||
| **Schieben** | Extruder schiebt das Teil von hinten nach vorn in die Kiste. |
|
||||
| **Tasten** | LeviQ-Netz tippt; Maß aus `Result is z=`. Über 1 mm stoppt den Ablauf. |
|
||||
| **Tasten** | LeviQ-Netz tippt; Maß aus `Result is z=`. Über 3 mm stoppt den Ablauf (Default; enger geht unter Einstellungen/Ablauf). |
|
||||
|
||||
Aktueller Schritt: auf der Druckerkarte und in der Sidebar. Abbrechen an beiden Stellen. Jeder Schritt landet unter **Aktivität**.
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Wartung
|
||||
|
||||
Stand **18.10.2026**. Druckstunden, Filament-Meter und Verschleißteile je Drucker.
|
||||
|
||||
## Seite Wartung
|
||||
|
||||
Unter **Wartung** siehst du je Drucker die Summe aus dem Protokoll (Stunden, Meter, Starts). Teile mit Soll-Lebensdauer (Stunden oder Meter) und Einbaudatum; bei Reset wird der Zählerstand protokolliert.
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Einstellungen](Anleitung-Einstellungen.md)
|
||||
- [Statistik](Anleitung-Oberflaeche.md)
|
||||
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 219 KiB |
@@ -0,0 +1,45 @@
|
||||
# Grotti — Community-Vorstellung
|
||||
|
||||
Screenshots aus dem laufenden Betrieb (v3.2.0, Okt. 2026). Zielgruppe: mittlere bis große Printfarmen.
|
||||
|
||||
---
|
||||
|
||||
## Kurztext (zum Posten)
|
||||
|
||||
**Grotti** ist ein Farm-Manager für Moonraker/Klipper-Drucker — gebaut für den Alltag mit mehreren Maschinen, nicht für den Einzelplatz.
|
||||
|
||||
Statt zehn Tabs und Excel: eine Wand, eine Warteschlange, Material und Abräumen an einem Ort. Orca/AnycubicSlicerNext schicken den G-code per Nachbearbeitungsskript rein; Grotti wählt den nächsten freien Drucker (inkl. ACE-Farbe/Rest), startet im Zeitfenster und meldet, wenn die Platte geräumt werden muss oder Spule fehlt.
|
||||
|
||||
**Was schon geht**
|
||||
|
||||
- **Druckerwand + Cockpit** — Live-Status, Temps, Pause/Cancel/E-Stop, Farm-ETA („Alles fertig um …“)
|
||||
- **Jetzt-Liste** — Platte räumen, Materialbedarf, Wartung, überfällige Aufträge als To-do oben
|
||||
- **Warteschlange** — Priorität, Termine, Auto-Retry, Routing „nächster freier“ mit Material-Match
|
||||
- **Kobra-3-Ablauf** — Druck → Abkühlen → Schieben → Tasten (unbeaufsichtigte Serie)
|
||||
- **Material** — Spulen am Drucker/Regal, Chargen, Restgewicht, Fehlmenge aus der Queue
|
||||
- **Aufträge** — Kunden, Positionen, Einreihen, Lieferschein, Stück-Fortschritt aus dem Abräumen
|
||||
- **Kosten & Statistik** — Material + Strom (auch HT/NT), €/Teil, Yield, Ausschussgründe
|
||||
- **Wartung** — Laufstunden/Filamentmeter, Verschleißteile mit Intervallen
|
||||
- **Betrieb** — Auth (Session/API-Token), Telegram/ntfy, PWA/Regal-Touch, Ctrl+K-Suche
|
||||
|
||||
Stack: Python, PostgreSQL, Moonraker. UI auf Deutsch. Self-hosted.
|
||||
|
||||
Fragen und Feedback willkommen.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Datei | Inhalt |
|
||||
| --- | --- |
|
||||
| [01-druckerwand.png](01-druckerwand.png) | Farm-Übersicht, gruppiert nach Modell |
|
||||
| [06-jetzt.png](06-jetzt.png) | Jetzt-Popover: Abräumen + Materialbedarf |
|
||||
| [07-cockpit.png](07-cockpit.png) | Einzeldrucker mit Job, Spule, Steuerung |
|
||||
| [02-warteschlange.png](02-warteschlange.png) | Queue mit Status und Fehlmenge |
|
||||
| [09-dateien.png](09-dateien.png) | Bibliothek + Senden an mehrere Drucker |
|
||||
| [03-material.png](03-material.png) | Spulenbestand und Bedarf aus der Queue |
|
||||
| [04-auftraege.png](04-auftraege.png) | Kundenaufträge und Positionen |
|
||||
| [05-statistik.png](05-statistik.png) | KPIs: Läufe, Kosten, Erfolg, Schätztreue |
|
||||
| [08-wartung.png](08-wartung.png) | Laufzeit und Filament je Maschine |
|
||||
|
||||
Empfohlene Reihenfolge im Post: Wand → Jetzt → Warteschlange → Material → Statistik.
|
||||
@@ -6,7 +6,7 @@ Nach dem Druck kann Grotti auf der **Kobra 3** Bett und Düse abkühlen und die
|
||||
|
||||
## Quittierung vs. Schieben
|
||||
|
||||
- **Wand „Platte geräumt“:** Nur Quittierung. Tabelle `bed_status`, API `POST /api/v1/drucker/<host>/abraeumen` mit `confirm`. Fährt keine Bahn. `needs_clear` setzt der Watcher bei Druckende/Abbruch/Fehler (nicht schon beim Start). Solange gesetzt, startet „wenn frei“ den nächsten Job nicht.
|
||||
- **Wand „Platte geräumt“:** Nur Quittierung. Tabelle `bed_status`, API `POST /api/v1/drucker/<host>/abraeumen` mit `confirm`. Fährt keine Bahn. `needs_clear` setzt der Watcher beim Live-Übergang Druckende/Abbruch/Fehler (nicht schon beim Start; nicht blind beim ersten Blick nach Neustart). Solange gesetzt, startet „wenn frei“ den nächsten Job nicht.
|
||||
- **Ablauf (Dateien):** Drucken → Abkühlen → Schieben → Tasten, dann der nächste Druck mit LeviQ. Eine Bestätigung beim Einreihen; Schieben läuft ohne zweite Rückfrage. Nach erfolgreichem Schieben ist die Platte automatisch quittiert.
|
||||
- **Normale Drucke** und „Nochmal“ schieben nicht — nach dem Druck muss man auf der Wand quittieren.
|
||||
|
||||
@@ -17,7 +17,7 @@ Unter **Dateien** erscheint die Karte **Ablauf**, sobald mindestens eine Kobra 3
|
||||
- **Drucken** — Datei, Bettnetz (LeviQ) standardmäßig an
|
||||
- **Abkühlen** — Heizung aus, Lüfter an, Düse in die Bettmitte, warten bis Bett/Düse unter den Grenzen
|
||||
- **Schieben** — Bahn mit einstellbaren Zahlen, danach Lüfter aus
|
||||
- **Tasten** — kein `G28 Z`. **Mit aktivem LeviQ-Netz** (kein `BED_MESH_CLEAR`): `Result` = Höhe über der lokalen Platte. Bezugspunkt + Tastpunkt (x/y) einstellbar. Maß: letztes `Result is z=` aus dem Store. Über 1 mm = Restteil.
|
||||
- **Tasten** — kein `G28 Z`. **Mit aktivem LeviQ-Netz** (kein `BED_MESH_CLEAR`): `Result` = Höhe über der lokalen Platte. Bezugspunkt + Tastpunkt (x/y) einstellbar. Maß: letztes `Result is z=` aus dem Store. Über `max_delta_mm` (Default 3 mm) = Restteil. Enge 1 mm/2,5 mm-Werte aus alten Abläufen werden auf 3 mm angehoben (kalt an der Düse sonst Fehlalarm).
|
||||
|
||||
Darunter **wie oft hintereinander**. Einreihen legt einen Eintrag in die Warteschlange „Wenn frei“. **Schieben testen** fährt die Bahn auf einer freien Kobra 3 (Bestätigung „Bett ist leer“). Werte merkt sich der Browser lokal.
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ Etikett: `/etikett/<id>`, Druck über den Browser. QR per lokalem `qrcode.min.js
|
||||
|
||||
## Druck-Chargen
|
||||
|
||||
`D-YYYYMMDD-0001` vergibt der Watcher am aktiven Lauf (History-Job, sonst Live). `adopt_live_run` übernimmt die Nummer. Objekt-Etiketten `D-…-01` sobald Objektnamen da sind.
|
||||
`D-YYYYMMDD-0001` vergibt der Watcher am aktiven Lauf (History-Job, sonst Live). Die nächste Nummer zählt Läufe und schon vergebene Objekt-Etiketten, damit eine Charge nicht doppelt entsteht. `adopt_live_run` übernimmt die Nummer nur auf einen fertigen History-Lauf ohne eigene Charge. Objekt-Etiketten (`D-…-01`) wandern nur mit, wenn der Ziel-Lauf dieselbe Charge behält; sonst fallen sie mit dem Live-Lauf weg und werden unter der verbleibenden Charge neu vergeben.
|
||||
|
||||
Druck über `/etikett/lauf/<id>?mode=druck|objekt`. Auf dem Bogen: Charge, **Zielgewicht** (Slicer-Soll), Drucker, Material (Abzug oder Send/ACE/Lauf-Spule), QR 20 mm im 70‑mm-Bogen. Wand: Knopf **Etikett** bei Druck/Pause. Urteil Ausschuss ändert die Charge nicht — gedruckte Bögen verwerfen.
|
||||
Druck über `/etikett/lauf/<id>?mode=druck|objekt`. Auf dem Bogen: Charge, **Soll … g** (Slicer-Filamentmenge), Drucker, Material (Abzug oder Send/ACE/Lauf-Spule), QR 20 mm im 70‑mm-Bogen. Wand: Knopf **Etikett** bei Druck/Pause. Urteil Ausschuss ändert die Charge nicht — gedruckte Bögen verwerfen.
|
||||
|
||||
## 3D-Pfad
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Grotti-Dateien Metadaten
|
||||
|
||||
`stored_path` liegt nur in der DB und in Rohzeilen (`get_file_row`, `list_file_rows`). Die API-Antwort (`file_out` / `GET /api/v1/dateien`) enthält den Pfad bewusst nicht.
|
||||
`stored_path` liegt nur in der DB und in Rohzeilen (`get_file_row`, `list_file_rows`). Die API-Antwort (`file_out` / `GET /api/v1/dateien`) enthält den Pfad bewusst nicht — stattdessen berechnet `folder` den Relativ-Elternordner unter `watch_dir` (Uploads → `Upload`).
|
||||
|
||||
Verschieben einer Watch-Datei: Dateisystem unter `watch_dir` + `UPDATE files.stored_path` über dieselbe ID (`library.move_file`), damit Projekt/Spule erhalten bleiben. Reiner Re-Scan nach manuellem Verschieben würde die ID verlieren.
|
||||
|
||||
`POST /api/v1/dateien/neu-lesen` liest Watch-Ordner neu und ruft `refresh_file_meta` auf Rohzeilen auf — nicht auf `list_files`, sonst `KeyError: 'stored_path'`.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Stand **17.10.2026**.
|
||||
- `runs`: zusätzlich `weight_planned_g`, `weight_used_g`, `weight_source`, `filament_used_mm`/`filament_total_mm`, `progress_last`, `estimated_s`, `chargen_nr`, `message`, `objects`
|
||||
- `run_spools`: Abzug je Spule (`is_estimate` bei Mehrfarb)
|
||||
- `run_segments`: Abschnitte bei Rollenwechsel (Einlegen während Druck/Pause)
|
||||
- `run_samples`: Temperatur/Fortschritt ca. alle 30 s (Watcher)
|
||||
- `run_samples`: Temperatur/Soll/ED (`nozzle_power`/`bed_power`) — alle ~30 s, beim Aufheizen ~10 s; Proben schon vor `printing`, wenn Targets gesetzt und Auftrag/Live-Lauf bekannt
|
||||
- `run_labels`: Objekt-Chargen `D-…-01`
|
||||
- `events.run_id`: optionaler Bezug zum Lauf
|
||||
|
||||
@@ -19,7 +19,7 @@ Stand **17.10.2026**.
|
||||
|
||||
## Live
|
||||
|
||||
`StateWatcher` hängt am offenen History-Job oder legt `live:…` an, schreibt Proben, vergibt `chargen_nr`, merkt Objektnamen. Vor neuem Start: `abandon_live_runs`. `sync_history` übernimmt Proben/Segmente/Charge (`adopt_live_run`, nicht auf ältere fertige Jobs derselben Datei) und setzt bei Bedarf `needs_clear`.
|
||||
`StateWatcher` hängt am offenen History-Job oder legt `live:…` an, schreibt Proben, vergibt `chargen_nr`, merkt Objektnamen. Vor neuem Start: `abandon_live_runs`. `sync_history` übernimmt Proben/Segmente (`adopt_live_run`, nicht auf ältere fertige Jobs derselben Datei). Die Live-Charge geht nur auf einen fertigen Lauf ohne Nummer über; Objekt-Etiketten nur zusammen mit dieser Charge. Sonst werden sie neu vergeben, statt eine fremde `D-…-01` zu blockieren.
|
||||
|
||||
## Statistik-Filter
|
||||
|
||||
|
||||
@@ -1,60 +1,40 @@
|
||||
# Grotti: Alltag und Oberfläche
|
||||
|
||||
Stand **14.10.2026**, v1.4.0.
|
||||
Stand **18.10.2026**, v3.2.0.
|
||||
|
||||
## Design
|
||||
|
||||
- Dunkles UI mit Orange-Akzent, Lucide-ähnliche Stroke-Icons (SVG-Sprite in `layout.html`).
|
||||
- Kurze Seitenuntertitel; Technik-/Config-Hinweise liegen in der Doku, nicht als Fließtext in den Formularen.
|
||||
- Einheitliche Seitenköpfe, Toolbar-Filter, kompakte Tabellen, Formularfelder mit kleinen Labels, Empty-States mit Icon.
|
||||
- **Anzeige pro Browser** (Einstellungen → Dieser Browser): `Desktop` oder `Regal (Touch)` in `localStorage grotti-display`. Shortcut `?shelf=1` / `?shelf=0`. Regal: größere Controls, kompakte Nav (Drucker/Kameras/Warteschlange), Sidebar default eingeklappt.
|
||||
- Seitenmenü einklappbar (Icon-Rail, `grotti-nav-collapsed`). Unter 760 px weiter Bottom-Nav.
|
||||
- Dunkles UI mit Orange-Akzent; Light-Theme über dieselben Tokens (`html[data-theme]`).
|
||||
- Schrift **Inter** (variable, lokal unter `web/static/fonts/`).
|
||||
- Icons: **Lucide** als Sprite `web/static/icons.svg` (bauen mit `python scripts/build_icons.py`), IDs `i-<name>`.
|
||||
- Komponenten: Toolbar, Card-Head, KPI-Karten, Chips (Zustände + Queue + Urteile), Dialog-Kopf/Fuß, Empty-States, Skeleton.
|
||||
- Primär: `btn-primary` / `go-btn`; Danger: `btn-danger` / `warn`; Icon-Buttons mit `title`/`aria-label`.
|
||||
- **Anzeige pro Browser**: Desktop oder Regal (Touch); Zeitzone; Nav einklappbar.
|
||||
- PWA: `manifest.webmanifest` + Service-Worker (Shell-Cache inkl. Icons/Font).
|
||||
- Live: SSE `/api/v1/stream` plus Poll-Fallback; Verbindungsbanner.
|
||||
- Ctrl+K: globale Suche (`/api/v1/suche`); Knopf in der Topbar.
|
||||
- Kamera: Snapshot (gecacht) oder MJPEG-Stream (`/api/v1/kamera/stream`), Browser-Einstellung `grotti-camera`.
|
||||
|
||||
## Seitenleiste
|
||||
## Seitenleiste und Topbar
|
||||
|
||||
- Hauptmenü: Drucker, Dateien, Kameras, Aktivität, Warteschlange, Protokoll, Material, Projekte. Darunter Einstellungen, Ideen, Doku (mit Icons); Fuß **© by dominic@gundel.dev** und Version (Changelog).
|
||||
- Live-Leiste, eigene Scrollfläche, Boxen auf- und zuklappbar (Zustand in `localStorage` `grotti-live-closed`):
|
||||
- **Läuft jetzt**: laufende Aufträge und Abläufe mit Schrittleiste, Durchlauf `n/m`, aktueller Phase und ×-Abbrechen; auch Direktstarts ohne Queue.
|
||||
- **Wartet**: wartende Aufträge mit Ziel, Priorität hoch/runter, Abbrechen; die letzten Fehler mit Fortsetzen/ausblenden.
|
||||
- **Aufgaben**: Platte belegt ohne Ablauf — „geräumt“ per Zwei-Klick.
|
||||
- **Senden**: laufende Uploads mit Fortschritt.
|
||||
- **Alerts**: ungelesene wichtige Ereignisse, „gelesen“, Link auf Aktivität.
|
||||
- Aktualisierung: Senden 2 s, Queue 4 s, Aufgaben/Alerts 8 s.
|
||||
- Sidebar: Abschnittslabels **Live** / **Rückschau** / **Verwalten** (ohne Live-Panel).
|
||||
- Topbar-Live-Chips (**Jetzt** · Läufe · Senden · Meldungen) mit Popover; Farm-ETA nur bei mehreren Läufen.
|
||||
- Mobile: Tab **Jetzt** (`/jetzt`) statt Aktivität in der unteren Leiste; Aktivität unter Mehr.
|
||||
- Topbar: Suche, Einstellungen, Doku, Benutzer-Menü (Avatar → Abmelden, Version, Copyright).
|
||||
- Mobile: Bottom-Nav Drucker · Dateien · Warteschlange · Aktivität · **Mehr** (Sheet inkl. Abmelden); Topbar ausgeblendet, Suche als Icon in der Brand-Zeile.
|
||||
|
||||
## Wand
|
||||
## Wand und Abräumen
|
||||
|
||||
- Karte je Drucker: Zustandschip, Fertig-Uhrzeit groß, Fortschrittsbalken, drei Kacheln (Rest, Schicht, Läuft), Temperaturbalken Düse/Bett mit Ziel, Datei. Klick auf die Karte öffnet das Cockpit. Pause, Abbrechen, Fehler löschen und Platte geräumt bleiben auf der Karte (Zwei-Klick).
|
||||
- Laufender Ablauf als Streifen mit Schrittleiste und Abbrechen. Wartende Aufträge für diesen Drucker als Hinweis.
|
||||
- Streifen „Fehler“ mit **Fehler löschen** und „Platte belegt“ mit **Platte geräumt** (während eines Ablaufs ausgeblendet, der räumt selbst).
|
||||
- **Cockpit** (Hash `/#<host>`): Kamera (Frame-Vorladen; zugeklappt oder ohne Kamera → eine Spalte, kein leeres 16:10), Status, Temps inkl. Bauraum wenn vorhanden, Lüfter-Icons, Steuerung, 3D-Pfad (Exclude-Object), Bettnetz. E-Stop nur bei printing/paused/heating, Zwei-Klick. Live vom Watcher.
|
||||
- Karten + Cockpit; Technik-Fold mit Icons je Abschnitt.
|
||||
- **Platte geräumt** öffnet Dialog: gut/Ausschuss, Grund, Stückzahlen.
|
||||
|
||||
## Dateien und Senden
|
||||
## Aufträge und Wartung
|
||||
|
||||
- Links Bibliothek: Suche, Filter Maschine/Projekt, Vorschaubild; Zeile zeigt Material, Zeit, Gewicht, Schichthöhe, Maschine, Projekt; Details per Info-Popup. Metadaten aus G-code-Kopf **und** Footer. Hochladen / Ordner neu lesen.
|
||||
- Rechts der Flow: Datei → Drucker (Live-Zustand, eingelegte Rolle) → Wann (Jetzt / Wenn frei / Nächster freier) oder Reiter Ablauf.
|
||||
- Kein freies Spulen-Dropdown: Abzug nimmt die Rolle(n) am Ziel-Drucker zur Startzeit. ACE: Zuordnung Datei-Farbe → Platz. Warnung bei Material-Mismatch oder zu wenig Rest.
|
||||
- Startknopf mit Zwei-Klick bei „Jetzt“. Auswahl in `localStorage` `grotti-send-v1`.
|
||||
- `/auftraege`: Kunden, Positionen, Einreihen, Lieferschein. Status als Chip.
|
||||
- `/wartung`: Stunden/Meter und Verschleißteile.
|
||||
|
||||
## Material
|
||||
## Dateien, Material, Statistik
|
||||
|
||||
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf (`S-…` Spule, `D-…` Druckbericht).
|
||||
- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal. Akte mit Verbrauch und Leer-in.
|
||||
- Sorten: Profile (Dichte, mm³/s, Preis, Maschinen). Anlegen/ändern/löschen; „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
||||
|
||||
## Protokoll und Statistik
|
||||
|
||||
- Protokoll-Zeile → Druckbericht `/protokoll/<id>` (Soll/Ist, Spulen, Temperatur, Charge, Etiketten). Charge schon am laufenden Druck; Etikett an der Wand und im Bericht.
|
||||
- Statistik `/statistik`: KPIs, nach Drucker/Material, Zeitraum-Presets, CSV mit denselben Filtern.
|
||||
|
||||
## Warteschlange
|
||||
|
||||
- Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen.
|
||||
- Unter **Läuft** / **Alle aktiv** auch Direktstarts aus `/laeuft` (ohne Queue-Zeile), mit Fortschritt und Link zur Wand.
|
||||
|
||||
## Aktivität
|
||||
|
||||
- Zeitstrahl nach Tagen. Filter in der URL. Ungelesene Alerts markiert.
|
||||
|
||||
## Kameras und Handy
|
||||
|
||||
- Kamerakacheln mit Live-Zustand (`live=1`). Snapshot-Cache 2 s. Unter 760 px: Schnellmenü Drucker / Dateien / Aktivität.
|
||||
- Bibliothek mit Toolbar/Suche; Senden-Schritte nummeriert.
|
||||
- Material: Spulen/Sorten-Tabs, Filter-Toolbar.
|
||||
- Statistik: KPI-Karten mit Icons.
|
||||
@@ -18,7 +18,7 @@ Ab 12.10.2026: Start legt Hintergrund-Aufträge an (`pending` → `running` →
|
||||
|
||||
## Slice-Ordner / Upload
|
||||
|
||||
`watch_dir` in `config.json` zeigt auf den Slice-Ordner der Bibliothek. Ohne Ordner geht Hochladen über die Dateien-Seite trotzdem.
|
||||
Slice-Ordner unter **Einstellungen** (persistiert als `watch_dir`). Ohne Ordner geht Hochladen über die Dateien-Seite trotzdem.
|
||||
|
||||
**Dateinamen:** Moonraker-Multipart nutzt nur noch einen ASCII-`filename` (`moonraker.safe_upload_name`). Früher standen Nicht-ASCII-Zeichen in `filename*=UTF-8''…`, während `filename=` Unterstriche bekam — gklib speicherte A, Start suchte B → **10115 not found**.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from cryptography.hazmat.primitives import padding
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
CRED_TTL = 300
|
||||
INFO_TTL = 10
|
||||
INFO_TTL = 45
|
||||
FAIL_TTL = 45
|
||||
_CRED_CACHE = {}
|
||||
_INFO_CACHE = {}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Datenbank- und Datenordner-Backup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def backup_dir(cfg):
|
||||
base = Path(cfg.get("data_dir") or "data")
|
||||
path = base / "backup"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _safe_name(name):
|
||||
name = re.sub(r"[^\w.\-]+", "_", name or "")
|
||||
return name[:180] or "backup.zip"
|
||||
|
||||
|
||||
def create_backup(cfg):
|
||||
"""ZIP mit pg_dump (stdout) und data/ ohne toolpath/ und backup/."""
|
||||
data_dir = Path(cfg.get("data_dir") or "data")
|
||||
out_dir = backup_dir(cfg)
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
filename = _safe_name("grotti-%s.zip" % stamp)
|
||||
dest = out_dir / filename
|
||||
|
||||
url = cfg.get("database_url") or ""
|
||||
parsed = urlparse(url)
|
||||
db_name = (parsed.path or "/grotti").lstrip("/") or "grotti"
|
||||
env = {}
|
||||
if parsed.password:
|
||||
env["PGPASSWORD"] = parsed.password
|
||||
|
||||
cmd = ["pg_dump", "--no-owner", "--no-acl", db_name]
|
||||
if parsed.hostname:
|
||||
cmd[1:1] = ["-h", parsed.hostname]
|
||||
if parsed.port:
|
||||
cmd[1:1] = ["-p", str(parsed.port)]
|
||||
if parsed.username:
|
||||
cmd[1:1] = ["-U", parsed.username]
|
||||
|
||||
proc = subprocess.run(cmd, capture_output=True, env={**os.environ, **env})
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or b"").decode("utf-8", "replace").strip()
|
||||
raise RuntimeError(err or "pg_dump fehlgeschlagen.")
|
||||
|
||||
skip_dirs = {"toolpath", "backup"}
|
||||
with zipfile.ZipFile(dest, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("database.sql", proc.stdout)
|
||||
if data_dir.is_dir():
|
||||
for path in sorted(data_dir.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(data_dir)
|
||||
if rel.parts and rel.parts[0] in skip_dirs:
|
||||
continue
|
||||
zf.write(path, "data/" + rel.as_posix())
|
||||
return {
|
||||
"name": filename,
|
||||
"path": str(dest),
|
||||
"size": dest.stat().st_size,
|
||||
}
|
||||
|
||||
|
||||
def list_backups(cfg):
|
||||
out_dir = backup_dir(cfg)
|
||||
items = []
|
||||
for path in sorted(out_dir.glob("*.zip"), reverse=True):
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
size = 0
|
||||
items.append({"name": path.name, "size": size})
|
||||
return items
|
||||
|
||||
|
||||
def backup_path(cfg, name):
|
||||
out_dir = backup_dir(cfg)
|
||||
safe = _safe_name(name)
|
||||
path = (out_dir / safe).resolve()
|
||||
if not str(path).startswith(str(out_dir.resolve())):
|
||||
return None
|
||||
if not path.is_file():
|
||||
return None
|
||||
return path
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Filament-Bedarf aus wartender Queue vs. Spulenrest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _group_key(material_type, color):
|
||||
return (
|
||||
(material_type or "").strip().lower(),
|
||||
(color or "").strip().lower(),
|
||||
)
|
||||
|
||||
|
||||
def compute(conn):
|
||||
"""Wartende Jobs × Dateigewicht vs. Spulenrest, gruppiert nach Sorte/Farbe."""
|
||||
queue_rows = conn.execute(
|
||||
"""
|
||||
SELECT q.id, q.spool_id, q.repeat_total, q.repeat_done,
|
||||
f.weight_g, f.filaments,
|
||||
s.material_type AS spool_material, s.color AS spool_color
|
||||
FROM print_queue q
|
||||
JOIN files f ON f.id = q.file_id
|
||||
LEFT JOIN spools s ON s.id = q.spool_id
|
||||
WHERE q.status = 'waiting'
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
needed_map = {}
|
||||
for row in queue_rows:
|
||||
filaments = row.get("filaments") or []
|
||||
first = filaments[0] if isinstance(filaments, list) and filaments else {}
|
||||
if not isinstance(first, dict):
|
||||
first = {}
|
||||
mat = (row.get("spool_material") or first.get("type") or "").strip()
|
||||
color = (row.get("spool_color") or first.get("color") or "").strip()
|
||||
key = _group_key(mat, color)
|
||||
try:
|
||||
repeats = max(1, int(row.get("repeat_total") or 1) - int(row.get("repeat_done") or 0))
|
||||
except (TypeError, ValueError):
|
||||
repeats = 1
|
||||
weight = float(row.get("weight_g") or 0) * repeats
|
||||
if key not in needed_map:
|
||||
needed_map[key] = {
|
||||
"material_type": mat,
|
||||
"color": color,
|
||||
"weight_g": 0.0,
|
||||
"jobs": 0,
|
||||
}
|
||||
needed_map[key]["weight_g"] += weight
|
||||
needed_map[key]["jobs"] += 1
|
||||
|
||||
spool_rows = conn.execute(
|
||||
"""
|
||||
SELECT material_type, color, COALESCE(SUM(remaining_g), 0) AS remaining_g
|
||||
FROM spools
|
||||
GROUP BY material_type, color
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
stock_map = {}
|
||||
for row in spool_rows:
|
||||
key = _group_key(row.get("material_type"), row.get("color"))
|
||||
stock_map[key] = stock_map.get(key, 0.0) + float(row.get("remaining_g") or 0)
|
||||
|
||||
needed = sorted(
|
||||
needed_map.values(),
|
||||
key=lambda x: (-x["weight_g"], x["material_type"], x["color"]),
|
||||
)
|
||||
shortfall = []
|
||||
for item in needed:
|
||||
key = _group_key(item["material_type"], item["color"])
|
||||
have = stock_map.get(key, 0.0)
|
||||
gap = item["weight_g"] - have
|
||||
if gap > 0.5:
|
||||
shortfall.append({
|
||||
**item,
|
||||
"remaining_g": have,
|
||||
"shortfall_g": gap,
|
||||
})
|
||||
shortfall.sort(key=lambda x: (-x["shortfall_g"], x["material_type"], x["color"]))
|
||||
return {"needed": needed, "shortfall": shortfall}
|
||||
@@ -136,7 +136,7 @@ MATERIALS = [
|
||||
{
|
||||
"name": "Sunlu PA12-CF",
|
||||
"brand": "Sunlu",
|
||||
"material_type": "PA",
|
||||
"material_type": "PA12-CF",
|
||||
"density": 1.08,
|
||||
"volumetric": 8,
|
||||
"volumetric_s1": 8,
|
||||
@@ -157,6 +157,14 @@ MATERIALS = [
|
||||
},
|
||||
]
|
||||
|
||||
def ace_material_type(material_type):
|
||||
"""Typ für den ACE. PA12-CF gibt es dort nicht, der Platz heißt PA6-CF."""
|
||||
text = (material_type or "").strip()
|
||||
if text.lower() == "pa12-cf":
|
||||
return "PA6-CF"
|
||||
return text
|
||||
|
||||
|
||||
SOLIDS = [
|
||||
{"id": "schwarz", "name": "Schwarz", "hex": "#1c1c1c"},
|
||||
{"id": "weiss", "name": "Weiß", "hex": "#f4f4f4"},
|
||||
|
||||
@@ -2,23 +2,61 @@
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from grotti.errors import GrottiError
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
|
||||
CONFIG_PATH = ROOT / "config.json"
|
||||
DEFAULT_WATCH_REL = "slice"
|
||||
_SETTINGS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _tariff(raw):
|
||||
tariff = raw.get("tariff") if isinstance(raw, dict) else None
|
||||
if not isinstance(tariff, dict) or not tariff:
|
||||
return None
|
||||
try:
|
||||
ht = float(tariff.get("ht_euro"))
|
||||
nt = float(tariff.get("nt_euro"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return {
|
||||
"ht_euro": ht,
|
||||
"nt_euro": nt,
|
||||
"nt_from": str(tariff.get("nt_from") or "22:00").strip(),
|
||||
"nt_to": str(tariff.get("nt_to") or "06:00").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _energy(raw):
|
||||
energy = raw.get("energy") or {}
|
||||
return {
|
||||
"watts": float(energy.get("watts", 120)),
|
||||
out = {
|
||||
"watts": float(energy.get("watts", 40)),
|
||||
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
|
||||
"bed_watts": float(energy.get("bed_watts", 220)),
|
||||
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
|
||||
"dryer_watts": float(energy.get("dryer_watts", 0)),
|
||||
"dry_hours": float(energy.get("dry_hours", 0)),
|
||||
"waste_percent": float(energy.get("waste_percent", 0)),
|
||||
"utilization": float(energy.get("utilization", 50)),
|
||||
}
|
||||
tariff = _tariff(energy)
|
||||
if tariff:
|
||||
out["tariff"] = tariff
|
||||
return out
|
||||
|
||||
|
||||
def _ntfy(raw):
|
||||
ntfy = raw.get("ntfy") or {}
|
||||
return {
|
||||
"enabled": bool(ntfy.get("enabled")),
|
||||
"server": str(ntfy.get("server") or "https://ntfy.sh").rstrip("/"),
|
||||
"topic": str(ntfy.get("topic") or ""),
|
||||
"token": str(ntfy.get("token") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _telegram(raw):
|
||||
@@ -37,6 +75,7 @@ def _alerts(raw):
|
||||
"fehler": bool(alerts.get("fehler", True)),
|
||||
"offline": bool(alerts.get("offline", True)),
|
||||
"queue": bool(alerts.get("queue", True)),
|
||||
"spule": bool(alerts.get("spule", True)),
|
||||
"ablauf": bool(alerts.get("ablauf", False)),
|
||||
"start": bool(alerts.get("start", False)),
|
||||
}
|
||||
@@ -99,10 +138,7 @@ def load():
|
||||
data_dir = Path(raw.get("data_dir") or "data")
|
||||
if not data_dir.is_absolute():
|
||||
data_dir = ROOT / data_dir
|
||||
watch = (raw.get("watch_dir") or "").strip()
|
||||
watch_dir = Path(watch) if watch else None
|
||||
if watch_dir and not watch_dir.is_absolute():
|
||||
watch_dir = (ROOT / watch_dir).resolve()
|
||||
watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
|
||||
return {
|
||||
"host": host,
|
||||
"port": int(raw.get("port") or 8770),
|
||||
@@ -116,8 +152,10 @@ def load():
|
||||
"printers_file": ROOT / "drucker.json",
|
||||
"energy": _energy(raw),
|
||||
"telegram": _telegram(raw),
|
||||
"ntfy": _ntfy(raw),
|
||||
"alerts": _alerts(raw),
|
||||
"auto_queue": bool(raw.get("auto_queue", True)),
|
||||
"auto_retry": bool(raw.get("auto_retry", True)),
|
||||
"_raw": raw,
|
||||
"_path": path,
|
||||
}
|
||||
@@ -130,12 +168,50 @@ def mask_token(token):
|
||||
return token[:4] + "…" + token[-2:]
|
||||
|
||||
|
||||
def parse_watch_dir(value, *, create=False, missing_ok=False):
|
||||
"""Pfad aus Einstellungen/Config → (Path|None, gespeicherter String).
|
||||
|
||||
Relativ zum Repo-Root. Leer = aus. create legt den Ordner an.
|
||||
"""
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None, ""
|
||||
path = Path(text)
|
||||
if not path.is_absolute():
|
||||
path = (ROOT / path).resolve()
|
||||
else:
|
||||
path = path.resolve()
|
||||
if path.exists() and not path.is_dir():
|
||||
raise GrottiError("Der Slice-Ordner ist keine Ordner.", "watch")
|
||||
if create:
|
||||
try:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise GrottiError("Slice-Ordner konnte nicht angelegt werden: %s" % exc, "watch") from exc
|
||||
elif not path.is_dir():
|
||||
if missing_ok:
|
||||
return None, text
|
||||
raise GrottiError("Slice-Ordner fehlt: %s" % path, "watch")
|
||||
try:
|
||||
stored = path.relative_to(ROOT.resolve()).as_posix()
|
||||
except ValueError:
|
||||
stored = str(path)
|
||||
return path, stored
|
||||
|
||||
|
||||
def default_watch_suggestion():
|
||||
"""Absoluter Standardpfad (Repo/slice) für die Einstellungen."""
|
||||
return str((ROOT / DEFAULT_WATCH_REL).resolve())
|
||||
|
||||
|
||||
def settings_public(cfg):
|
||||
tg = cfg.get("telegram") or {}
|
||||
oauth = cfg.get("oauth") or {}
|
||||
return {
|
||||
"watch_dir": str(cfg.get("watch_dir") or ""),
|
||||
"watch_dir_default": default_watch_suggestion(),
|
||||
"auto_queue": bool(cfg.get("auto_queue", True)),
|
||||
"auto_retry": bool(cfg.get("auto_retry", True)),
|
||||
"fqdn": str(cfg.get("fqdn") or ""),
|
||||
"legacy_token_set": bool(cfg.get("token")),
|
||||
"energy": dict(cfg.get("energy") or {}),
|
||||
@@ -145,6 +221,13 @@ def settings_public(cfg):
|
||||
"bot_token_set": bool(tg.get("bot_token")),
|
||||
"chat_id": str(tg.get("chat_id") or ""),
|
||||
},
|
||||
"ntfy": {
|
||||
"enabled": bool((cfg.get("ntfy") or {}).get("enabled")),
|
||||
"server": str((cfg.get("ntfy") or {}).get("server") or "https://ntfy.sh"),
|
||||
"topic": str((cfg.get("ntfy") or {}).get("topic") or ""),
|
||||
"token": mask_token((cfg.get("ntfy") or {}).get("token") or ""),
|
||||
"token_set": bool((cfg.get("ntfy") or {}).get("token")),
|
||||
},
|
||||
"alerts": dict(cfg.get("alerts") or {}),
|
||||
"oauth": {
|
||||
"enabled": bool(oauth.get("enabled")),
|
||||
@@ -155,8 +238,43 @@ def settings_public(cfg):
|
||||
}
|
||||
|
||||
|
||||
def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None):
|
||||
"""Schreibt energy/telegram/alerts/auto_queue/fqdn zurück nach config.json und aktualisiert cfg."""
|
||||
def save_settings(
|
||||
cfg,
|
||||
energy=None,
|
||||
telegram=None,
|
||||
ntfy=None,
|
||||
alerts=None,
|
||||
auto_queue=None,
|
||||
auto_retry=None,
|
||||
fqdn=None,
|
||||
watch_dir=None,
|
||||
):
|
||||
"""Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
|
||||
with _SETTINGS_LOCK:
|
||||
return _save_settings_locked(
|
||||
cfg,
|
||||
energy=energy,
|
||||
telegram=telegram,
|
||||
ntfy=ntfy,
|
||||
alerts=alerts,
|
||||
auto_queue=auto_queue,
|
||||
auto_retry=auto_retry,
|
||||
fqdn=fqdn,
|
||||
watch_dir=watch_dir,
|
||||
)
|
||||
|
||||
|
||||
def _save_settings_locked(
|
||||
cfg,
|
||||
energy=None,
|
||||
telegram=None,
|
||||
ntfy=None,
|
||||
alerts=None,
|
||||
auto_queue=None,
|
||||
auto_retry=None,
|
||||
fqdn=None,
|
||||
watch_dir=None,
|
||||
):
|
||||
path = cfg.get("_path") or CONFIG_PATH
|
||||
raw = dict(cfg.get("_raw") or {})
|
||||
if path.exists():
|
||||
@@ -167,19 +285,37 @@ def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None,
|
||||
if auto_queue is not None:
|
||||
raw["auto_queue"] = bool(auto_queue)
|
||||
cfg["auto_queue"] = bool(auto_queue)
|
||||
if auto_retry is not None:
|
||||
raw["auto_retry"] = bool(auto_retry)
|
||||
cfg["auto_retry"] = bool(auto_retry)
|
||||
if watch_dir is not None:
|
||||
resolved, stored = parse_watch_dir(watch_dir, create=True, missing_ok=False)
|
||||
raw["watch_dir"] = stored
|
||||
cfg["watch_dir"] = resolved
|
||||
if fqdn is not None:
|
||||
normalized = normalize_fqdn(fqdn)
|
||||
raw["fqdn"] = normalized
|
||||
cfg["fqdn"] = normalized
|
||||
if energy is not None:
|
||||
raw["energy"] = {
|
||||
"watts": float(energy.get("watts", 120)),
|
||||
raw_energy = {
|
||||
"watts": float(energy.get("watts", 40)),
|
||||
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
|
||||
"bed_watts": float(energy.get("bed_watts", 220)),
|
||||
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
|
||||
"dryer_watts": float(energy.get("dryer_watts", 0)),
|
||||
"dry_hours": float(energy.get("dry_hours", 0)),
|
||||
"waste_percent": float(energy.get("waste_percent", 0)),
|
||||
"utilization": float(energy.get("utilization", 50)),
|
||||
}
|
||||
tariff_in = energy.get("tariff") if isinstance(energy.get("tariff"), dict) else None
|
||||
if tariff_in:
|
||||
raw_energy["tariff"] = {
|
||||
"ht_euro": float(tariff_in.get("ht_euro", raw_energy["euro_per_kwh"])),
|
||||
"nt_euro": float(tariff_in.get("nt_euro", raw_energy["euro_per_kwh"])),
|
||||
"nt_from": str(tariff_in.get("nt_from") or "22:00"),
|
||||
"nt_to": str(tariff_in.get("nt_to") or "06:00"),
|
||||
}
|
||||
raw["energy"] = raw_energy
|
||||
cfg["energy"] = _energy(raw)
|
||||
if telegram is not None:
|
||||
current = _telegram(raw)
|
||||
@@ -192,12 +328,25 @@ def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None,
|
||||
"chat_id": str(telegram.get("chat_id", current["chat_id"]) or ""),
|
||||
}
|
||||
cfg["telegram"] = _telegram(raw)
|
||||
if ntfy is not None:
|
||||
current = _ntfy(raw)
|
||||
token = ntfy.get("token")
|
||||
if token is None or token == "" or "…" in str(token) or str(token).startswith("••••"):
|
||||
token = current["token"]
|
||||
raw["ntfy"] = {
|
||||
"enabled": bool(ntfy.get("enabled", current["enabled"])),
|
||||
"server": str(ntfy.get("server", current["server"]) or "https://ntfy.sh").rstrip("/"),
|
||||
"topic": str(ntfy.get("topic", current["topic"]) or ""),
|
||||
"token": str(token or ""),
|
||||
}
|
||||
cfg["ntfy"] = _ntfy(raw)
|
||||
if alerts is not None:
|
||||
raw["alerts"] = {
|
||||
"fertig": bool(alerts.get("fertig", True)),
|
||||
"fehler": bool(alerts.get("fehler", True)),
|
||||
"offline": bool(alerts.get("offline", True)),
|
||||
"queue": bool(alerts.get("queue", True)),
|
||||
"spule": bool(alerts.get("spule", True)),
|
||||
"ablauf": bool(alerts.get("ablauf", False)),
|
||||
"start": bool(alerts.get("start", False)),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"""Dieselbe Rechnung wie in der Druckkalkulation."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from grotti.time_window import _in_segment, _parse_hm
|
||||
|
||||
|
||||
def calculate(job, material):
|
||||
part_g = float(job.get("weight_g") or 0) * max(1, float(job.get("qty") or 1))
|
||||
@@ -37,6 +41,225 @@ def calculate(job, material):
|
||||
}
|
||||
|
||||
|
||||
def energy_cost(duration_s, energy):
|
||||
hours = float(duration_s or 0) / 3600
|
||||
return (float(energy["watts"]) / 1000) * hours * float(energy["euro_per_kwh"])
|
||||
def _parse_ts(value):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.timestamp()
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _avg_power(a, b):
|
||||
vals = [float(v) for v in (a, b) if isinstance(v, (int, float)) and not isinstance(v, bool)]
|
||||
if not vals:
|
||||
return 0.0
|
||||
return max(0.0, min(1.0, sum(vals) / len(vals)))
|
||||
|
||||
|
||||
def _ed_print_parts(samples, base_w, nozzle_w, bed_w):
|
||||
"""Trapez: Grundlast / Düse / Bett getrennt. None wenn zu wenig ED-Proben."""
|
||||
if not samples or len(samples) < 2:
|
||||
return None
|
||||
base_kwh = 0.0
|
||||
nozzle_kwh = 0.0
|
||||
bed_kwh = 0.0
|
||||
ed_steps = 0
|
||||
# Max. Intervall: 3× Normal-Sample (90 s), damit Lücken nicht die ED fortschreiben
|
||||
max_dt = 90.0
|
||||
for i in range(len(samples) - 1):
|
||||
a, b = samples[i], samples[i + 1]
|
||||
t0 = _parse_ts(a.get("sampled_at"))
|
||||
t1 = _parse_ts(b.get("sampled_at"))
|
||||
if t0 is None or t1 is None or t1 <= t0:
|
||||
continue
|
||||
dt = min(t1 - t0, max_dt)
|
||||
hours = dt / 3600.0
|
||||
pn0, pn1 = a.get("nozzle_power"), b.get("nozzle_power")
|
||||
pb0, pb1 = a.get("bed_power"), b.get("bed_power")
|
||||
has_ed = any(
|
||||
isinstance(v, (int, float)) and not isinstance(v, bool)
|
||||
for v in (pn0, pn1, pb0, pb1)
|
||||
)
|
||||
if has_ed:
|
||||
ed_steps += 1
|
||||
ed_n = _avg_power(pn0, pn1)
|
||||
ed_b = _avg_power(pb0, pb1)
|
||||
base_kwh += (float(base_w) / 1000.0) * hours
|
||||
nozzle_kwh += (float(nozzle_w) * ed_n / 1000.0) * hours
|
||||
bed_kwh += (float(bed_w) * ed_b / 1000.0) * hours
|
||||
if ed_steps < 1:
|
||||
return None
|
||||
return {
|
||||
"base_kwh": base_kwh,
|
||||
"nozzle_kwh": nozzle_kwh,
|
||||
"bed_kwh": bed_kwh,
|
||||
"print_kwh": base_kwh + nozzle_kwh + bed_kwh,
|
||||
}
|
||||
|
||||
|
||||
def _is_nt_moment(moment, tariff):
|
||||
start_t = _parse_hm(tariff.get("nt_from"))
|
||||
end_t = _parse_hm(tariff.get("nt_to"))
|
||||
if start_t is None or end_t is None:
|
||||
return False
|
||||
now_t = moment.timetz().replace(tzinfo=None)
|
||||
return _in_segment(now_t, start_t, end_t)
|
||||
|
||||
|
||||
def effective_euro_per_kwh(duration_s, energy, end_dt=None):
|
||||
energy = energy or {}
|
||||
tariff = energy.get("tariff")
|
||||
if not tariff or not duration_s or float(duration_s) <= 0:
|
||||
return float(energy.get("euro_per_kwh") or 0)
|
||||
end = end_dt if isinstance(end_dt, datetime) else datetime.now(timezone.utc).astimezone()
|
||||
if end.tzinfo is None:
|
||||
end = end.replace(tzinfo=timezone.utc)
|
||||
start = end - timedelta(seconds=float(duration_s))
|
||||
step = 300.0
|
||||
t = start
|
||||
nt_s = 0.0
|
||||
total = 0.0
|
||||
while t < end:
|
||||
chunk = min(step, (end - t).total_seconds())
|
||||
if _is_nt_moment(t, tariff):
|
||||
nt_s += chunk
|
||||
total += chunk
|
||||
t += timedelta(seconds=chunk)
|
||||
if total <= 0:
|
||||
return float(energy.get("euro_per_kwh") or 0)
|
||||
nt_frac = nt_s / total
|
||||
ht = float(tariff.get("ht_euro") or energy.get("euro_per_kwh") or 0)
|
||||
nt = float(tariff.get("nt_euro") or ht)
|
||||
return nt_frac * nt + (1.0 - nt_frac) * ht
|
||||
|
||||
|
||||
def _part(key, label, kwh, euro):
|
||||
kwh = float(kwh or 0)
|
||||
return {
|
||||
"key": key,
|
||||
"label": label,
|
||||
"kwh": kwh,
|
||||
"cost": kwh * float(euro or 0),
|
||||
}
|
||||
|
||||
|
||||
def energy_estimate(duration_s, energy, samples=None, end_dt=None):
|
||||
"""Strom aus ED-Proben oder pauschal Dauer × Watt.
|
||||
|
||||
Quelle ``ed`` nur wenn Düse-/Bett-Watt gesetzt und Proben mit power vorliegen.
|
||||
``parts`` listet Grundlast / Düse / Bett / Trockner (kWh + €).
|
||||
"""
|
||||
energy = energy or {}
|
||||
euro = effective_euro_per_kwh(duration_s, energy, end_dt=end_dt)
|
||||
dry_kwh = (float(energy.get("dryer_watts") or 0) / 1000) * float(energy.get("dry_hours") or 0)
|
||||
base_w = float(energy.get("watts") or 0)
|
||||
nozzle_w = float(energy.get("nozzle_watts") or 0)
|
||||
bed_w = float(energy.get("bed_watts") or 0)
|
||||
|
||||
parts_map = None
|
||||
source = "flat"
|
||||
if (nozzle_w > 0 or bed_w > 0) and samples:
|
||||
parts_map = _ed_print_parts(samples, base_w, nozzle_w, bed_w)
|
||||
if parts_map is not None:
|
||||
source = "ed"
|
||||
|
||||
if parts_map is None:
|
||||
hours = float(duration_s or 0) / 3600
|
||||
parts_map = {
|
||||
"base_kwh": (base_w / 1000) * hours,
|
||||
"nozzle_kwh": 0.0,
|
||||
"bed_kwh": 0.0,
|
||||
"print_kwh": (base_w / 1000) * hours,
|
||||
}
|
||||
source = "flat"
|
||||
|
||||
print_kwh = float(parts_map["print_kwh"])
|
||||
kwh = print_kwh + dry_kwh
|
||||
parts = [
|
||||
_part("base", "Grundlast", parts_map["base_kwh"], euro),
|
||||
_part("nozzle", "Düse", parts_map["nozzle_kwh"], euro),
|
||||
_part("bed", "Bett", parts_map["bed_kwh"], euro),
|
||||
_part("dryer", "Trockner", dry_kwh, euro),
|
||||
]
|
||||
parts = [p for p in parts if p["kwh"] > 1e-6]
|
||||
return {
|
||||
"kwh": kwh,
|
||||
"print_kwh": print_kwh,
|
||||
"dry_kwh": dry_kwh,
|
||||
"base_kwh": float(parts_map["base_kwh"]),
|
||||
"nozzle_kwh": float(parts_map["nozzle_kwh"]),
|
||||
"bed_kwh": float(parts_map["bed_kwh"]),
|
||||
"cost": kwh * euro,
|
||||
"euro_per_kwh": euro,
|
||||
"source": source,
|
||||
"parts": parts,
|
||||
}
|
||||
|
||||
|
||||
def energy_cost(duration_s, energy, samples=None, end_dt=None):
|
||||
return energy_estimate(duration_s, energy, samples, end_dt=end_dt)["cost"]
|
||||
|
||||
|
||||
def offer_from_file(file_row, material_price, machine_rate_per_h, setup_min, margin):
|
||||
"""Angebots-Schätzung aus Datei-Gewicht und Druckzeit."""
|
||||
weight_g = float((file_row or {}).get("weight_g") or 0)
|
||||
est_s = float((file_row or {}).get("estimated_s") or 0)
|
||||
hours = est_s / 3600.0 if est_s > 0 else 0.0
|
||||
mat = float(material_price or 0)
|
||||
rate = float(machine_rate_per_h or 0)
|
||||
setup_h = float(setup_min or 0) / 60.0
|
||||
material_cost = (weight_g / 1000.0) * mat
|
||||
machine_cost = hours * rate + setup_h * rate
|
||||
subtotal = material_cost + machine_cost
|
||||
pct = float(margin or 0)
|
||||
total = subtotal * (1.0 + pct / 100.0) if subtotal > 0 else 0.0
|
||||
return {
|
||||
"weight_g": weight_g,
|
||||
"hours": hours,
|
||||
"material_cost": material_cost,
|
||||
"machine_cost": machine_cost,
|
||||
"subtotal": subtotal,
|
||||
"margin_percent": pct,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
def order_actuals(conn, order_id):
|
||||
"""Summe Kosten, Zeit und Ausschuss der verknüpften Läufe."""
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost_total,
|
||||
COALESCE(SUM(r.duration_s), 0) AS duration_s,
|
||||
COALESCE(SUM(r.pieces_ok), 0) AS pieces_ok,
|
||||
COALESCE(SUM(r.pieces_bad), 0) AS pieces_bad,
|
||||
COALESCE(SUM(r.weight_g) FILTER (WHERE r.verdict = 'ausschuss'), 0) AS scrap_g,
|
||||
COUNT(*) FILTER (WHERE r.verdict = 'ausschuss') AS scrap_runs
|
||||
FROM order_items oi
|
||||
JOIN runs r ON r.order_item_id = oi.id
|
||||
WHERE oi.order_id = %s
|
||||
""",
|
||||
(order_id,),
|
||||
).fetchone()
|
||||
pieces_ok = int(row["pieces_ok"] or 0)
|
||||
cost_total = float(row["cost_total"] or 0)
|
||||
return {
|
||||
"cost_total": cost_total,
|
||||
"duration_s": float(row["duration_s"] or 0),
|
||||
"pieces_ok": pieces_ok,
|
||||
"pieces_bad": int(row["pieces_bad"] or 0),
|
||||
"scrap_g": float(row["scrap_g"] or 0),
|
||||
"scrap_runs": int(row["scrap_runs"] or 0),
|
||||
"cost_per_part": (cost_total / pieces_ok) if pieces_ok > 0 else None,
|
||||
"yield_percent": (
|
||||
100.0 * pieces_ok / (pieces_ok + int(row["pieces_bad"] or 0))
|
||||
if (pieces_ok + int(row["pieces_bad"] or 0)) > 0
|
||||
else None
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Tagesbericht und Push für Wartung/Bedarf/Termine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from grotti import bedarf as bedarf_mod
|
||||
from grotti import jetzt as jetzt_mod
|
||||
from grotti import notify
|
||||
from grotti import store
|
||||
from grotti.db import connect
|
||||
|
||||
|
||||
def _yesterday_stats(conn):
|
||||
today = date.today()
|
||||
yesterday = today - timedelta(days=1)
|
||||
start = datetime.combine(yesterday, datetime.min.time(), tzinfo=timezone.utc)
|
||||
end = datetime.combine(today, datetime.min.time(), tzinfo=timezone.utc)
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) AS runs,
|
||||
COALESCE(SUM(duration_s), 0) AS duration_s,
|
||||
COALESCE(SUM(weight_used_g), 0) AS grams,
|
||||
COALESCE(SUM(CASE WHEN verdict = 'ausschuss' THEN 1 ELSE 0 END), 0) AS scrap,
|
||||
COALESCE(SUM(pieces_ok), 0) AS pieces_ok,
|
||||
COALESCE(SUM(pieces_bad), 0) AS pieces_bad
|
||||
FROM runs
|
||||
WHERE started_at >= %s AND started_at < %s
|
||||
""",
|
||||
(start, end),
|
||||
).fetchone()
|
||||
return {
|
||||
"date": yesterday.isoformat(),
|
||||
"runs": int(row["runs"] or 0),
|
||||
"duration_s": float(row["duration_s"] or 0),
|
||||
"grams": float(row["grams"] or 0),
|
||||
"scrap": int(row["scrap"] or 0),
|
||||
"pieces_ok": int(row["pieces_ok"] or 0),
|
||||
"pieces_bad": int(row["pieces_bad"] or 0),
|
||||
}
|
||||
|
||||
|
||||
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"])
|
||||
eta = jetzt_mod.farm_eta([
|
||||
{"remain": (live or {}).get("remain") or 0}
|
||||
for live in (watcher_latest or [])
|
||||
if (live or {}).get("state") in ("printing", "paused", "heating")
|
||||
])
|
||||
lines = [
|
||||
"Grotti Tagesbericht %s" % stats["date"],
|
||||
"Läufe: %d · Druckzeit: %.1f h · Material: %.0f g" % (
|
||||
stats["runs"], stats["duration_s"] / 3600.0, stats["grams"],
|
||||
),
|
||||
"Ausschuss-Läufe: %d · Stück gut/schlecht: %d/%d" % (
|
||||
stats["scrap"], stats["pieces_ok"], stats["pieces_bad"],
|
||||
),
|
||||
"Heute in Warteschlange: %d" % waiting,
|
||||
]
|
||||
if eta.get("all_done_in_s"):
|
||||
at = datetime.now(timezone.utc) + timedelta(seconds=eta["all_done_in_s"])
|
||||
lines.append("Laufende Drucke fertig gegen %s Uhr" % at.astimezone().strftime("%H:%M"))
|
||||
overdue = jetzt_mod.overdue_orders(conn, limit=5)
|
||||
if overdue:
|
||||
lines.append("Überfällig: " + ", ".join(o["title"] for o in overdue))
|
||||
short = (bedarf_mod.compute(conn).get("shortfall") or [])[:3]
|
||||
if short:
|
||||
bits = []
|
||||
for item in short:
|
||||
label = " ".join(x for x in [item.get("material_type"), item.get("color")] if x) or "Filament"
|
||||
bits.append("%s (−%sg)" % (label, int(item.get("shortfall_g") or 0)))
|
||||
lines.append("Bedarf: " + ", ".join(bits))
|
||||
parts = jetzt_mod.overdue_maintenance(conn, printers or store.list_printers(conn), limit=5)
|
||||
if parts:
|
||||
lines.append("Wartung: " + ", ".join("%s/%s" % (p["printer"], p["label"]) for p in parts))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def push_ops_alerts(conn, cfg, printers=None):
|
||||
"""Einmalige Push-Hinweise für Bedarf, Wartung, Termine (mit Dedup über Events).
|
||||
|
||||
kind beim Speichern muss zum Dedup-Check passen — sonst jede Minute ein neuer Eintrag
|
||||
(DailyReportThread tickt alle 60 s).
|
||||
"""
|
||||
printers = printers or store.list_printers(conn)
|
||||
for order in jetzt_mod.overdue_orders(conn, limit=10):
|
||||
msg = "Auftrag überfällig: %s (fällig %s)" % (order["title"], order.get("due_at") or "")
|
||||
key = "order:%s" % order["id"]
|
||||
if not store.recent_event_kind(conn, key, "auftrag", within_s=86400):
|
||||
notify.record(conn, cfg, key, order["title"], "auftrag", msg, dedupe_s=86400)
|
||||
for part in jetzt_mod.overdue_maintenance(conn, printers, limit=10):
|
||||
msg = "Wartung fällig: %s · %s" % (part["printer"], part["label"])
|
||||
key = "maint:%s" % part["id"]
|
||||
if not store.recent_event_kind(conn, key, "wartung", within_s=86400):
|
||||
notify.record(conn, cfg, key, part["printer"], "wartung", msg, dedupe_s=86400)
|
||||
for item in (bedarf_mod.compute(conn).get("shortfall") or [])[:5]:
|
||||
label = " ".join(x for x in [item.get("material_type"), item.get("color")] if x) or "Filament"
|
||||
msg = "Bedarf: %s — %s g fehlen" % (label, int(item.get("shortfall_g") or 0))
|
||||
key = "bedarf:%s:%s" % (item.get("material_type") or "", item.get("color") or "")
|
||||
if not store.recent_event_kind(conn, key, "bedarf", within_s=21600):
|
||||
notify.record(conn, cfg, key, label, "bedarf", msg, dedupe_s=21600)
|
||||
|
||||
|
||||
class DailyReportThread:
|
||||
"""Sendet um 7:00 Ortszeit den Tagesbericht und prüft periodisch Ops-Alerts."""
|
||||
|
||||
def __init__(self, database_url, cfg, watcher=None):
|
||||
self.database_url = database_url
|
||||
self.cfg = cfg
|
||||
self.watcher = watcher
|
||||
self._stop = threading.Event()
|
||||
self._thread = None
|
||||
self._last_report_day = None
|
||||
|
||||
def start(self):
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._thread = threading.Thread(target=self._loop, name="grotti-daily", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
|
||||
def _loop(self):
|
||||
while not self._stop.wait(60):
|
||||
try:
|
||||
self._tick()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _tick(self):
|
||||
now = datetime.now().astimezone()
|
||||
with connect(self.database_url) as conn:
|
||||
push_ops_alerts(conn, self.cfg, store.list_printers(conn))
|
||||
conn.commit()
|
||||
if now.hour == 7 and now.minute < 2:
|
||||
day = now.date().isoformat()
|
||||
if self._last_report_day == day:
|
||||
return
|
||||
latest = (self.watcher.latest if self.watcher else None) or []
|
||||
text = build_text(conn, latest, store.list_printers(conn))
|
||||
notify.send_telegram(self.cfg, text)
|
||||
notify.send_ntfy(self.cfg, text)
|
||||
store.add_event(conn, "", "Grotti", "fertig", text.split("\n")[0])
|
||||
conn.commit()
|
||||
self._last_report_day = day
|
||||
@@ -1,6 +1,9 @@
|
||||
"""PostgreSQL-Verbindung und Schema."""
|
||||
"""PostgreSQL-Verbindung, Pool und Migrationen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
@@ -8,22 +11,105 @@ from urllib.parse import urlparse, urlunparse
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
SCHEMA = (Path(__file__).parent / "schema.sql").read_text(encoding="utf-8")
|
||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
|
||||
|
||||
_pool: ConnectionPool | None = None
|
||||
_pool_lock = threading.Lock()
|
||||
_user_count_cache = {"n": None, "at": 0.0}
|
||||
_USER_COUNT_TTL = 5.0
|
||||
|
||||
|
||||
def database_name(url):
|
||||
name = urlparse(url).path.lstrip("/")
|
||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name or ""):
|
||||
raise SystemExit("Der Datenbankname in database_url darf nur Buchstaben, Zahlen und Unterstrich enthalten.")
|
||||
raise SystemExit(
|
||||
"Der Datenbankname in database_url darf nur Buchstaben, Zahlen und Unterstrich enthalten."
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def apply_schema(conn):
|
||||
for statement in SCHEMA.split(";"):
|
||||
def _migration_files():
|
||||
files = sorted(MIGRATIONS_DIR.glob("*.sql"))
|
||||
out = []
|
||||
for path in files:
|
||||
match = re.match(r"^(\d+)_", path.name)
|
||||
if not match:
|
||||
continue
|
||||
out.append((int(match.group(1)), path))
|
||||
return out
|
||||
|
||||
|
||||
def _split_statements(text):
|
||||
"""Einfache Trennung an Semikolon — Migrationen ohne Funktionen mit ; im Körper."""
|
||||
parts = []
|
||||
for statement in text.split(";"):
|
||||
cleaned = statement.strip()
|
||||
if cleaned:
|
||||
conn.execute(cleaned)
|
||||
parts.append(cleaned)
|
||||
return parts
|
||||
|
||||
|
||||
def _ensure_version_table(conn):
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version integer PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _applied_versions(conn):
|
||||
rows = conn.execute("SELECT version FROM schema_version ORDER BY version").fetchall()
|
||||
return {int(row["version"] if isinstance(row, dict) else row[0]) for row in rows}
|
||||
|
||||
|
||||
def _legacy_schema_present(conn):
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'printers'
|
||||
"""
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def apply_migrations(conn):
|
||||
"""Wendet fehlende Migrationen an. Bestehende Installationen ohne Version bekommen 001 gutgeschrieben."""
|
||||
_ensure_version_table(conn)
|
||||
applied = _applied_versions(conn)
|
||||
migrations = _migration_files()
|
||||
if not migrations and SCHEMA_LEGACY.exists():
|
||||
# Fallback: altes schema.sql einmalig
|
||||
for statement in _split_statements(SCHEMA_LEGACY.read_text(encoding="utf-8")):
|
||||
conn.execute(statement)
|
||||
return
|
||||
|
||||
if not applied and _legacy_schema_present(conn):
|
||||
# Schema war schon da (vor Migrationssystem) → Basis als angewandt markieren.
|
||||
base = next((m for m in migrations if m[0] == 1), None)
|
||||
if base:
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version, name) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||
(1, base[1].name),
|
||||
)
|
||||
applied.add(1)
|
||||
|
||||
for version, path in migrations:
|
||||
if version in applied:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for statement in _split_statements(text):
|
||||
conn.execute(statement)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version, name) VALUES (%s, %s)",
|
||||
(version, path.name),
|
||||
)
|
||||
|
||||
|
||||
def prepare(url):
|
||||
@@ -31,14 +117,16 @@ def prepare(url):
|
||||
admin = urlunparse(urlparse(url)._replace(path="/postgres"))
|
||||
try:
|
||||
with psycopg.connect(admin, autocommit=True, connect_timeout=5) as conn:
|
||||
exists = conn.execute("SELECT 1 FROM pg_database WHERE datname = %s", (name,)).fetchone()
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM pg_database WHERE datname = %s", (name,)
|
||||
).fetchone()
|
||||
if not exists:
|
||||
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
|
||||
except psycopg.Error:
|
||||
pass
|
||||
except psycopg.Error as exc:
|
||||
print("Datenbank anlegen: %s" % exc, flush=True)
|
||||
try:
|
||||
with psycopg.connect(url, connect_timeout=5) as conn:
|
||||
apply_schema(conn)
|
||||
with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
|
||||
apply_migrations(conn)
|
||||
conn.commit()
|
||||
except psycopg.Error as exc:
|
||||
raise SystemExit(
|
||||
@@ -50,9 +138,50 @@ def prepare(url):
|
||||
) from exc
|
||||
|
||||
|
||||
def init_pool(url, min_size=2, max_size=16):
|
||||
global _pool
|
||||
with _pool_lock:
|
||||
if _pool is not None:
|
||||
return _pool
|
||||
_pool = ConnectionPool(
|
||||
conninfo=url,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
kwargs={"row_factory": dict_row, "connect_timeout": 5},
|
||||
open=True,
|
||||
name="grotti",
|
||||
)
|
||||
return _pool
|
||||
|
||||
|
||||
def close_pool():
|
||||
global _pool
|
||||
with _pool_lock:
|
||||
if _pool is not None:
|
||||
_pool.close()
|
||||
_pool = None
|
||||
|
||||
|
||||
def get_pool():
|
||||
if _pool is None:
|
||||
raise RuntimeError("Datenbank-Pool ist nicht gestartet.")
|
||||
return _pool
|
||||
|
||||
|
||||
@contextmanager
|
||||
def connect(url):
|
||||
conn = psycopg.connect(url, row_factory=dict_row)
|
||||
def connect(url=None):
|
||||
"""Verbindung aus dem Pool, sonst einmalig direkt (Tests / prepare)."""
|
||||
if _pool is not None:
|
||||
with _pool.connection() as conn:
|
||||
try:
|
||||
yield conn
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
return
|
||||
if not url:
|
||||
raise RuntimeError("Keine database_url und kein Pool.")
|
||||
conn = psycopg.connect(url, row_factory=dict_row, connect_timeout=5)
|
||||
try:
|
||||
yield conn
|
||||
except Exception:
|
||||
@@ -60,3 +189,35 @@ def connect(url):
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def cached_user_count(conn):
|
||||
"""Zählt Benutzer mit kurzem Cache — vermeidet count(*) bei jedem Request."""
|
||||
import time
|
||||
|
||||
now = time.monotonic()
|
||||
cached = _user_count_cache["n"]
|
||||
if cached is not None and (now - _user_count_cache["at"]) < _USER_COUNT_TTL:
|
||||
return cached
|
||||
n = int(conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"])
|
||||
_user_count_cache["n"] = n
|
||||
_user_count_cache["at"] = now
|
||||
return n
|
||||
|
||||
|
||||
def invalidate_user_count():
|
||||
_user_count_cache["n"] = None
|
||||
_user_count_cache["at"] = 0.0
|
||||
|
||||
|
||||
def prune_run_samples(conn, keep_days=90):
|
||||
"""Alte Temperaturproben löschen (Retention)."""
|
||||
days = max(7, int(keep_days))
|
||||
row = conn.execute(
|
||||
"""
|
||||
DELETE FROM run_samples
|
||||
WHERE sampled_at < now() - (%s || ' days')::interval
|
||||
""",
|
||||
(str(days),),
|
||||
)
|
||||
return row.rowcount if row is not None else 0
|
||||
@@ -55,8 +55,8 @@ DEFAULT_SWEEP = {
|
||||
# Ein Punkt in der Bettmitte — kein LeviQ, kein 3×3, kein Heizen.
|
||||
DEFAULT_CHECK = {
|
||||
"type": "check",
|
||||
# Result is z= gegen Grenze. Temporär 3 mm — kalter Kunststoff an der Düse
|
||||
# tastet sonst oft 1–2 mm und meldet Restteil. Später wieder straffen.
|
||||
# Result is z= gegen Grenze. 3 mm: kalter Kunststoff an der Düse
|
||||
# tastet sonst oft 1–2 mm und meldet Restteil.
|
||||
"max_delta_mm": 3.0,
|
||||
"timeout_min": 2,
|
||||
"x": 125.0,
|
||||
@@ -454,11 +454,6 @@ def sweep_lower_to_push_script(sweep):
|
||||
])
|
||||
|
||||
|
||||
def sweep_probe_height_script(sweep):
|
||||
"""Kompatibel: hoch, hinten, langsam auf z_push."""
|
||||
return sweep_go_back_high_script(sweep) + "\n" + sweep_lower_to_push_script(sweep)
|
||||
|
||||
|
||||
def assert_clear_before_lower(live, sweep, min_z_mm=15.0):
|
||||
"""Vor dem Senken: Toolhead muss noch klar über dem Bett stehen."""
|
||||
pos = (live or {}).get("position") or []
|
||||
@@ -562,100 +557,6 @@ def check_lift_script(check):
|
||||
])
|
||||
|
||||
|
||||
def _axis_coords(lo, hi, count):
|
||||
if count <= 1:
|
||||
return [(lo + hi) / 2.0]
|
||||
return [lo + (hi - lo) * i / (count - 1) for i in range(count)]
|
||||
|
||||
|
||||
def _matrix_ok(matrix):
|
||||
if not isinstance(matrix, list) or not matrix:
|
||||
return False
|
||||
width = None
|
||||
for row in matrix:
|
||||
if not isinstance(row, list) or not row:
|
||||
return False
|
||||
if width is None:
|
||||
width = len(row)
|
||||
if len(row) != width:
|
||||
return False
|
||||
for value in row:
|
||||
if not isinstance(value, (int, float)):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def interpolate_mesh_z(mesh, x, y):
|
||||
"""Bilinear aus Roh-probed_matrix. Klipper: Zeile 0 = mesh_min Y."""
|
||||
matrix = mesh.get("matrix") if isinstance(mesh, dict) else None
|
||||
if not _matrix_ok(matrix):
|
||||
return None
|
||||
mesh_min = mesh.get("mesh_min") or [0, 0]
|
||||
mesh_max = mesh.get("mesh_max") or [0, 0]
|
||||
try:
|
||||
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
|
||||
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return None
|
||||
rows = len(matrix)
|
||||
cols = len(matrix[0])
|
||||
if cols < 1 or rows < 1:
|
||||
return None
|
||||
if abs(x1 - x0) < 1e-9 or abs(y1 - y0) < 1e-9:
|
||||
return float(matrix[0][0])
|
||||
tx = 0.0 if cols == 1 else (float(x) - x0) / (x1 - x0) * (cols - 1)
|
||||
ty = 0.0 if rows == 1 else (float(y) - y0) / (y1 - y0) * (rows - 1)
|
||||
tx = max(0.0, min(float(cols - 1), tx))
|
||||
ty = max(0.0, min(float(rows - 1), ty))
|
||||
c0 = int(tx)
|
||||
r0 = int(ty)
|
||||
c1 = min(cols - 1, c0 + 1)
|
||||
r1 = min(rows - 1, r0 + 1)
|
||||
fx = tx - c0
|
||||
fy = ty - r0
|
||||
z00 = float(matrix[r0][c0])
|
||||
z10 = float(matrix[r0][c1])
|
||||
z01 = float(matrix[r1][c0])
|
||||
z11 = float(matrix[r1][c1])
|
||||
z0 = z00 * (1 - fx) + z10 * fx
|
||||
z1 = z01 * (1 - fx) + z11 * fx
|
||||
return z0 * (1 - fy) + z1 * fy
|
||||
|
||||
|
||||
def mesh_delta_mm(baseline, measured):
|
||||
"""Max |Δ| und Mittel |Δ|: Messpunkte gegen interpoliertes Baseline-Netz."""
|
||||
if not isinstance(baseline, dict) or not _matrix_ok(baseline.get("matrix")):
|
||||
raise GrottiError("Kein Bettnetz zum Vergleich — vorher nivellieren.", "mesh")
|
||||
if not isinstance(measured, dict) or not _matrix_ok(measured.get("matrix")):
|
||||
raise GrottiError("Neues Bettnetz fehlt nach dem Antasten.", "mesh")
|
||||
matrix = measured["matrix"]
|
||||
mesh_min = measured.get("mesh_min") or [0, 0]
|
||||
mesh_max = measured.get("mesh_max") or [0, 0]
|
||||
try:
|
||||
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
|
||||
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
|
||||
except (TypeError, ValueError, IndexError) as exc:
|
||||
raise GrottiError("Messnetz ohne gültigen Bereich.", "mesh") from exc
|
||||
rows = len(matrix)
|
||||
cols = len(matrix[0])
|
||||
xs = _axis_coords(x0, x1, cols)
|
||||
ys = _axis_coords(y0, y1, rows)
|
||||
deltas = []
|
||||
for ri, y in enumerate(ys):
|
||||
for ci, x in enumerate(xs):
|
||||
expected = interpolate_mesh_z(baseline, x, y)
|
||||
if expected is None:
|
||||
raise GrottiError("Baseline-Netz lässt sich nicht interpolieren.", "mesh")
|
||||
deltas.append(abs(float(matrix[ri][ci]) - expected))
|
||||
if not deltas:
|
||||
raise GrottiError("Keine Messpunkte zum Vergleich.", "mesh")
|
||||
return {
|
||||
"max_delta_mm": max(deltas),
|
||||
"mean_delta_mm": sum(deltas) / len(deltas),
|
||||
"points": len(deltas),
|
||||
}
|
||||
|
||||
|
||||
def step_label(step):
|
||||
if not isinstance(step, dict):
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Thread-sicherer Pub/Sub für Server-Sent Events (Live-UI)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from queue import Empty, Queue
|
||||
|
||||
_lock = threading.Lock()
|
||||
_subscribers: list[Queue] = []
|
||||
|
||||
|
||||
def subscribe() -> Queue:
|
||||
q: Queue = Queue(maxsize=256)
|
||||
with _lock:
|
||||
_subscribers.append(q)
|
||||
return q
|
||||
|
||||
|
||||
def unsubscribe(q: Queue) -> None:
|
||||
with _lock:
|
||||
try:
|
||||
_subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def publish(kind: str, payload: dict | None = None) -> None:
|
||||
msg = {"kind": kind or "message", "payload": payload if payload is not None else {}}
|
||||
with _lock:
|
||||
targets = list(_subscribers)
|
||||
for q in targets:
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def wait_event(q: Queue, timeout_s: float) -> dict | None:
|
||||
try:
|
||||
return q.get(timeout=timeout_s)
|
||||
except Empty:
|
||||
return None
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Aggregierte „Jetzt“-Handlungsliste für Sidebar und Mobile."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from grotti import bedarf as bedarf_mod
|
||||
from grotti import maintenance
|
||||
from grotti import store
|
||||
|
||||
|
||||
def _filename_label(filename):
|
||||
name = (filename or "").strip()
|
||||
for suffix in (".gcode.3mf", ".gcode", ".3mf"):
|
||||
if name.lower().endswith(suffix):
|
||||
name = name[: -len(suffix)]
|
||||
break
|
||||
while name and name[0].isdigit():
|
||||
parts = name.split("-", 1)
|
||||
if len(parts) == 2 and parts[0].isdigit():
|
||||
name = parts[1]
|
||||
else:
|
||||
break
|
||||
return name or filename or ""
|
||||
|
||||
|
||||
def _iso_remain(remain_s):
|
||||
remain = float(remain_s or 0)
|
||||
if remain <= 0:
|
||||
return None
|
||||
at = datetime.now(timezone.utc).timestamp() + remain
|
||||
return datetime.fromtimestamp(at, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def farm_eta(running_items):
|
||||
"""Früheste und späteste Fertig-Zeit aus laufenden Drucken."""
|
||||
remains = [
|
||||
float(item.get("remain") or 0)
|
||||
for item in (running_items or [])
|
||||
if float(item.get("remain") or 0) > 0
|
||||
]
|
||||
if not remains:
|
||||
return {"all_done_at": None, "all_done_in_s": None, "next_free_in_s": None}
|
||||
return {
|
||||
"all_done_at": _iso_remain(max(remains)),
|
||||
"all_done_in_s": max(remains),
|
||||
"next_free_in_s": min(remains),
|
||||
}
|
||||
|
||||
|
||||
def overdue_orders(conn, limit=8):
|
||||
today = date.today()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT o.*, c.name AS customer_name
|
||||
FROM orders o
|
||||
LEFT JOIN customers c ON c.id = o.customer_id
|
||||
WHERE o.due_at IS NOT NULL
|
||||
AND o.due_at < %s
|
||||
AND o.status NOT IN ('fertig', 'geliefert', 'bezahlt')
|
||||
ORDER BY o.due_at ASC, o.id ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
(today, int(limit)),
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
out.append({
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
"status": row["status"],
|
||||
"due_at": row["due_at"].isoformat() if row.get("due_at") else None,
|
||||
"customer": row.get("customer_name") or "",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def overdue_maintenance(conn, printers, limit=8):
|
||||
items = []
|
||||
for printer in printers or []:
|
||||
host = printer.get("host") or ""
|
||||
counters = maintenance.counters_for_host(conn, host)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM maintenance_parts WHERE printer_host = %s ORDER BY label, part_key, id",
|
||||
(host,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
part = maintenance.part_out(row, counters, conn=conn)
|
||||
if not (part.get("overdue_hours") or part.get("overdue_meters")):
|
||||
continue
|
||||
items.append({
|
||||
"id": part["id"],
|
||||
"host": host,
|
||||
"printer": printer.get("name") or host,
|
||||
"label": part.get("label") or part.get("part_key") or "Teil",
|
||||
"overdue_hours": bool(part.get("overdue_hours")),
|
||||
"overdue_meters": bool(part.get("overdue_meters")),
|
||||
})
|
||||
if len(items) >= limit:
|
||||
return items
|
||||
return items
|
||||
|
||||
|
||||
def build(conn, watcher_latest, printers=None, send_jobs=None):
|
||||
"""Priorisierte Handlungsliste + laufende Drucke + Farm-ETA."""
|
||||
printers = printers if printers is not None else store.list_printers(conn)
|
||||
clears = store.bed_clear_map(conn)
|
||||
busy_hosts = {
|
||||
row["host"]
|
||||
for row in conn.execute(
|
||||
"SELECT DISTINCT host FROM print_queue WHERE status = 'running'"
|
||||
).fetchall()
|
||||
}
|
||||
queue = store.list_print_queue(conn, limit=80)
|
||||
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"]
|
||||
|
||||
actions = []
|
||||
|
||||
for live in watcher_latest or []:
|
||||
host = live.get("host") or ""
|
||||
state = (live.get("state") or "").lower()
|
||||
if state == "paused" and (live.get("filament_empty") or (live.get("pause_reason") or "").strip()):
|
||||
actions.append({
|
||||
"kind": "filament_pause",
|
||||
"priority": 12,
|
||||
"host": host,
|
||||
"printer": live.get("name") or host,
|
||||
"title": "Material nachlegen",
|
||||
"detail": (live.get("pause_reason") or "Drucker pausiert — Filament leer?").strip(),
|
||||
"filename": live.get("filename") or "",
|
||||
"href": "/#" + host,
|
||||
})
|
||||
|
||||
for live in watcher_latest or []:
|
||||
host = live.get("host") or ""
|
||||
state = (live.get("state") or "").lower()
|
||||
if not clears.get(host) or host in busy_hosts or state in ("printing", "paused", "heating"):
|
||||
continue
|
||||
actions.append({
|
||||
"kind": "clear",
|
||||
"priority": 10,
|
||||
"host": host,
|
||||
"printer": live.get("name") or host,
|
||||
"title": "Platte räumen",
|
||||
"detail": _filename_label(live.get("filename") or ""),
|
||||
"filename": live.get("filename") or "",
|
||||
"href": "/#" + host,
|
||||
})
|
||||
|
||||
for job in errors[:6]:
|
||||
actions.append({
|
||||
"kind": "queue_error",
|
||||
"priority": 20,
|
||||
"id": job.get("id"),
|
||||
"host": job.get("host") or "",
|
||||
"printer": job.get("printer_name") or job.get("host") or "",
|
||||
"title": "Druck fehlgeschlagen",
|
||||
"detail": job.get("message") or _filename_label(job.get("filename") or ""),
|
||||
"filename": job.get("filename") or "",
|
||||
"is_ablauf": bool(job.get("is_ablauf")),
|
||||
"step_index": job.get("step_index"),
|
||||
"step_label_current": job.get("step_label_current") or "",
|
||||
"href": "/warteschlange",
|
||||
})
|
||||
|
||||
bedarf = bedarf_mod.compute(conn)
|
||||
for item in (bedarf.get("shortfall") or [])[:5]:
|
||||
label = " · ".join(
|
||||
x for x in [item.get("material_type"), item.get("color")] if x
|
||||
) or "Filament"
|
||||
actions.append({
|
||||
"kind": "bedarf",
|
||||
"priority": 30,
|
||||
"title": "Spule fehlt",
|
||||
"detail": "%s — %s g fehlen" % (label, int(item.get("shortfall_g") or 0)),
|
||||
"material_type": item.get("material_type") or "",
|
||||
"color": item.get("color") or "",
|
||||
"shortfall_g": item.get("shortfall_g") or 0,
|
||||
"href": "/material#bedarf",
|
||||
})
|
||||
|
||||
for part in overdue_maintenance(conn, printers):
|
||||
actions.append({
|
||||
"kind": "wartung",
|
||||
"priority": 40,
|
||||
"id": part["id"],
|
||||
"host": part["host"],
|
||||
"printer": part["printer"],
|
||||
"title": "Wartung fällig",
|
||||
"detail": "%s · %s" % (part["printer"], part["label"]),
|
||||
"href": "/wartung",
|
||||
})
|
||||
|
||||
for order in overdue_orders(conn):
|
||||
actions.append({
|
||||
"kind": "auftrag",
|
||||
"priority": 50,
|
||||
"id": order["id"],
|
||||
"title": "Auftrag überfällig",
|
||||
"detail": "%s · fällig %s" % (order["title"], order.get("due_at") or ""),
|
||||
"due_at": order.get("due_at"),
|
||||
"href": "/auftraege",
|
||||
})
|
||||
|
||||
shortfall = bedarf.get("shortfall") or []
|
||||
for job in waiting[:8]:
|
||||
tip = (job.get("wait_reason") or "").strip()
|
||||
if not tip and shortfall:
|
||||
# Fallback ohne Routing-Hinweis
|
||||
tip = ""
|
||||
if not tip:
|
||||
continue
|
||||
actions.append({
|
||||
"kind": "waiting",
|
||||
"priority": 60,
|
||||
"id": job.get("id"),
|
||||
"host": job.get("host") or "",
|
||||
"printer": job.get("printer_name") or job.get("host") or "",
|
||||
"title": "Wartet auf Material",
|
||||
"detail": tip,
|
||||
"filename": job.get("filename") or "",
|
||||
"reason": tip,
|
||||
"href": "/warteschlange",
|
||||
})
|
||||
|
||||
actions.sort(key=lambda a: (a.get("priority") or 99, a.get("id") or 0))
|
||||
|
||||
live_by_host = {}
|
||||
running = []
|
||||
for live in watcher_latest or []:
|
||||
state = (live.get("state") or "").lower()
|
||||
if state not in ("printing", "paused", "heating"):
|
||||
continue
|
||||
item = {
|
||||
"host": live.get("host") or "",
|
||||
"name": live.get("name") or "",
|
||||
"state": state,
|
||||
"progress": live.get("progress") or 0,
|
||||
"remain": live.get("remain") or 0,
|
||||
"filename": live.get("filename") or "",
|
||||
"filament_empty": bool(live.get("filament_empty")),
|
||||
"pause_reason": (live.get("pause_reason") or "").strip(),
|
||||
"queue_id": None,
|
||||
}
|
||||
live_by_host[item["host"]] = item
|
||||
running.append(item)
|
||||
for job in running_q:
|
||||
host = job.get("host") or ""
|
||||
if host in live_by_host:
|
||||
live_by_host[host]["queue_id"] = job.get("id")
|
||||
live_by_host[host]["is_ablauf"] = bool(job.get("is_ablauf"))
|
||||
live_by_host[host]["step_labels"] = job.get("step_labels")
|
||||
live_by_host[host]["display_step_index"] = job.get("display_step_index")
|
||||
live_by_host[host]["step_index"] = job.get("step_index")
|
||||
else:
|
||||
running.append({
|
||||
"host": host,
|
||||
"name": job.get("printer_name") or host,
|
||||
"state": "running",
|
||||
"progress": job.get("progress") or 0,
|
||||
"remain": job.get("remain") or 0,
|
||||
"filename": job.get("filename") or "",
|
||||
"queue_id": job.get("id"),
|
||||
"is_ablauf": bool(job.get("is_ablauf")),
|
||||
})
|
||||
|
||||
sends = []
|
||||
for job in send_jobs or []:
|
||||
if job.get("status") in ("pending", "running"):
|
||||
sends.append(job)
|
||||
elif job.get("status") == "error" and len(sends) < 2:
|
||||
sends.append(job)
|
||||
|
||||
alert_kinds = list(store.ALERT_EVENT_KINDS)
|
||||
alert_items = store.list_events(conn, limit=5, unread_only=True, kinds=alert_kinds)
|
||||
unread = store.unread_event_count(conn, kinds=alert_kinds)
|
||||
|
||||
return {
|
||||
"actions": actions,
|
||||
"running": running,
|
||||
"waiting_count": len(waiting),
|
||||
"sends": sends[:5],
|
||||
"alerts": alert_items,
|
||||
"unread_alerts": unread,
|
||||
"eta": farm_eta(running),
|
||||
"bedarf_shortfall": len(shortfall),
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import urllib.error
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from grotti import acm, eject, library, moonraker, store, usage
|
||||
from grotti import acm, catalog, eject, library, 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
|
||||
@@ -43,14 +43,7 @@ def _targets_from_ace(conn, host, ace):
|
||||
targets = []
|
||||
for item in ace:
|
||||
slot = int(item["ams_index"]) + 1
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM spools
|
||||
WHERE printer_host = %s AND ace_slot = %s
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(host, slot),
|
||||
).fetchone()
|
||||
row = store.spool_at_slot(conn, host, slot)
|
||||
if row:
|
||||
targets.append(row)
|
||||
return targets
|
||||
@@ -62,12 +55,18 @@ def unix_to_dt(value):
|
||||
return datetime.fromtimestamp(float(value), timezone.utc)
|
||||
|
||||
|
||||
def choose_spool(conn, host, material, hinted_id):
|
||||
def choose_spool(conn, host, material, hinted_id, ended_at=None):
|
||||
"""Spule für History-Abzug. Ohne Hint nur Rollen, die vor Druckende existierten."""
|
||||
if hinted_id:
|
||||
row = store.get_spool_row(conn, hinted_id)
|
||||
if row:
|
||||
if row and _spool_valid_for_end(row, ended_at):
|
||||
return row
|
||||
rows = store.spools_for_printer(conn, host)
|
||||
if row and not _spool_valid_for_end(row, ended_at):
|
||||
return None
|
||||
rows = [
|
||||
row for row in store.spools_for_printer(conn, host)
|
||||
if _spool_valid_for_end(row, ended_at)
|
||||
]
|
||||
if len(rows) == 1:
|
||||
return rows[0]
|
||||
if material:
|
||||
@@ -77,6 +76,24 @@ def choose_spool(conn, host, material, hinted_id):
|
||||
return None
|
||||
|
||||
|
||||
def _spool_valid_for_end(spool, ended_at):
|
||||
"""Verhindert Abzug alter Moonraker-Jobs auf neu eingelegte Spulen."""
|
||||
if ended_at is None or not spool:
|
||||
return True
|
||||
created = spool.get("created_at")
|
||||
if created is None:
|
||||
return True
|
||||
end = ended_at
|
||||
if getattr(created, "tzinfo", None) is None and getattr(end, "tzinfo", None) is not None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
if getattr(end, "tzinfo", None) is None and getattr(created, "tzinfo", None) is not None:
|
||||
end = end.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
return created <= end
|
||||
except TypeError:
|
||||
return True
|
||||
|
||||
|
||||
def file_material_types(file_row):
|
||||
types = []
|
||||
for item in (file_row or {}).get("filaments") or []:
|
||||
@@ -93,14 +110,7 @@ def resolve_spools_at_host(conn, host, ace_map=None):
|
||||
if mapping:
|
||||
for item in mapping:
|
||||
slot = int(item["ams_index"]) + 1
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM spools
|
||||
WHERE printer_host = %s AND ace_slot = %s
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(host, slot),
|
||||
).fetchone()
|
||||
row = store.spool_at_slot(conn, host, slot)
|
||||
if row:
|
||||
found.append(row)
|
||||
return found
|
||||
@@ -117,21 +127,8 @@ def resolve_spools_at_host(conn, host, ace_map=None):
|
||||
|
||||
|
||||
def assert_material_match(file_row, spools):
|
||||
"""Datei-Material gegen eingelegte Rollen — warnt nicht bei leerer Liste."""
|
||||
want = {t.lower() for t in file_material_types(file_row)}
|
||||
if not want or not spools:
|
||||
return
|
||||
have = {(row.get("material_type") or "").strip().lower() for row in spools}
|
||||
have.discard("")
|
||||
if not have:
|
||||
return
|
||||
if want.isdisjoint(have):
|
||||
raise GrottiError(
|
||||
"Material passt nicht: Datei braucht %s, im Drucker steckt %s."
|
||||
% (", ".join(sorted(want)), ", ".join(sorted(have)) or "unbekannt"),
|
||||
"material",
|
||||
409,
|
||||
)
|
||||
"""Falsche Sorte bricht den Start nicht ab. Die Oberfläche warnt vorher."""
|
||||
return
|
||||
|
||||
|
||||
def assert_spool_at_host(conn, spool_id, host):
|
||||
@@ -270,7 +267,8 @@ def apply_deduction(conn, run, energy, metadata=None):
|
||||
return False
|
||||
used_total = sum(item["weight_g"] for item in entries)
|
||||
filament = sum(item["cost"] for item in entries)
|
||||
power = energy_cost(run["duration_s"], energy)
|
||||
samples = store.list_run_samples(conn, run["id"])
|
||||
power = energy_cost(run["duration_s"], energy, samples)
|
||||
# run_spools vor deducted=true, sonst bleibt die Akte leer bei Abbruch dazwischen
|
||||
store.replace_run_spools(conn, run["id"], entries)
|
||||
marked = store.mark_deducted(
|
||||
@@ -328,7 +326,12 @@ def sync_history(conn, cfg):
|
||||
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")
|
||||
spool = choose_spool(conn, printer["host"], material, hinted)
|
||||
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)
|
||||
@@ -502,7 +505,7 @@ def send_file(
|
||||
progress("pruefen", 15, "Drucker prüfen…")
|
||||
try:
|
||||
live = moonraker.query(host)
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as exc:
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError, ValueError) as exc:
|
||||
raise GrottiError("Der Drucker ist nicht erreichbar.", "offline") from exc
|
||||
if live.get("state") in ("printing", "paused"):
|
||||
raise GrottiError("Der Drucker druckt schon.", "belegt", 409)
|
||||
@@ -670,7 +673,7 @@ def steuern(conn, host, action, params=None):
|
||||
continue
|
||||
if not spool:
|
||||
raise GrottiError("Auf diesem Platz liegt keine Spule in Grotti.", "spule")
|
||||
material = (spool.get("material_type") or "").strip()
|
||||
material = catalog.ace_material_type(spool.get("material_type"))
|
||||
if not material:
|
||||
raise GrottiError("Die Spule braucht eine Sorte.", "material")
|
||||
color = moonraker.spool_rgb(spool)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Watch-Ordner, Upload und Vorschaubild."""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
@@ -10,6 +12,8 @@ from grotti.parse import embedded_png, inspect_file, safe_zip_name
|
||||
|
||||
_LAST_WATCH_SYNC = 0.0
|
||||
_WATCH_MIN_INTERVAL_S = 20
|
||||
_FOLDER_PART = re.compile(r"^[A-Za-z0-9._\- äöüÄÖÜß]+$")
|
||||
_MAX_FOLDER_DEPTH = 6
|
||||
|
||||
|
||||
def skip_path(path, data_dir):
|
||||
@@ -36,7 +40,7 @@ def sync_watch(conn, cfg, force=False):
|
||||
global _LAST_WATCH_SYNC
|
||||
folder = cfg["watch_dir"]
|
||||
if folder is None:
|
||||
return {"gelesen": 0, "hinweis": "In config.json watch_dir auf den Slice-Ordner setzen."}
|
||||
return {"gelesen": 0, "hinweis": "Unter Einstellungen den Slice-Ordner setzen."}
|
||||
if not folder.exists():
|
||||
return {"gelesen": 0, "hinweis": "Der Watch-Ordner fehlt: %s" % folder}
|
||||
now = time.monotonic()
|
||||
@@ -96,7 +100,10 @@ def refresh_file_meta(conn, force=False):
|
||||
|
||||
def safe_filename(name):
|
||||
base = Path(name).name.replace("\\", "_").replace("/", "_").replace('"', "")
|
||||
return base or "datei.gcode"
|
||||
base = base.strip().strip(".")
|
||||
if not base or base in (".", ".."):
|
||||
return "datei.gcode"
|
||||
return base
|
||||
|
||||
|
||||
def unique_path(directory, filename):
|
||||
@@ -113,6 +120,138 @@ def unique_path(directory, filename):
|
||||
return candidate
|
||||
|
||||
|
||||
def require_watch_dir(cfg):
|
||||
folder = cfg.get("watch_dir")
|
||||
if not folder:
|
||||
raise GrottiError("Kein Slice-Ordner gesetzt — unter Einstellungen festlegen.", "watch")
|
||||
root = Path(folder).resolve()
|
||||
if not root.is_dir():
|
||||
raise GrottiError("Der Slice-Ordner fehlt: %s" % root, "watch")
|
||||
return root
|
||||
|
||||
|
||||
def normalize_folder(rel):
|
||||
"""Relativpfad unter watch_dir; leer = Wurzel. Kein .., begrenzte Tiefe."""
|
||||
text = str(rel or "").strip().replace("\\", "/").strip("/")
|
||||
if not text:
|
||||
return ""
|
||||
parts = []
|
||||
for part in text.split("/"):
|
||||
part = part.strip()
|
||||
if not part or part in (".", ".."):
|
||||
raise GrottiError("Ungültiger Ordnername.", "ordner")
|
||||
if not _FOLDER_PART.match(part):
|
||||
raise GrottiError(
|
||||
"Ordnername nur Buchstaben, Ziffern, Leerzeichen, . _ -.",
|
||||
"ordner",
|
||||
)
|
||||
parts.append(part)
|
||||
if len(parts) > _MAX_FOLDER_DEPTH:
|
||||
raise GrottiError("Ordner zu tief verschachtelt (max. %s)." % _MAX_FOLDER_DEPTH, "ordner")
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def path_under_watch(watch_dir, rel_folder, name=None):
|
||||
"""Absoluter Pfad unter watch_dir; wirft bei Escape."""
|
||||
root = Path(watch_dir).resolve()
|
||||
folder = normalize_folder(rel_folder)
|
||||
target = root if not folder else (root / folder)
|
||||
if name:
|
||||
target = target / safe_filename(name)
|
||||
resolved = target.resolve()
|
||||
if resolved != root and root not in resolved.parents:
|
||||
raise GrottiError("Pfad außerhalb des Slice-Ordners.", "ordner")
|
||||
return resolved
|
||||
|
||||
|
||||
def list_folders(cfg):
|
||||
"""Unterordner unter watch_dir (auch leer), relativ, sortiert."""
|
||||
try:
|
||||
root = require_watch_dir(cfg)
|
||||
except GrottiError:
|
||||
return []
|
||||
data_dir = cfg.get("data_dir")
|
||||
found = set()
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_dir():
|
||||
continue
|
||||
if data_dir is not None and skip_path(path, data_dir):
|
||||
continue
|
||||
try:
|
||||
rel = path.resolve().relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
continue
|
||||
if rel and rel != ".":
|
||||
found.add(rel)
|
||||
return sorted(found, key=lambda s: s.lower())
|
||||
|
||||
|
||||
def create_folder(cfg, rel):
|
||||
root = require_watch_dir(cfg)
|
||||
folder = normalize_folder(rel)
|
||||
if not folder:
|
||||
raise GrottiError("Ordnername fehlt.", "ordner")
|
||||
target = path_under_watch(root, folder)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
return folder
|
||||
|
||||
|
||||
def delete_folder(cfg, rel):
|
||||
root = require_watch_dir(cfg)
|
||||
folder = normalize_folder(rel)
|
||||
if not folder:
|
||||
raise GrottiError("Die Wurzel lässt sich nicht löschen.", "ordner")
|
||||
target = path_under_watch(root, folder)
|
||||
if not target.is_dir():
|
||||
raise GrottiError("Der Ordner fehlt.", "ordner", 404)
|
||||
try:
|
||||
next(target.iterdir())
|
||||
raise GrottiError("Ordner ist nicht leer.", "ordner")
|
||||
except StopIteration:
|
||||
pass
|
||||
target.rmdir()
|
||||
return folder
|
||||
|
||||
|
||||
def move_file(conn, cfg, file_id, folder):
|
||||
"""Watch-Datei in Unterordner (oder Wurzel) verschieben; ID bleibt."""
|
||||
root = require_watch_dir(cfg)
|
||||
folder = normalize_folder(folder)
|
||||
row = store.get_file_row(conn, file_id)
|
||||
if not row:
|
||||
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
||||
if row.get("source") != "watch":
|
||||
raise GrottiError(
|
||||
"Nur Dateien aus dem Slice-Ordner lassen sich verschieben.",
|
||||
"watch",
|
||||
)
|
||||
src = Path(row.get("stored_path") or "")
|
||||
if not src.is_file():
|
||||
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
||||
dest_dir = path_under_watch(root, folder)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = dest_dir / src.name
|
||||
if dest.resolve() == src.resolve():
|
||||
return store.file_out(row, watch_dir=root)
|
||||
if dest.exists():
|
||||
dest = unique_path(dest_dir, src.name)
|
||||
try:
|
||||
shutil.move(str(src), str(dest))
|
||||
except OSError as exc:
|
||||
raise GrottiError("Verschieben fehlgeschlagen: %s" % exc, "ordner") from exc
|
||||
stat = dest.stat()
|
||||
store.update_file_location(
|
||||
conn,
|
||||
file_id,
|
||||
str(dest.resolve()),
|
||||
name=dest.name,
|
||||
size_bytes=stat.st_size,
|
||||
mtime=stat.st_mtime,
|
||||
)
|
||||
row = store.get_file_row(conn, file_id)
|
||||
return store.file_out(row, watch_dir=root)
|
||||
|
||||
|
||||
def save_upload(conn, filename, content, cfg, project_id=None):
|
||||
directory = cfg["data_dir"] / "eingang"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Wartungs-Teile und Laufzähler aus dem Protokoll."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
def _baseline(conn, host, part_key):
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT hours_at, meters_at FROM maintenance_log
|
||||
WHERE printer_host = %s AND part_key = %s AND event IN ('install', 'reset')
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(host, part_key),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return 0.0, 0.0
|
||||
return float(row.get("hours_at") or 0), float(row.get("meters_at") or 0)
|
||||
|
||||
|
||||
def counters_for_host(conn, host):
|
||||
"""Summe Druckzeit, Filament und Anzahl Läufe für einen Host."""
|
||||
host = (host or "").strip()
|
||||
if not host:
|
||||
return {"hours": 0.0, "meters": 0.0, "runs": 0}
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(SUM(duration_s), 0) AS duration_s,
|
||||
COALESCE(SUM(filament_used_mm), 0) AS meters,
|
||||
COUNT(*) AS runs
|
||||
FROM runs
|
||||
WHERE printer_host = %s AND state = 'completed'
|
||||
""",
|
||||
(host,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {"hours": 0.0, "meters": 0.0, "runs": 0}
|
||||
return {
|
||||
"hours": float(row.get("duration_s") or 0) / 3600.0,
|
||||
"meters": float(row.get("meters") or 0),
|
||||
"runs": int(row.get("runs") or 0),
|
||||
}
|
||||
|
||||
|
||||
def part_out(row, counters=None, conn=None):
|
||||
counters = counters or {}
|
||||
life_h = float(row.get("life_hours") or 0)
|
||||
life_m = float(row.get("life_meters") or 0)
|
||||
hours_total = float(counters.get("hours") or 0)
|
||||
meters_total = float(counters.get("meters") or 0)
|
||||
host = row["printer_host"]
|
||||
part_key = row["part_key"]
|
||||
base_h, base_m = (0.0, 0.0)
|
||||
if conn is not None:
|
||||
base_h, base_m = _baseline(conn, host, part_key)
|
||||
hours_since = max(0.0, hours_total - base_h)
|
||||
meters_since = max(0.0, meters_total - base_m)
|
||||
installed = row.get("installed_at")
|
||||
return {
|
||||
"id": row["id"],
|
||||
"printer_host": host,
|
||||
"part_key": part_key,
|
||||
"label": row.get("label") or "",
|
||||
"installed_at": installed.isoformat() if hasattr(installed, "isoformat") else str(installed or ""),
|
||||
"life_hours": life_h,
|
||||
"life_meters": life_m,
|
||||
"note": row.get("note") or "",
|
||||
"hours_since": hours_since,
|
||||
"meters_since": meters_since,
|
||||
"overdue_hours": life_h > 0 and hours_since >= life_h,
|
||||
"overdue_meters": life_m > 0 and meters_since >= life_m,
|
||||
}
|
||||
|
||||
|
||||
def list_for_api(conn, printers):
|
||||
out = []
|
||||
for printer in printers or []:
|
||||
host = printer.get("host") or ""
|
||||
counters = counters_for_host(conn, host)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM maintenance_parts WHERE printer_host = %s ORDER BY label, part_key, id",
|
||||
(host,),
|
||||
).fetchall()
|
||||
out.append({
|
||||
"host": host,
|
||||
"name": printer.get("name") or host,
|
||||
"counters": counters,
|
||||
"parts": [part_out(r, counters, conn=conn) for r in rows],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def create_part(conn, printer_host, part_key, label="", life_hours=0, life_meters=0, note=""):
|
||||
printer_host = (printer_host or "").strip()
|
||||
part_key = (part_key or "").strip()
|
||||
if not printer_host or not part_key:
|
||||
raise ValueError("Drucker und part_key nötig.")
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO maintenance_parts (printer_host, part_key, label, life_hours, life_meters, note)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
printer_host,
|
||||
part_key,
|
||||
(label or part_key).strip(),
|
||||
float(life_hours or 0),
|
||||
float(life_meters or 0),
|
||||
(note or "").strip(),
|
||||
),
|
||||
).fetchone()
|
||||
counters = counters_for_host(conn, printer_host)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO maintenance_log (printer_host, part_key, event, hours_at, meters_at, note)
|
||||
VALUES (%s, %s, 'install', %s, %s, %s)
|
||||
""",
|
||||
(printer_host, part_key, counters["hours"], counters["meters"], (note or "").strip()),
|
||||
)
|
||||
return part_out(row, counters, conn=conn)
|
||||
|
||||
|
||||
def reset_part(conn, part_id):
|
||||
row = conn.execute(
|
||||
"SELECT * FROM maintenance_parts WHERE id = %s",
|
||||
(int(part_id),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
host = row["printer_host"]
|
||||
counters = counters_for_host(conn, host)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO maintenance_log (printer_host, part_key, event, hours_at, meters_at, note)
|
||||
VALUES (%s, %s, 'reset', %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
host,
|
||||
row["part_key"],
|
||||
counters["hours"],
|
||||
counters["meters"],
|
||||
row.get("note") or "",
|
||||
),
|
||||
)
|
||||
updated = conn.execute(
|
||||
"""
|
||||
UPDATE maintenance_parts
|
||||
SET installed_at = now()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(int(part_id),),
|
||||
).fetchone()
|
||||
fresh_counters = counters_for_host(conn, host)
|
||||
return part_out(updated, fresh_counters, conn=conn)
|
||||
@@ -124,6 +124,27 @@ def main():
|
||||
"""Spulen / Materialbestand."""
|
||||
return json.dumps(_api("GET", "/spulen"), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def auftraege_lesen(limit: int = 30, status: str = "") -> str:
|
||||
"""Aufträge (optional nach Status filtern)."""
|
||||
st = (status or "").strip().lower()
|
||||
q = "?status=" + urllib.parse.quote(st) if st else ""
|
||||
rows = _api("GET", "/auftraege" + q) or []
|
||||
cap = max(1, min(limit, 200))
|
||||
if len(rows) > cap:
|
||||
rows = rows[:cap]
|
||||
return json.dumps(rows, ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def bedarf_lesen() -> str:
|
||||
"""Filament-Bedarf (Queue vs. Spulenrest)."""
|
||||
return json.dumps(_api("GET", "/bedarf"), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def wartung_lesen() -> str:
|
||||
"""Wartungsteile und Zählerstände."""
|
||||
return json.dumps(_api("GET", "/wartung"), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def get_version() -> str:
|
||||
"""Grotti-Version."""
|
||||
@@ -220,11 +241,11 @@ def main():
|
||||
out = []
|
||||
if nozzle is not None:
|
||||
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||
"action": "nozzle", "value": nozzle,
|
||||
"action": "nozzle", "temp": nozzle,
|
||||
}))
|
||||
if bed is not None:
|
||||
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||
"action": "bed", "value": bed,
|
||||
"action": "bed", "temp": bed,
|
||||
}))
|
||||
if not out:
|
||||
raise RuntimeError("nozzle und/oder bed angeben.")
|
||||
@@ -236,7 +257,7 @@ def main():
|
||||
_write_guard()
|
||||
return json.dumps(
|
||||
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||
"action": "speed", "value": speed,
|
||||
"action": "speed", "percent": speed,
|
||||
}),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -247,7 +268,7 @@ def main():
|
||||
_write_guard()
|
||||
return json.dumps(
|
||||
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||
"action": "fan", "value": fan,
|
||||
"action": "fan", "percent": fan,
|
||||
}),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
notes text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'idee',
|
||||
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS spools (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
brand text NOT NULL DEFAULT '',
|
||||
material_type text NOT NULL DEFAULT '',
|
||||
color text NOT NULL DEFAULT '',
|
||||
price_per_kg double precision NOT NULL DEFAULT 0,
|
||||
density double precision NOT NULL DEFAULT 1.24,
|
||||
volumetric double precision NOT NULL DEFAULT 12,
|
||||
remaining_g double precision NOT NULL DEFAULT 1000,
|
||||
printer_host text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
|
||||
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
|
||||
name text NOT NULL,
|
||||
stored_path text NOT NULL,
|
||||
source text NOT NULL,
|
||||
kind text NOT NULL,
|
||||
printer_family text NOT NULL DEFAULT '',
|
||||
filaments jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
weight_g double precision NOT NULL DEFAULT 0,
|
||||
estimated_s double precision NOT NULL DEFAULT 0,
|
||||
thumb_entry text NOT NULL DEFAULT '',
|
||||
plate text NOT NULL DEFAULT '',
|
||||
size_bytes bigint NOT NULL DEFAULT 0,
|
||||
mtime double precision NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (source, stored_path)
|
||||
);
|
||||
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS slice_meta jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sends (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
printer_host text NOT NULL,
|
||||
filename text NOT NULL,
|
||||
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
|
||||
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
|
||||
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE sends ADD COLUMN IF NOT EXISTS ace_map jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
ALTER TABLE sends ADD COLUMN IF NOT EXISTS bed_leveling boolean NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
printer_host text NOT NULL,
|
||||
printer_name text NOT NULL DEFAULT '',
|
||||
filename text NOT NULL DEFAULT '',
|
||||
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
|
||||
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
|
||||
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
|
||||
state text NOT NULL DEFAULT '',
|
||||
started_at timestamptz,
|
||||
ended_at timestamptz,
|
||||
duration_s double precision NOT NULL DEFAULT 0,
|
||||
weight_g double precision NOT NULL DEFAULT 0,
|
||||
filament_cost double precision NOT NULL DEFAULT 0,
|
||||
energy_cost double precision NOT NULL DEFAULT 0,
|
||||
moonraker_job_id text NOT NULL,
|
||||
deducted boolean NOT NULL DEFAULT false,
|
||||
needs_spool boolean NOT NULL DEFAULT false,
|
||||
verdict text NOT NULL DEFAULT '',
|
||||
material_type text NOT NULL DEFAULT '',
|
||||
UNIQUE (printer_host, moonraker_job_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS files_project ON files (project_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS runs_started ON runs (started_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS printers (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
host text NOT NULL UNIQUE,
|
||||
family text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE printers ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ideas (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
title text NOT NULL,
|
||||
body text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'offen',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS materials (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
brand text NOT NULL DEFAULT '',
|
||||
material_type text NOT NULL DEFAULT '',
|
||||
density double precision NOT NULL,
|
||||
volumetric double precision NOT NULL,
|
||||
volumetric_s1 double precision,
|
||||
machines text NOT NULL DEFAULT 'beide',
|
||||
price_per_kg double precision NOT NULL DEFAULT 0,
|
||||
note text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS colors jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS ace_slot smallint;
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS material_id bigint REFERENCES materials (id) ON DELETE SET NULL;
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS chargen_nr text;
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS hersteller_charge text NOT NULL DEFAULT '';
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS spools_chargen_nr ON spools (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS spools_code ON spools (code) WHERE code IS NOT NULL AND code <> '';
|
||||
-- Ein ACE-Platz gehört höchstens einer Spule.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS spools_host_slot
|
||||
ON spools (printer_host, ace_slot)
|
||||
WHERE printer_host <> '' AND ace_slot IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
host text NOT NULL DEFAULT '',
|
||||
printer text NOT NULL DEFAULT '',
|
||||
kind text NOT NULL,
|
||||
message text NOT NULL DEFAULT '',
|
||||
filename text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
read_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS events_unread ON events (read_at, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS print_queue (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
file_id bigint NOT NULL REFERENCES files (id) ON DELETE CASCADE,
|
||||
host text NOT NULL DEFAULT '',
|
||||
family text NOT NULL DEFAULT '',
|
||||
priority integer NOT NULL DEFAULT 100,
|
||||
status text NOT NULL DEFAULT 'waiting',
|
||||
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
|
||||
ace_map jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
bed_leveling boolean NOT NULL DEFAULT false,
|
||||
override_machine boolean NOT NULL DEFAULT false,
|
||||
message text NOT NULL DEFAULT '',
|
||||
steps jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
step_index integer NOT NULL DEFAULT 0,
|
||||
repeat_total integer NOT NULL DEFAULT 1,
|
||||
repeat_done integer NOT NULL DEFAULT 0,
|
||||
phase text NOT NULL DEFAULT '',
|
||||
phase_started_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS steps jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS step_index integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_total integer NOT NULL DEFAULT 1;
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_done integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase text NOT NULL DEFAULT '';
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase_started_at timestamptz;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS print_queue_waiting ON print_queue (status, priority, id);
|
||||
CREATE INDEX IF NOT EXISTS print_queue_running_host ON print_queue (host, status) WHERE status = 'running';
|
||||
|
||||
-- Nach dem Druck: Platte manuell quittieren, bevor „wenn frei“ den nächsten Job startet.
|
||||
CREATE TABLE IF NOT EXISTS bed_status (
|
||||
host text PRIMARY KEY,
|
||||
needs_clear boolean NOT NULL DEFAULT false,
|
||||
cleared_at timestamptz,
|
||||
message text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text NOT NULL,
|
||||
role text NOT NULL DEFAULT 'operator',
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_login timestamptz
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
name text NOT NULL DEFAULT '',
|
||||
token_hash text NOT NULL UNIQUE,
|
||||
token_prefix text NOT NULL DEFAULT '',
|
||||
role text NOT NULL DEFAULT 'operator',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_used timestamptz,
|
||||
revoked_at timestamptz,
|
||||
expires_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS api_tokens_user ON api_tokens (user_id) WHERE revoked_at IS NULL;
|
||||
|
||||
-- OIDC später: Identitäten an lokale User binden.
|
||||
CREATE TABLE IF NOT EXISTS oauth_identities (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
provider text NOT NULL,
|
||||
subject text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (provider, subject)
|
||||
);
|
||||
|
||||
-- Druckbericht: Soll/Ist, Charge, Live-Fortschritt
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_planned_g double precision NOT NULL DEFAULT 0;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_used_g double precision NOT NULL DEFAULT 0;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_source text NOT NULL DEFAULT '';
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_used_mm double precision NOT NULL DEFAULT 0;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_total_mm double precision NOT NULL DEFAULT 0;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS progress_last double precision NOT NULL DEFAULT 0;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS estimated_s double precision NOT NULL DEFAULT 0;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS chargen_nr text;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS message text NOT NULL DEFAULT '';
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS objects jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS runs_chargen_nr
|
||||
ON runs (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
|
||||
|
||||
ALTER TABLE events ADD COLUMN IF NOT EXISTS run_id bigint REFERENCES runs (id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS events_run ON events (run_id) WHERE run_id IS NOT NULL;
|
||||
|
||||
-- Abzug je Spule (Messwert oder Schätzwert)
|
||||
CREATE TABLE IF NOT EXISTS run_spools (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
|
||||
ace_slot smallint,
|
||||
weight_g double precision NOT NULL DEFAULT 0,
|
||||
weight_planned_g double precision NOT NULL DEFAULT 0,
|
||||
cost double precision NOT NULL DEFAULT 0,
|
||||
is_estimate boolean NOT NULL DEFAULT false,
|
||||
UNIQUE (run_id, spool_id, ace_slot)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS run_spools_spool ON run_spools (spool_id);
|
||||
CREATE INDEX IF NOT EXISTS run_spools_run ON run_spools (run_id);
|
||||
|
||||
-- Abschnitte bei Rollenwechsel mitten im Druck
|
||||
CREATE TABLE IF NOT EXISTS run_segments (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
|
||||
ace_slot smallint,
|
||||
mm_start double precision NOT NULL DEFAULT 0,
|
||||
mm_end double precision,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
closed_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS run_segments_run ON run_segments (run_id);
|
||||
CREATE INDEX IF NOT EXISTS run_segments_open
|
||||
ON run_segments (run_id) WHERE mm_end IS NULL;
|
||||
|
||||
-- Temperatur / Fortschritt während des Drucks
|
||||
CREATE TABLE IF NOT EXISTS run_samples (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||
sampled_at timestamptz NOT NULL DEFAULT now(),
|
||||
nozzle double precision,
|
||||
nozzle_target double precision,
|
||||
bed double precision,
|
||||
bed_target double precision,
|
||||
chamber double precision,
|
||||
fan double precision,
|
||||
speed_factor double precision,
|
||||
extrude_factor double precision,
|
||||
layer integer,
|
||||
progress double precision,
|
||||
filament_used_mm double precision,
|
||||
mmu_tool smallint,
|
||||
nozzle_power double precision,
|
||||
bed_power double precision
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at);
|
||||
|
||||
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS nozzle_power double precision;
|
||||
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS bed_power double precision;
|
||||
|
||||
-- Objekt-Chargen für Etiketten
|
||||
CREATE TABLE IF NOT EXISTS run_labels (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||
object_name text NOT NULL DEFAULT '',
|
||||
chargen_nr text NOT NULL,
|
||||
excluded boolean NOT NULL DEFAULT false,
|
||||
UNIQUE (run_id, object_name)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS run_labels_chargen ON run_labels (chargen_nr);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Heiße Abfragepfade für Sends, Läufe, Dateien und Ereignisse.
|
||||
CREATE INDEX IF NOT EXISTS sends_host_filename ON sends (printer_host, filename);
|
||||
CREATE INDEX IF NOT EXISTS sends_host_file ON sends (printer_host, file_id);
|
||||
CREATE INDEX IF NOT EXISTS runs_host_state ON runs (printer_host, state);
|
||||
CREATE INDEX IF NOT EXISTS files_name ON files (name);
|
||||
CREATE INDEX IF NOT EXISTS events_host_kind_created ON events (host, kind, created_at DESC);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS verdict_reason text NOT NULL DEFAULT '';
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_ok int NOT NULL DEFAULT 0;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_bad int NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE IF NOT EXISTS maintenance_parts (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
printer_host text NOT NULL,
|
||||
part_key text NOT NULL,
|
||||
label text NOT NULL DEFAULT '',
|
||||
installed_at timestamptz NOT NULL DEFAULT now(),
|
||||
life_hours double precision NOT NULL DEFAULT 0,
|
||||
life_meters double precision NOT NULL DEFAULT 0,
|
||||
note text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS maintenance_parts_host ON maintenance_parts (printer_host);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS maintenance_log (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
printer_host text NOT NULL,
|
||||
part_key text NOT NULL,
|
||||
event text NOT NULL DEFAULT '',
|
||||
hours_at double precision NOT NULL DEFAULT 0,
|
||||
meters_at double precision NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
note text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS maintenance_log_host ON maintenance_log (printer_host, created_at DESC);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE printers ADD COLUMN IF NOT EXISTS start_window jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -0,0 +1,63 @@
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
email text NOT NULL DEFAULT '',
|
||||
notes text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
customer_id bigint REFERENCES customers (id) ON DELETE SET NULL,
|
||||
title text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'idee',
|
||||
due_at date,
|
||||
notes text NOT NULL DEFAULT '',
|
||||
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
order_id bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
|
||||
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
|
||||
qty int NOT NULL DEFAULT 1,
|
||||
unit_price double precision NOT NULL DEFAULT 0,
|
||||
material_type text NOT NULL DEFAULT '',
|
||||
color text NOT NULL DEFAULT '',
|
||||
done_ok int NOT NULL DEFAULT 0,
|
||||
done_bad int NOT NULL DEFAULT 0,
|
||||
note text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
|
||||
|
||||
INSERT INTO orders (title, status, notes, quote, created_at)
|
||||
SELECT
|
||||
p.name,
|
||||
CASE p.status
|
||||
WHEN 'bereit' THEN 'angebot'
|
||||
WHEN 'druckt' THEN 'druckt'
|
||||
WHEN 'fertig' THEN 'fertig'
|
||||
ELSE 'idee'
|
||||
END,
|
||||
p.notes,
|
||||
p.quote,
|
||||
p.created_at
|
||||
FROM projects p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.title = p.name AND o.created_at = p.created_at);
|
||||
|
||||
INSERT INTO orders (title, status, notes, created_at)
|
||||
SELECT
|
||||
i.title,
|
||||
CASE i.status WHEN 'erledigt' THEN 'fertig' ELSE 'idee' END,
|
||||
i.body,
|
||||
i.created_at
|
||||
FROM ideas i
|
||||
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.title = i.title AND o.created_at = i.created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS orders_status ON orders (status, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS order_items_order ON order_items (order_id);
|
||||
CREATE INDEX IF NOT EXISTS runs_order_item ON runs (order_item_id) WHERE order_item_id IS NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Auto-Retry und Routing-Hinweise in der Warteschlange.
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS retry_count integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS wait_reason text NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- PA12-CF bleibt in Grotti die echte Sorte. Der ACE kennt nur PA6-CF.
|
||||
UPDATE materials
|
||||
SET material_type = 'PA12-CF'
|
||||
WHERE name = 'Sunlu PA12-CF' AND material_type = 'PA';
|
||||
|
||||
UPDATE spools
|
||||
SET material_type = 'PA12-CF'
|
||||
WHERE name = 'Sunlu PA12-CF' AND material_type = 'PA';
|
||||
@@ -17,6 +17,16 @@ SLOT_CACHE = {}
|
||||
MESH_CACHE = {}
|
||||
OBJECT_CACHE = {}
|
||||
THUMB_RE_LIMIT = 180_000
|
||||
_CACHE_MAX = 128
|
||||
|
||||
|
||||
def _trim_cache(cache, max_items=_CACHE_MAX):
|
||||
"""Einfache Größenbegrenzung (älteste Keys zuerst bei dict-Reihenfolge)."""
|
||||
while len(cache) > max_items:
|
||||
try:
|
||||
cache.pop(next(iter(cache)))
|
||||
except StopIteration:
|
||||
break
|
||||
|
||||
|
||||
def matrix_rows(points):
|
||||
@@ -50,31 +60,6 @@ def _raw_matrix(points):
|
||||
return []
|
||||
|
||||
|
||||
def fetch_bed_mesh(host):
|
||||
"""Roh-Bettnetz von Moonraker (ohne UI-Spiegelung)."""
|
||||
url = "http://%s/printer/objects/query?bed_mesh" % host
|
||||
payload = fetch_json(url, timeout=10)
|
||||
status = (payload.get("result") or {}).get("status") or {}
|
||||
mesh = status.get("bed_mesh") or {}
|
||||
matrix = _raw_matrix(mesh.get("probed_matrix"))
|
||||
if not matrix:
|
||||
matrix = _raw_matrix(mesh.get("mesh_matrix"))
|
||||
mesh_min = mesh.get("mesh_min")
|
||||
mesh_max = mesh.get("mesh_max")
|
||||
if not (isinstance(mesh_min, (list, tuple)) and len(mesh_min) >= 2):
|
||||
mesh_min = None
|
||||
if not (isinstance(mesh_max, (list, tuple)) and len(mesh_max) >= 2):
|
||||
mesh_max = None
|
||||
if not matrix or mesh_min is None or mesh_max is None:
|
||||
return None
|
||||
return {
|
||||
"matrix": matrix,
|
||||
"mesh_min": [float(mesh_min[0]), float(mesh_min[1])],
|
||||
"mesh_max": [float(mesh_max[0]), float(mesh_max[1])],
|
||||
"profile": mesh.get("profile_name") or "",
|
||||
}
|
||||
|
||||
|
||||
def store_mesh(host, shown, profile=""):
|
||||
if not shown:
|
||||
return
|
||||
@@ -118,11 +103,6 @@ class PrinterError(Exception):
|
||||
self.message = message
|
||||
|
||||
|
||||
def is_timeout(exc):
|
||||
text = (getattr(exc, "message", None) or str(exc) or "").lower()
|
||||
return "zu lange nicht geantwortet" in text or "timed out" in text
|
||||
|
||||
|
||||
def fetch_json(url, timeout=4):
|
||||
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
@@ -373,6 +353,11 @@ def steuern(host, action, params=None):
|
||||
"""Whitelist-Steuerung ohne freie Konsole."""
|
||||
params = params if isinstance(params, dict) else {}
|
||||
action = (action or "").strip()
|
||||
# MCP und ältere Clients schicken "value" statt temp/percent.
|
||||
if "temp" not in params and params.get("value") is not None and action in ("nozzle", "bed", "dryer_start"):
|
||||
params = {**params, "temp": params["value"]}
|
||||
if "percent" not in params and params.get("value") is not None and action in ("speed", "flow", "fan"):
|
||||
params = {**params, "percent": params["value"]}
|
||||
|
||||
def pct(key, default=100, lo=0, hi=200):
|
||||
try:
|
||||
@@ -522,6 +507,7 @@ def metadata(host, path):
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
||||
data = {}
|
||||
META_CACHE[key] = (now, data)
|
||||
_trim_cache(META_CACHE)
|
||||
return data
|
||||
|
||||
|
||||
@@ -548,9 +534,11 @@ def embedded_preview(host, path):
|
||||
text = response.read().decode("utf-8", "replace")
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
EMBEDDED[key] = None
|
||||
_trim_cache(EMBEDDED)
|
||||
return None
|
||||
png = embedded_png(text)
|
||||
EMBEDDED[key] = png
|
||||
_trim_cache(EMBEDDED)
|
||||
return png
|
||||
|
||||
|
||||
@@ -776,6 +764,16 @@ def fan_values(status):
|
||||
return speed, rpm
|
||||
|
||||
|
||||
def _heater_power(entry):
|
||||
"""Klipper-Einschaltdauer 0…1 (ED), sonst None."""
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
power = entry.get("power")
|
||||
if isinstance(power, (int, float)) and not isinstance(power, bool):
|
||||
return max(0.0, min(1.0, float(power)))
|
||||
return None
|
||||
|
||||
|
||||
def _object_names(host):
|
||||
now = time.time()
|
||||
cached = OBJECT_CACHE.get(host)
|
||||
@@ -891,6 +889,81 @@ def enrich_error_message(host, stats, status):
|
||||
return " · ".join(bits) if bits else (raw or "Unbekannter Fehler — in Mainsail prüfen")
|
||||
|
||||
|
||||
_FILAMENT_HINT_RE = re.compile(
|
||||
r"filament|runout|out of fil|no fil|fil.?empty|10107|"
|
||||
r"material.?leer|spule.?leer|filament.?gerissen|filament.?fehlt|"
|
||||
r"filament.?sensor|out of material",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def last_filament_gcode_hint(host, limit=40):
|
||||
"""Letzte G-code-Zeile, die auf Filament-Ende / Runout hindeutet."""
|
||||
try:
|
||||
payload = fetch_json("http://%s/server/gcode_store?count=%d" % (host, int(limit)), timeout=3)
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError):
|
||||
return ""
|
||||
items = ((payload.get("result") or {}).get("gcode_store") or [])
|
||||
for item in reversed(items):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
text = (item.get("message") or "").strip()
|
||||
if not text or not _FILAMENT_HINT_RE.search(text):
|
||||
continue
|
||||
low = text.lower()
|
||||
if "10107" in low:
|
||||
return "Filament gerissen oder leer (10107)"
|
||||
return text[:160]
|
||||
return ""
|
||||
|
||||
|
||||
def filament_out_hint(mmu, message="", host=None):
|
||||
"""Vermutetes Material-Ende bei Pause: (code, Text) oder (None, None).
|
||||
|
||||
Quellen: print_stats.message, ACE/mmu (active_filament.empty, gate_status),
|
||||
optional G-code-Store (nur wenn host gesetzt).
|
||||
"""
|
||||
text = (message or "").strip()
|
||||
if text and _FILAMENT_HINT_RE.search(text):
|
||||
if "10107" in text.lower():
|
||||
return "message", "Filament gerissen oder leer (10107)"
|
||||
return "message", text[:160]
|
||||
|
||||
if isinstance(mmu, dict) and mmu:
|
||||
active = mmu.get("active_filament")
|
||||
if isinstance(active, dict) and (
|
||||
active.get("empty") is True
|
||||
or str(active.get("empty") or "").lower() in ("1", "true")
|
||||
):
|
||||
return "active_empty", "Aktives Filament leer (ACE)"
|
||||
|
||||
tool = mmu.get("tool")
|
||||
if not isinstance(tool, (int, float)) or isinstance(tool, bool):
|
||||
tool = mmu.get("gate")
|
||||
if isinstance(tool, (int, float)) and not isinstance(tool, bool) and int(tool) >= 0:
|
||||
tool_i = int(tool)
|
||||
states = mmu.get("gate_status")
|
||||
if isinstance(states, list) and tool_i < len(states) and states[tool_i] == 0:
|
||||
return "gate_empty", "ACE-Platz %d leer" % (tool_i + 1)
|
||||
|
||||
# ACE pausiert und kein aktives Filament → oft Runout ohne leeren gate_status
|
||||
if mmu.get("is_paused") and isinstance(active, dict):
|
||||
kind = (
|
||||
active.get("filament_type")
|
||||
or active.get("type")
|
||||
or active.get("material")
|
||||
or ""
|
||||
)
|
||||
if not str(kind).strip():
|
||||
return "mmu_paused", "ACE pausiert — kein Filament aktiv"
|
||||
|
||||
if host:
|
||||
hint = last_filament_gcode_hint(host)
|
||||
if hint:
|
||||
return "gcode", hint
|
||||
return None, None
|
||||
|
||||
|
||||
def exclude_status(host):
|
||||
"""Klipper [exclude_object] — leer, wenn Modul fehlt oder Query scheitert."""
|
||||
empty = {"objects": [], "excluded": [], "current": None}
|
||||
@@ -1057,6 +1130,15 @@ def query(host):
|
||||
message = stats.get("message") or ""
|
||||
if (state or "").lower() == "error":
|
||||
message = enrich_error_message(host, stats, status)
|
||||
pause_code, pause_reason = None, ""
|
||||
filament_empty = False
|
||||
if (state or "").lower() == "paused":
|
||||
# G-code-Store nur bei Pause abfragen — sonst unnötige Last.
|
||||
pause_code, pause_reason = filament_out_hint(mmu, message, host=host)
|
||||
filament_empty = bool(pause_code)
|
||||
if filament_empty and pause_reason:
|
||||
if not message or message.lower() in ("paused", "pause", "pausiert"):
|
||||
message = pause_reason
|
||||
toolhead = status.get("toolhead") or {}
|
||||
axis_max = toolhead.get("axis_maximum") or []
|
||||
z_max = None
|
||||
@@ -1066,6 +1148,8 @@ def query(host):
|
||||
"ok": True,
|
||||
"state": state,
|
||||
"message": message,
|
||||
"filament_empty": filament_empty,
|
||||
"pause_reason": pause_reason or "",
|
||||
"homed_axes": toolhead.get("homed_axes") or "",
|
||||
"position": toolhead.get("position") or [],
|
||||
"z_max": z_max,
|
||||
@@ -1078,8 +1162,10 @@ def query(host):
|
||||
"elapsed": stats.get("print_duration") or 0,
|
||||
"nozzle": extruder.get("temperature"),
|
||||
"nozzle_target": extruder.get("target"),
|
||||
"nozzle_power": _heater_power(extruder),
|
||||
"bed": bed.get("temperature"),
|
||||
"bed_target": bed.get("target"),
|
||||
"bed_power": _heater_power(bed),
|
||||
"chamber": chamber,
|
||||
"chamber_target": chamber_target,
|
||||
"filament_used": filament_used,
|
||||
@@ -1135,11 +1221,15 @@ def snapshot(printers):
|
||||
}
|
||||
try:
|
||||
card.update(query(printer["host"]))
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as error:
|
||||
except Exception as error:
|
||||
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
|
||||
print("snapshot %s: %s" % (printer.get("host"), error), flush=True)
|
||||
card.update({
|
||||
"ok": False,
|
||||
"state": "offline",
|
||||
"message": str(error),
|
||||
"filament_empty": False,
|
||||
"pause_reason": "",
|
||||
"camera": "",
|
||||
"has_camera": False,
|
||||
"fan": None,
|
||||
@@ -1183,20 +1273,23 @@ def camera_image(host):
|
||||
return CAMERA_CACHE[host][2]
|
||||
|
||||
|
||||
def open_camera_stream(host):
|
||||
"""Öffnet den MJPEG-Stream am Drucker. Aufrufer schließt die Response."""
|
||||
url = "http://%s/webcam/?action=stream" % host
|
||||
request = urllib.request.Request(url)
|
||||
return urllib.request.urlopen(request, timeout=30)
|
||||
|
||||
|
||||
def _camera_fields(host, quoted_host):
|
||||
"""Kamera-URL nur, wenn nicht als tot bekannt (Cache-Miss = erst versuchen)."""
|
||||
snap = "/api/v1/kamera?host=%s" % quoted_host
|
||||
stream = "/api/v1/kamera/stream?host=%s" % quoted_host
|
||||
cached = CAMERA_CACHE.get(host)
|
||||
if cached is not None and cached[2] is None:
|
||||
return {"camera": "", "has_camera": False}
|
||||
return {"camera": "", "camera_stream": "", "has_camera": False}
|
||||
if cached is not None and cached[2] is not None:
|
||||
return {
|
||||
"camera": "/api/v1/kamera?host=%s" % quoted_host,
|
||||
"has_camera": True,
|
||||
}
|
||||
return {
|
||||
"camera": "/api/v1/kamera?host=%s" % quoted_host,
|
||||
"has_camera": None,
|
||||
}
|
||||
return {"camera": snap, "camera_stream": stream, "has_camera": True}
|
||||
return {"camera": snap, "camera_stream": stream, "has_camera": None}
|
||||
|
||||
|
||||
def embedded_bytes(host, name):
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Telegram-Benachrichtigungen (Bot API)."""
|
||||
"""Telegram- und ntfy-Benachrichtigungen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@@ -33,7 +34,12 @@ def send_telegram(cfg, text):
|
||||
return False
|
||||
|
||||
|
||||
ALERT_GROUP = {"abbruch": "fehler"}
|
||||
ALERT_GROUP = {
|
||||
"abbruch": "fehler",
|
||||
"bedarf": "queue",
|
||||
"wartung": "queue",
|
||||
"auftrag": "queue",
|
||||
}
|
||||
DEFAULT_OFF = {"ablauf", "start"}
|
||||
|
||||
|
||||
@@ -43,11 +49,52 @@ def alert_enabled(cfg, kind):
|
||||
return bool(alerts.get(key, key not in DEFAULT_OFF))
|
||||
|
||||
|
||||
def ntfy_configured(cfg):
|
||||
ntfy = (cfg or {}).get("ntfy") or {}
|
||||
return bool(ntfy.get("enabled") and ntfy.get("topic"))
|
||||
|
||||
|
||||
def send_ntfy(cfg, text):
|
||||
if not ntfy_configured(cfg):
|
||||
return False
|
||||
ntfy = cfg["ntfy"]
|
||||
server = str(ntfy.get("server") or "https://ntfy.sh").rstrip("/")
|
||||
topic = str(ntfy.get("topic") or "").strip()
|
||||
if not topic:
|
||||
return False
|
||||
url = "%s/%s" % (server, urllib.parse.quote(topic, safe=""))
|
||||
headers = {"Title": "Grotti", "Priority": "default"}
|
||||
token = str(ntfy.get("token") or "").strip()
|
||||
if token:
|
||||
headers["Authorization"] = "Bearer %s" % token
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=text[:4000].encode("utf-8"),
|
||||
method="POST",
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=12) as response:
|
||||
return 200 <= response.status < 300
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _push_alerts(cfg, message):
|
||||
send_telegram(cfg, message)
|
||||
send_ntfy(cfg, message)
|
||||
|
||||
|
||||
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60, run_id=None):
|
||||
"""Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster."""
|
||||
"""Ereignis immer speichern; Telegram/ntfy nur einmal pro Art und Drucker im Zeitfenster."""
|
||||
from grotti import store
|
||||
|
||||
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s)
|
||||
store.add_event(conn, host, printer, kind, message, filename or "", run_id=run_id)
|
||||
if not recent and alert_enabled(cfg, kind):
|
||||
send_telegram(cfg, message)
|
||||
threading.Thread(
|
||||
target=_push_alerts,
|
||||
args=(cfg, message),
|
||||
name="grotti-notify",
|
||||
daemon=True,
|
||||
).start()
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Aufträge, Kunden und Positionen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from psycopg.types.json import Json
|
||||
|
||||
from grotti import store
|
||||
|
||||
ORDER_STATUSES = frozenset({
|
||||
"idee",
|
||||
"angebot",
|
||||
"bestaetigt",
|
||||
"druckt",
|
||||
"fertig",
|
||||
"geliefert",
|
||||
"bezahlt",
|
||||
})
|
||||
|
||||
PROJECT_TO_ORDER = {
|
||||
"idee": "idee",
|
||||
"bereit": "angebot",
|
||||
"druckt": "druckt",
|
||||
"fertig": "fertig",
|
||||
}
|
||||
|
||||
IDEA_TO_ORDER = {
|
||||
"offen": "idee",
|
||||
"spaeter": "idee",
|
||||
"erledigt": "fertig",
|
||||
}
|
||||
|
||||
|
||||
def normalize_status(value):
|
||||
s = (value or "idee").strip().lower()
|
||||
if s not in ORDER_STATUSES:
|
||||
raise ValueError("Unbekannter Auftragsstatus.")
|
||||
return s
|
||||
|
||||
|
||||
def customer_out(row):
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"email": row.get("email") or "",
|
||||
"notes": row.get("notes") or "",
|
||||
"created_at": store.iso(row["created_at"]),
|
||||
}
|
||||
|
||||
|
||||
def order_item_out(row, file_name=None):
|
||||
return {
|
||||
"id": row["id"],
|
||||
"order_id": row["order_id"],
|
||||
"file_id": row.get("file_id"),
|
||||
"file_name": file_name or "",
|
||||
"qty": int(row.get("qty") or 1),
|
||||
"unit_price": float(row.get("unit_price") or 0),
|
||||
"material_type": row.get("material_type") or "",
|
||||
"color": row.get("color") or "",
|
||||
"done_ok": int(row.get("done_ok") or 0),
|
||||
"done_bad": int(row.get("done_bad") or 0),
|
||||
"note": row.get("note") or "",
|
||||
}
|
||||
|
||||
|
||||
def order_out(row, customer=None, items=None, progress=None):
|
||||
data = {
|
||||
"id": row["id"],
|
||||
"customer_id": row.get("customer_id"),
|
||||
"title": row["title"],
|
||||
"status": row["status"],
|
||||
"due_at": row["due_at"].isoformat() if row.get("due_at") else None,
|
||||
"notes": row.get("notes") or "",
|
||||
"quote": row.get("quote") or {},
|
||||
"created_at": store.iso(row["created_at"]),
|
||||
"updated_at": store.iso(row.get("updated_at") or row["created_at"]),
|
||||
}
|
||||
if customer is not None:
|
||||
data["customer"] = customer
|
||||
if items is not None:
|
||||
data["items"] = items
|
||||
if progress is not None:
|
||||
data["progress"] = progress
|
||||
return data
|
||||
|
||||
|
||||
def order_progress(conn, order_id):
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(SUM(qty), 0) AS target,
|
||||
COALESCE(SUM(LEAST(done_ok, qty)), 0) AS done_ok,
|
||||
COALESCE(SUM(done_bad), 0) AS done_bad
|
||||
FROM order_items
|
||||
WHERE order_id = %s
|
||||
""",
|
||||
(order_id,),
|
||||
).fetchone()
|
||||
target = int(row["target"] or 0)
|
||||
done = int(row["done_ok"] or 0)
|
||||
return {
|
||||
"target": target,
|
||||
"done": done,
|
||||
"done_bad": int(row["done_bad"] or 0),
|
||||
"label": "%d von %d" % (done, target) if target else "0 von 0",
|
||||
}
|
||||
|
||||
|
||||
def list_customers(conn):
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM customers ORDER BY name, id"
|
||||
).fetchall()
|
||||
return [customer_out(r) for r in rows]
|
||||
|
||||
|
||||
def get_customer(conn, customer_id):
|
||||
row = conn.execute("SELECT * FROM customers WHERE id = %s", (customer_id,)).fetchone()
|
||||
return customer_out(row) if row else None
|
||||
|
||||
|
||||
def create_customer(conn, name, email="", notes=""):
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO customers (name, email, notes)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(name.strip(), (email or "").strip(), (notes or "").strip()),
|
||||
).fetchone()
|
||||
return customer_out(row)
|
||||
|
||||
|
||||
def update_customer(conn, customer_id, fields):
|
||||
sets = []
|
||||
values = []
|
||||
for key in ("name", "email", "notes"):
|
||||
if key in fields:
|
||||
sets.append("%s = %%s" % key)
|
||||
values.append(fields[key])
|
||||
if not sets:
|
||||
return get_customer(conn, customer_id)
|
||||
values.append(customer_id)
|
||||
row = conn.execute(
|
||||
"UPDATE customers SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
|
||||
values,
|
||||
).fetchone()
|
||||
return customer_out(row) if row else None
|
||||
|
||||
|
||||
def delete_customer(conn, customer_id):
|
||||
row = conn.execute(
|
||||
"DELETE FROM customers WHERE id = %s RETURNING id",
|
||||
(customer_id,),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def list_orders(conn, status=None):
|
||||
where = ""
|
||||
params = []
|
||||
if status:
|
||||
where = "WHERE o.status = %s"
|
||||
params.append(status)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT o.*, c.name AS customer_name
|
||||
FROM orders o
|
||||
LEFT JOIN customers c ON c.id = o.customer_id
|
||||
%s
|
||||
ORDER BY o.updated_at DESC, o.id DESC
|
||||
""" % where,
|
||||
params,
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
prog = order_progress(conn, row["id"])
|
||||
customer = None
|
||||
if row.get("customer_id"):
|
||||
customer = {"id": row["customer_id"], "name": row.get("customer_name") or ""}
|
||||
out.append(order_out(row, customer=customer, progress=prog))
|
||||
return out
|
||||
|
||||
|
||||
def get_order(conn, order_id):
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT o.*, c.name AS customer_name, c.email AS customer_email
|
||||
FROM orders o
|
||||
LEFT JOIN customers c ON c.id = o.customer_id
|
||||
WHERE o.id = %s
|
||||
""",
|
||||
(order_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
items_rows = conn.execute(
|
||||
"""
|
||||
SELECT oi.*, f.name AS file_name
|
||||
FROM order_items oi
|
||||
LEFT JOIN files f ON f.id = oi.file_id
|
||||
WHERE oi.order_id = %s
|
||||
ORDER BY oi.id
|
||||
""",
|
||||
(order_id,),
|
||||
).fetchall()
|
||||
items = [order_item_out(r, r.get("file_name")) for r in items_rows]
|
||||
customer = None
|
||||
if row.get("customer_id"):
|
||||
customer = {
|
||||
"id": row["customer_id"],
|
||||
"name": row.get("customer_name") or "",
|
||||
"email": row.get("customer_email") or "",
|
||||
}
|
||||
return order_out(
|
||||
row,
|
||||
customer=customer,
|
||||
items=items,
|
||||
progress=order_progress(conn, order_id),
|
||||
)
|
||||
|
||||
|
||||
def create_order(conn, title, status="idee", customer_id=None, due_at=None, notes="", quote=None):
|
||||
status = normalize_status(status)
|
||||
due = None
|
||||
if due_at:
|
||||
if isinstance(due_at, date):
|
||||
due = due_at
|
||||
else:
|
||||
due = date.fromisoformat(str(due_at)[:10])
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO orders (customer_id, title, status, due_at, notes, quote)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
customer_id,
|
||||
title.strip(),
|
||||
status,
|
||||
due,
|
||||
(notes or "").strip(),
|
||||
Json(quote if isinstance(quote, dict) else {}),
|
||||
),
|
||||
).fetchone()
|
||||
return get_order(conn, row["id"])
|
||||
|
||||
|
||||
def update_order(conn, order_id, fields):
|
||||
sets = ["updated_at = now()"]
|
||||
values = []
|
||||
if "title" in fields:
|
||||
sets.append("title = %s")
|
||||
values.append(fields["title"])
|
||||
if "status" in fields:
|
||||
sets.append("status = %s")
|
||||
values.append(normalize_status(fields["status"]))
|
||||
if "customer_id" in fields:
|
||||
sets.append("customer_id = %s")
|
||||
values.append(fields["customer_id"])
|
||||
if "due_at" in fields:
|
||||
sets.append("due_at = %s")
|
||||
due = fields["due_at"]
|
||||
if due in ("", None):
|
||||
values.append(None)
|
||||
elif isinstance(due, date):
|
||||
values.append(due)
|
||||
else:
|
||||
values.append(date.fromisoformat(str(due)[:10]))
|
||||
if "notes" in fields:
|
||||
sets.append("notes = %s")
|
||||
values.append(fields["notes"] or "")
|
||||
if "quote" in fields:
|
||||
sets.append("quote = %s")
|
||||
values.append(Json(fields["quote"] if isinstance(fields["quote"], dict) else {}))
|
||||
if len(sets) <= 1:
|
||||
return get_order(conn, order_id)
|
||||
values.append(order_id)
|
||||
row = conn.execute(
|
||||
"UPDATE orders SET %s WHERE id = %%s RETURNING id" % ", ".join(sets),
|
||||
values,
|
||||
).fetchone()
|
||||
return get_order(conn, row["id"]) if row else None
|
||||
|
||||
|
||||
def delete_order(conn, order_id):
|
||||
row = conn.execute("DELETE FROM orders WHERE id = %s RETURNING id", (order_id,)).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def create_order_item(conn, order_id, file_id=None, qty=1, unit_price=0, material_type="", color="", note=""):
|
||||
if not conn.execute("SELECT 1 FROM orders WHERE id = %s", (order_id,)).fetchone():
|
||||
return None
|
||||
try:
|
||||
qty_n = max(1, int(qty))
|
||||
except (TypeError, ValueError):
|
||||
qty_n = 1
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO order_items (
|
||||
order_id, file_id, qty, unit_price, material_type, color, note
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
order_id,
|
||||
file_id,
|
||||
qty_n,
|
||||
float(unit_price or 0),
|
||||
(material_type or "").strip(),
|
||||
(color or "").strip(),
|
||||
(note or "").strip(),
|
||||
),
|
||||
).fetchone()
|
||||
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (order_id,))
|
||||
file_name = ""
|
||||
if file_id:
|
||||
f = store.get_file_row(conn, file_id)
|
||||
file_name = (f or {}).get("name") or ""
|
||||
return order_item_out(row, file_name)
|
||||
|
||||
|
||||
def get_order_item(conn, item_id):
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT oi.*, f.name AS file_name
|
||||
FROM order_items oi
|
||||
LEFT JOIN files f ON f.id = oi.file_id
|
||||
WHERE oi.id = %s
|
||||
""",
|
||||
(item_id,),
|
||||
).fetchone()
|
||||
return order_item_out(row, row.get("file_name")) if row else None
|
||||
|
||||
|
||||
def update_order_item(conn, item_id, fields):
|
||||
sets = []
|
||||
values = []
|
||||
for key in ("file_id", "material_type", "color", "note"):
|
||||
if key in fields:
|
||||
sets.append("%s = %%s" % key)
|
||||
values.append(fields[key])
|
||||
if "qty" in fields:
|
||||
try:
|
||||
qty_n = max(1, int(fields["qty"]))
|
||||
except (TypeError, ValueError):
|
||||
qty_n = 1
|
||||
sets.append("qty = %s")
|
||||
values.append(qty_n)
|
||||
if "unit_price" in fields:
|
||||
sets.append("unit_price = %s")
|
||||
values.append(float(fields["unit_price"] or 0))
|
||||
if not sets:
|
||||
return get_order_item(conn, item_id)
|
||||
values.append(item_id)
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE order_items SET %s
|
||||
WHERE id = %%s
|
||||
RETURNING order_id
|
||||
""" % ", ".join(sets),
|
||||
values,
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (row["order_id"],))
|
||||
return get_order_item(conn, item_id)
|
||||
|
||||
|
||||
def delete_order_item(conn, item_id):
|
||||
row = conn.execute(
|
||||
"DELETE FROM order_items WHERE id = %s RETURNING order_id",
|
||||
(item_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (row["order_id"],))
|
||||
return bool(row)
|
||||
|
||||
|
||||
def apply_verdict_delta(conn, order_item_id, old_ok, old_bad, new_ok, new_bad):
|
||||
"""Erhöht done_ok/done_bad an der Position — nur Delta zum vorherigen Lauf-Stand."""
|
||||
if not order_item_id:
|
||||
return
|
||||
d_ok = int(new_ok or 0) - int(old_ok or 0)
|
||||
d_bad = int(new_bad or 0) - int(old_bad or 0)
|
||||
if d_ok == 0 and d_bad == 0:
|
||||
return
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE order_items
|
||||
SET done_ok = done_ok + %s, done_bad = done_bad + %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(d_ok, d_bad, order_item_id),
|
||||
)
|
||||
item = conn.execute(
|
||||
"SELECT order_id FROM order_items WHERE id = %s",
|
||||
(order_item_id,),
|
||||
).fetchone()
|
||||
if item:
|
||||
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (item["order_id"],))
|
||||
|
||||
|
||||
def enqueue_order_item(
|
||||
conn,
|
||||
order_id,
|
||||
item_id,
|
||||
host="",
|
||||
family="",
|
||||
priority=100,
|
||||
spool_id=None,
|
||||
ace_map=None,
|
||||
bed_leveling=False,
|
||||
override=False,
|
||||
):
|
||||
item = conn.execute(
|
||||
"SELECT * FROM order_items WHERE id = %s AND order_id = %s",
|
||||
(item_id, order_id),
|
||||
).fetchone()
|
||||
if not item:
|
||||
raise ValueError("Position fehlt.")
|
||||
file_id = item.get("file_id")
|
||||
if not file_id:
|
||||
raise ValueError("Position ohne Datei.")
|
||||
if not store.get_file_row(conn, file_id):
|
||||
raise ValueError("Datei fehlt.")
|
||||
row = store.enqueue_print(
|
||||
conn,
|
||||
file_id,
|
||||
host or "",
|
||||
family or "",
|
||||
priority,
|
||||
spool_id,
|
||||
ace_map if isinstance(ace_map, list) else [],
|
||||
bool(bed_leveling),
|
||||
bool(override),
|
||||
order_item_id=item["id"],
|
||||
)
|
||||
conn.execute("UPDATE orders SET updated_at = now(), status = CASE WHEN status IN ('idee', 'angebot', 'bestaetigt') THEN 'druckt' ELSE status END WHERE id = %s", (order_id,))
|
||||
return row
|
||||
|
||||
|
||||
def attach_order_item_to_live_run(conn, host, filename, order_item_id):
|
||||
"""Verknüpft gestarteten Lauf mit Auftragsposition."""
|
||||
if not order_item_id or not host:
|
||||
return
|
||||
base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE runs
|
||||
SET order_item_id = COALESCE(order_item_id, %s)
|
||||
WHERE printer_host = %s
|
||||
AND (filename = %s OR filename LIKE %s)
|
||||
AND state IN ('printing', 'paused', 'in_progress', 'completed')
|
||||
AND ended_at IS NULL
|
||||
""",
|
||||
(order_item_id, host, base, "%/" + base),
|
||||
)
|
||||
@@ -13,6 +13,14 @@ from datetime import datetime, timezone
|
||||
from grotti import eject, jobs, moonraker, notify, store
|
||||
from grotti.db import connect
|
||||
from grotti.errors import GrottiError
|
||||
from grotti import time_window
|
||||
|
||||
WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…"
|
||||
from grotti.events_bus import publish as publish_live
|
||||
|
||||
|
||||
def _notify_queue():
|
||||
publish_live("queue", {})
|
||||
|
||||
FREE = {"standby", "complete", "cancelled"}
|
||||
BUSY = {"printing", "paused", "heating", "error"}
|
||||
@@ -20,6 +28,8 @@ BUSY = {"printing", "paused", "heating", "error"}
|
||||
KEEPALIVE_S = 120
|
||||
# Nach Start ohne print_active: Upload/MQTT/LeviQ brauchen Zeit, aber nicht ewig
|
||||
PRINT_START_TIMEOUT_S = 600
|
||||
# Einfacher Queue-Start ohne Worker (typisch Grotti-Neustart)
|
||||
SIMPLE_START_STUCK_S = 45
|
||||
|
||||
|
||||
def _ablauf_needs_motors(item):
|
||||
@@ -120,6 +130,73 @@ class PrintQueueDispatcher:
|
||||
thread = self._workers.get(item_id)
|
||||
return bool(thread and thread.is_alive())
|
||||
|
||||
def _item_phase_age_s(self, item):
|
||||
"""Alter von phase_started_at, sonst updated_at (für hängende Starts nach Neustart)."""
|
||||
started = item.get("phase_started_at")
|
||||
if started:
|
||||
if getattr(started, "tzinfo", None) is None:
|
||||
started = started.replace(tzinfo=timezone.utc)
|
||||
return (datetime.now(timezone.utc) - started).total_seconds()
|
||||
updated = item.get("updated_at")
|
||||
if updated:
|
||||
if getattr(updated, "tzinfo", None) is None:
|
||||
updated = updated.replace(tzinfo=timezone.utc)
|
||||
return (datetime.now(timezone.utc) - updated).total_seconds()
|
||||
return 0
|
||||
|
||||
def _auto_retry_enabled(self):
|
||||
return bool((self.cfg or {}).get("auto_retry", True))
|
||||
|
||||
def _try_auto_retry(self, conn, item, message):
|
||||
"""Ein automatischer Neuversuch; sonst False."""
|
||||
if not self._auto_retry_enabled():
|
||||
return False
|
||||
retries = int(item.get("retry_count") or 0)
|
||||
if retries >= 1:
|
||||
return False
|
||||
store.update_print_queue(
|
||||
conn,
|
||||
item["id"],
|
||||
{
|
||||
"status": "waiting",
|
||||
"message": "Nochmal (%s)" % (message or "Fehler"),
|
||||
"retry_count": retries + 1,
|
||||
"phase": "",
|
||||
"phase_started_at": None,
|
||||
"host": item.get("host") or "",
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
def _fail_stuck_simple_start(self, conn, item, printer):
|
||||
host = printer["host"]
|
||||
msg = "Start abgebrochen (Neustart)"
|
||||
if self._try_auto_retry(conn, item, msg):
|
||||
self._event(
|
||||
conn, host, printer["name"], "queue",
|
||||
"%s: Nochmal — %s" % (printer["name"], msg),
|
||||
item.get("filename") or "",
|
||||
)
|
||||
return
|
||||
store.update_print_queue(
|
||||
conn,
|
||||
item["id"],
|
||||
{
|
||||
"status": "error",
|
||||
"message": msg,
|
||||
"phase": "",
|
||||
"phase_started_at": None,
|
||||
},
|
||||
)
|
||||
self._event(
|
||||
conn,
|
||||
host,
|
||||
printer["name"],
|
||||
"queue",
|
||||
"%s: Start abgebrochen (Neustart)" % printer["name"],
|
||||
item.get("filename") or "",
|
||||
)
|
||||
|
||||
def _spawn_worker(self, item_id, target, *args):
|
||||
with self._worker_lock:
|
||||
existing = self._workers.get(item_id)
|
||||
@@ -129,7 +206,10 @@ class PrintQueueDispatcher:
|
||||
def run():
|
||||
try:
|
||||
target(*args)
|
||||
except Exception as exc:
|
||||
print("Queue-Worker %s: %s" % (item_id, exc), flush=True)
|
||||
finally:
|
||||
_notify_queue()
|
||||
with self._worker_lock:
|
||||
self._workers.pop(item_id, None)
|
||||
|
||||
@@ -143,6 +223,12 @@ class PrintQueueDispatcher:
|
||||
return True
|
||||
|
||||
def tick(self):
|
||||
try:
|
||||
self._tick_once()
|
||||
finally:
|
||||
_notify_queue()
|
||||
|
||||
def _tick_once(self):
|
||||
with connect(self.database_url) as conn:
|
||||
printers = store.list_printers(conn)
|
||||
if not printers:
|
||||
@@ -190,13 +276,36 @@ class PrintQueueDispatcher:
|
||||
continue
|
||||
if state not in FREE:
|
||||
continue
|
||||
if store.running_print_on_host(conn, host):
|
||||
running = store.running_print_on_host(conn, host)
|
||||
if running:
|
||||
if (
|
||||
not _has_ablauf(running)
|
||||
and (running.get("phase") or "").lower() == "starting"
|
||||
and not self._worker_busy(running["id"])
|
||||
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
|
||||
):
|
||||
self._fail_stuck_simple_start(conn, running, printer)
|
||||
conn.commit()
|
||||
continue
|
||||
if store.bed_needs_clear(conn, host):
|
||||
continue
|
||||
waiting = store.next_waiting_print(conn, host=host, family=printer.get("family") or "")
|
||||
if not waiting:
|
||||
continue
|
||||
sw = printer.get("start_window") or {}
|
||||
if sw and not time_window.allowed_now(sw):
|
||||
if (waiting.get("message") or "") != WINDOW_WAIT_MSG:
|
||||
store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG})
|
||||
continue
|
||||
est_s = 0
|
||||
if waiting.get("file_id"):
|
||||
file_row = store.get_file_row(conn, waiting["file_id"])
|
||||
if file_row:
|
||||
est_s = float(file_row.get("estimated_s") or 0)
|
||||
if sw and not time_window.job_fits_overnight(sw, est_s):
|
||||
if (waiting.get("message") or "") != WINDOW_WAIT_MSG:
|
||||
store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG})
|
||||
continue
|
||||
claimed = store.claim_print_queue(conn, waiting["id"], host)
|
||||
if not claimed:
|
||||
continue
|
||||
@@ -255,6 +364,11 @@ class PrintQueueDispatcher:
|
||||
bed_leveling=bed_leveling,
|
||||
)
|
||||
name = (result or {}).get("filename") or ""
|
||||
oid = item.get("order_item_id")
|
||||
if oid:
|
||||
from grotti import orders
|
||||
|
||||
orders.attach_order_item_to_live_run(wconn, host, name, oid)
|
||||
store.update_print_queue(
|
||||
wconn,
|
||||
item_id,
|
||||
@@ -276,6 +390,7 @@ class PrintQueueDispatcher:
|
||||
)
|
||||
wconn.commit()
|
||||
except GrottiError as exc:
|
||||
wconn.rollback()
|
||||
store.update_print_queue(
|
||||
wconn,
|
||||
item_id,
|
||||
@@ -291,6 +406,7 @@ class PrintQueueDispatcher:
|
||||
)
|
||||
wconn.commit()
|
||||
except Exception as exc:
|
||||
wconn.rollback()
|
||||
store.update_print_queue(
|
||||
wconn,
|
||||
item_id,
|
||||
@@ -684,10 +800,12 @@ class PrintQueueDispatcher:
|
||||
self._step_event(wconn, row, printer_copy, "druckt %s" % name)
|
||||
wconn.commit()
|
||||
except GrottiError as exc:
|
||||
wconn.rollback()
|
||||
row = store.get_print_queue_row(wconn, item_id) or item
|
||||
self._fail_ablauf(wconn, row, printer_copy, exc.message)
|
||||
wconn.commit()
|
||||
except Exception as exc:
|
||||
wconn.rollback()
|
||||
row = store.get_print_queue_row(wconn, item_id) or item
|
||||
self._fail_ablauf(wconn, row, printer_copy, str(exc) or "Druckstart fehlgeschlagen.")
|
||||
wconn.commit()
|
||||
@@ -742,42 +860,64 @@ class PrintQueueDispatcher:
|
||||
return state
|
||||
|
||||
def _start_cool_step(self, conn, item, printer, step):
|
||||
# Park (bis 60 s G-code) läuft im Worker, damit tick() nicht blockiert.
|
||||
host = printer["host"]
|
||||
cool = eject.normalize_cool(step)
|
||||
try:
|
||||
moonraker.gcode_script(host, eject.cool_script(cool), timeout=30)
|
||||
# Idle zuerst verlängern, dann Park (sonst Stepper aus → must home).
|
||||
try:
|
||||
moonraker.gcode_script(host, eject.idle_timeout_script(cool), timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
park = self._try_cool_park(host, item["id"], force=True)
|
||||
self._keepalive[item["id"]] = time.monotonic()
|
||||
park_note = ""
|
||||
if not park["ok"] and park["error"]:
|
||||
park_note = " — Mitte fehlgeschlagen: %s" % park["error"]
|
||||
elif park["ok"]:
|
||||
park_note = " · Düse Mitte"
|
||||
store.update_print_queue(
|
||||
conn,
|
||||
item["id"],
|
||||
{
|
||||
"phase": "cooling",
|
||||
"phase_started_at": "now",
|
||||
"message": self._ablauf_message(
|
||||
item,
|
||||
"kühlt ab (Bett ≤ %.0f °C, Düse ≤ %.0f °C)%s"
|
||||
% (cool["bed_max"], cool["nozzle_max"], park_note),
|
||||
),
|
||||
},
|
||||
)
|
||||
self._step_event(
|
||||
conn, item, printer,
|
||||
"kühlt ab bis Bett ≤ %.0f °C, Düse ≤ %.0f °C%s"
|
||||
% (cool["bed_max"], cool["nozzle_max"], park_note),
|
||||
)
|
||||
except Exception as exc:
|
||||
self._fail_ablauf(conn, item, printer, "Abkühlen: %s" % exc)
|
||||
item_id = item["id"]
|
||||
printer_copy = dict(printer)
|
||||
item_copy = dict(item)
|
||||
|
||||
def work():
|
||||
with connect(self.database_url) as wconn:
|
||||
try:
|
||||
moonraker.gcode_script(host, eject.cool_script(cool), timeout=30)
|
||||
try:
|
||||
moonraker.gcode_script(host, eject.idle_timeout_script(cool), timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
park = self._try_cool_park(host, item_id, force=True)
|
||||
self._keepalive[item_id] = time.monotonic()
|
||||
park_note = ""
|
||||
if not park["ok"] and park["error"]:
|
||||
park_note = " — Mitte fehlgeschlagen: %s" % park["error"]
|
||||
elif park["ok"]:
|
||||
park_note = " · Düse Mitte"
|
||||
row = store.get_print_queue_row(wconn, item_id) or item_copy
|
||||
store.update_print_queue(
|
||||
wconn,
|
||||
item_id,
|
||||
{
|
||||
"message": self._ablauf_message(row, "kühlt%s" % park_note),
|
||||
"phase": "cooling",
|
||||
"phase_started_at": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
wconn.commit()
|
||||
except GrottiError as exc:
|
||||
self._fail_ablauf(wconn, item_copy, printer_copy, "Abkühlen: %s" % exc.message)
|
||||
wconn.commit()
|
||||
except Exception as exc:
|
||||
self._fail_ablauf(wconn, item_copy, printer_copy, "Abkühlen: %s" % exc)
|
||||
wconn.commit()
|
||||
|
||||
store.update_print_queue(
|
||||
conn,
|
||||
item_id,
|
||||
{
|
||||
"message": self._ablauf_message(
|
||||
item,
|
||||
"kühlt ab (Bett ≤ %.0f °C, Düse ≤ %.0f °C)"
|
||||
% (cool["bed_max"], cool["nozzle_max"]),
|
||||
),
|
||||
"phase": "cooling",
|
||||
"phase_started_at": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
self._step_event(
|
||||
conn, item, printer,
|
||||
"kühlt ab bis Bett ≤ %.0f °C, Düse ≤ %.0f °C" % (cool["bed_max"], cool["nozzle_max"]),
|
||||
)
|
||||
self._spawn_worker(item_id, work)
|
||||
|
||||
def _poll_cool(self, conn, item, printer, live, step):
|
||||
cool = eject.normalize_cool(step)
|
||||
@@ -908,14 +1048,17 @@ class PrintQueueDispatcher:
|
||||
self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True})
|
||||
wconn.commit()
|
||||
except GrottiError as exc:
|
||||
wconn.rollback()
|
||||
row = store.get_print_queue_row(wconn, item_id) or item
|
||||
self._fail_ablauf(wconn, row, printer_copy, exc.message)
|
||||
wconn.commit()
|
||||
except moonraker.PrinterError as exc:
|
||||
wconn.rollback()
|
||||
row = store.get_print_queue_row(wconn, item_id) or item
|
||||
self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message))
|
||||
wconn.commit()
|
||||
except Exception as exc:
|
||||
wconn.rollback()
|
||||
row = store.get_print_queue_row(wconn, item_id) or item
|
||||
self._fail_ablauf(wconn, row, printer_copy, "Schieben: %s" % exc)
|
||||
wconn.commit()
|
||||
@@ -1013,14 +1156,17 @@ class PrintQueueDispatcher:
|
||||
self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True})
|
||||
wconn.commit()
|
||||
except GrottiError as exc:
|
||||
wconn.rollback()
|
||||
row = store.get_print_queue_row(wconn, item_id) or item
|
||||
self._fail_ablauf(wconn, row, printer_copy, exc.message)
|
||||
wconn.commit()
|
||||
except moonraker.PrinterError as exc:
|
||||
wconn.rollback()
|
||||
row = store.get_print_queue_row(wconn, item_id) or item
|
||||
self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message))
|
||||
wconn.commit()
|
||||
except Exception as exc:
|
||||
wconn.rollback()
|
||||
row = store.get_print_queue_row(wconn, item_id) or item
|
||||
self._fail_ablauf(wconn, row, printer_copy, "Tasten: %s" % exc)
|
||||
wconn.commit()
|
||||
@@ -1121,6 +1267,16 @@ class PrintQueueDispatcher:
|
||||
self._fail_ablauf(conn, item, printer, "Unbekannter Schritt: %s" % kind)
|
||||
|
||||
def _fail_ablauf(self, conn, item, printer, message):
|
||||
if self._try_auto_retry(conn, item, message):
|
||||
self._event(
|
||||
conn,
|
||||
printer["host"],
|
||||
printer["name"],
|
||||
"queue",
|
||||
"%s Nochmal — %s" % (printer["name"], message),
|
||||
item.get("filename") or "",
|
||||
)
|
||||
return
|
||||
store.update_print_queue(
|
||||
conn,
|
||||
item["id"],
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Die Drucker: Familie aus dem Namen, die Liste selbst liegt in PostgreSQL."""
|
||||
|
||||
import json
|
||||
|
||||
FAMILY_LABELS = {"kobra_s1": "Kobra S1", "kobra_3": "Kobra 3"}
|
||||
|
||||
|
||||
@@ -21,24 +19,3 @@ def family_from_text(text):
|
||||
if "kobra 3" in low or "kobra3" in low:
|
||||
return "kobra_3"
|
||||
return ""
|
||||
|
||||
|
||||
def load_printers(path):
|
||||
rows = json.loads(path.read_text(encoding="utf-8"))
|
||||
printers = []
|
||||
for row in rows:
|
||||
family = row.get("family") or family_from_name(row["name"])
|
||||
printers.append({
|
||||
"name": row["name"],
|
||||
"host": row["host"],
|
||||
"family": family,
|
||||
"family_label": FAMILY_LABELS.get(family, family),
|
||||
})
|
||||
return printers
|
||||
|
||||
|
||||
def printer_by_host(path, host):
|
||||
for printer in load_printers(path):
|
||||
if printer["host"] == host:
|
||||
return printer
|
||||
return None
|
||||
@@ -1,3 +1,6 @@
|
||||
-- Referenz-Schema. Laufende Migrationen liegen in grotti/migrations/*.sql
|
||||
-- (001_base.sql entspricht diesem Stand, danach nummerierte Änderungen).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
@@ -169,6 +172,8 @@ ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_total integer NOT NULL D
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_done integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase text NOT NULL DEFAULT '';
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase_started_at timestamptz;
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS retry_count integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS wait_reason text NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS print_queue_waiting ON print_queue (status, priority, id);
|
||||
CREATE INDEX IF NOT EXISTS print_queue_running_host ON print_queue (host, status) WHERE status = 'running';
|
||||
@@ -282,11 +287,16 @@ CREATE TABLE IF NOT EXISTS run_samples (
|
||||
layer integer,
|
||||
progress double precision,
|
||||
filament_used_mm double precision,
|
||||
mmu_tool smallint
|
||||
mmu_tool smallint,
|
||||
nozzle_power double precision,
|
||||
bed_power double precision
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at);
|
||||
|
||||
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS nozzle_power double precision;
|
||||
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS bed_power double precision;
|
||||
|
||||
-- Objekt-Chargen für Etiketten
|
||||
CREATE TABLE IF NOT EXISTS run_labels (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
@@ -298,3 +308,67 @@ CREATE TABLE IF NOT EXISTS run_labels (
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS run_labels_chargen ON run_labels (chargen_nr);
|
||||
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS verdict_reason text NOT NULL DEFAULT '';
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_ok int NOT NULL DEFAULT 0;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_bad int NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS maintenance_parts (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
printer_host text NOT NULL,
|
||||
part_key text NOT NULL,
|
||||
label text NOT NULL DEFAULT '',
|
||||
installed_at timestamptz NOT NULL DEFAULT now(),
|
||||
life_hours double precision NOT NULL DEFAULT 0,
|
||||
life_meters double precision NOT NULL DEFAULT 0,
|
||||
note text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS maintenance_log (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
printer_host text NOT NULL,
|
||||
part_key text NOT NULL,
|
||||
event text NOT NULL DEFAULT '',
|
||||
hours_at double precision NOT NULL DEFAULT 0,
|
||||
meters_at double precision NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
note text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
ALTER TABLE printers ADD COLUMN IF NOT EXISTS start_window jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
email text NOT NULL DEFAULT '',
|
||||
notes text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
customer_id bigint REFERENCES customers (id) ON DELETE SET NULL,
|
||||
title text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'idee',
|
||||
due_at date,
|
||||
notes text NOT NULL DEFAULT '',
|
||||
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
order_id bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
|
||||
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
|
||||
qty int NOT NULL DEFAULT 1,
|
||||
unit_price double precision NOT NULL DEFAULT 0,
|
||||
material_type text NOT NULL DEFAULT '',
|
||||
color text NOT NULL DEFAULT '',
|
||||
done_ok int NOT NULL DEFAULT 0,
|
||||
done_bad int NOT NULL DEFAULT 0,
|
||||
note text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
|
||||
@@ -11,18 +11,26 @@ from queue import Empty, Queue
|
||||
from grotti import jobs
|
||||
from grotti.db import connect
|
||||
from grotti.errors import GrottiError
|
||||
from grotti.events_bus import publish as publish_live
|
||||
|
||||
|
||||
class SendQueue:
|
||||
def __init__(self, database_url, cfg, workers=4):
|
||||
self.database_url = database_url
|
||||
self.cfg = cfg
|
||||
self._workers_n = max(1, int(workers))
|
||||
self._lock = threading.Lock()
|
||||
self._pending = Queue()
|
||||
self._jobs = {}
|
||||
self._order = deque(maxlen=60)
|
||||
self._threads = []
|
||||
for index in range(max(1, int(workers))):
|
||||
self._stop = threading.Event()
|
||||
|
||||
def start(self):
|
||||
if self._threads:
|
||||
return
|
||||
self._stop.clear()
|
||||
for index in range(self._workers_n):
|
||||
thread = threading.Thread(
|
||||
target=self._worker,
|
||||
name="grotti-send-queue-%s" % (index + 1),
|
||||
@@ -31,6 +39,14 @@ class SendQueue:
|
||||
thread.start()
|
||||
self._threads.append(thread)
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
for _ in self._threads:
|
||||
try:
|
||||
self._pending.put_nowait(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
conn,
|
||||
@@ -82,9 +98,14 @@ class SendQueue:
|
||||
want_level = bool(bed_leveling)
|
||||
created = []
|
||||
for host in unique:
|
||||
printer, row, chosen = jobs.prepare_send(conn, file_id, host, True, override, spool_id)
|
||||
# Mapping zuerst, damit Materialprüfung die ACE-Spulen sieht.
|
||||
mapping = ace_maps.get(host) or []
|
||||
printer, row, chosen = jobs.prepare_send(
|
||||
conn, file_id, host, True, override, spool_id, ace_map=mapping
|
||||
)
|
||||
if not mapping:
|
||||
mapping = ace_maps.get(printer["name"]) or []
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
mapping = ace_maps.get(host) or ace_maps.get(printer["name"]) or []
|
||||
job = {
|
||||
"id": job_id,
|
||||
"status": "pending",
|
||||
@@ -141,13 +162,16 @@ class SendQueue:
|
||||
return
|
||||
job.update(fields)
|
||||
job["updated_at"] = time.time()
|
||||
publish_live("senden", {})
|
||||
|
||||
def _worker(self):
|
||||
while True:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
item = self._pending.get(timeout=1)
|
||||
except Empty:
|
||||
continue
|
||||
if item is None:
|
||||
break
|
||||
self._run(item)
|
||||
|
||||
def _run(self, item):
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Start-Zeitfenster pro Drucker (Nachtdruck)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
|
||||
def _parse_hm(text):
|
||||
text = str(text or "").strip()
|
||||
if not text or ":" not in text:
|
||||
return None
|
||||
parts = text.split(":", 1)
|
||||
try:
|
||||
h, m = int(parts[0]), int(parts[1])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not (0 <= h <= 23 and 0 <= m <= 59):
|
||||
return None
|
||||
return time(h, m)
|
||||
|
||||
|
||||
def _in_segment(now_t, start_t, end_t):
|
||||
if start_t <= end_t:
|
||||
return start_t <= now_t <= end_t
|
||||
return now_t >= start_t or now_t <= end_t
|
||||
|
||||
|
||||
def allowed_now(start_window, now=None):
|
||||
"""True wenn kein Fenster gesetzt oder die aktuelle Uhrzeit erlaubt ist."""
|
||||
window = start_window or {}
|
||||
if not isinstance(window, dict):
|
||||
return True
|
||||
allowed = window.get("allowed")
|
||||
if not allowed:
|
||||
return True
|
||||
if not isinstance(allowed, list):
|
||||
return True
|
||||
now_dt = now if isinstance(now, datetime) else datetime.now().astimezone()
|
||||
now_t = now_dt.timetz().replace(tzinfo=None)
|
||||
for entry in allowed:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
start_t = _parse_hm(entry.get("from"))
|
||||
end_t = _parse_hm(entry.get("to"))
|
||||
if start_t is None or end_t is None:
|
||||
continue
|
||||
if _in_segment(now_t, start_t, end_t):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def overnight_max_hours(start_window):
|
||||
window = start_window or {}
|
||||
if not isinstance(window, dict):
|
||||
return None
|
||||
try:
|
||||
val = float(window.get("max_hours_overnight") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return val if val > 0 else None
|
||||
|
||||
|
||||
def job_fits_overnight(start_window, estimated_s, now=None):
|
||||
"""Optional: Job darf max_hours_overnight nicht überschreiten (nur wenn gesetzt)."""
|
||||
max_h = overnight_max_hours(start_window)
|
||||
if max_h is None:
|
||||
return True
|
||||
if not estimated_s or float(estimated_s) <= 0:
|
||||
return True
|
||||
if allowed_now(start_window, now=now):
|
||||
return float(estimated_s) <= max_h * 3600
|
||||
return True
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Urteil und Ausschuss-Gründe beim Abräumen."""
|
||||
|
||||
VERDICT_REASONS = frozenset({
|
||||
"haftung",
|
||||
"warping",
|
||||
"spaghetti",
|
||||
"layer_shift",
|
||||
"verstopfung",
|
||||
"farbe",
|
||||
"sonstiges",
|
||||
})
|
||||
|
||||
|
||||
def normalize_abraeumen_verdict(verdict, verdict_reason, pieces_ok, pieces_bad):
|
||||
v = (verdict or "").strip().lower()
|
||||
if not v:
|
||||
v = "gut"
|
||||
if v not in ("gut", "ausschuss"):
|
||||
raise ValueError("Urteil muss gut oder ausschuss sein.")
|
||||
reason = (verdict_reason or "").strip().lower()
|
||||
if v == "gut":
|
||||
reason = ""
|
||||
pieces_bad = 0
|
||||
elif reason and reason not in VERDICT_REASONS:
|
||||
raise ValueError("Unbekannter Ausschuss-Grund.")
|
||||
try:
|
||||
ok_n = max(0, int(pieces_ok or 0))
|
||||
bad_n = max(0, int(pieces_bad or 0))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("Stückzahlen müssen ganze Zahlen sein.") from exc
|
||||
if v == "ausschuss" and bad_n <= 0 and ok_n <= 0:
|
||||
bad_n = 1
|
||||
return v, reason, ok_n, bad_n
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||
|
||||
__version__ = "2.5.1"
|
||||
__version__ = "3.2.0"
|
||||
@@ -7,9 +7,11 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from grotti import moonraker, notify, store, usage
|
||||
from grotti.events_bus import publish as publish_live
|
||||
from grotti.db import connect
|
||||
|
||||
SAMPLE_EVERY_S = 30
|
||||
SAMPLE_HEAT_S = 10
|
||||
|
||||
|
||||
class StateWatcher:
|
||||
@@ -61,18 +63,21 @@ class StateWatcher:
|
||||
self._stop.wait(self.interval)
|
||||
|
||||
def tick(self):
|
||||
with connect(self.database_url) as conn:
|
||||
printers = store.list_printers(conn)
|
||||
if not printers:
|
||||
try:
|
||||
with connect(self.database_url) as conn:
|
||||
printers = store.list_printers(conn)
|
||||
if not printers:
|
||||
conn.commit()
|
||||
return
|
||||
lives = moonraker.snapshot(printers)
|
||||
with self._lock:
|
||||
self.latest = lives
|
||||
self._updated_at = time.monotonic()
|
||||
for live in lives:
|
||||
self._one(conn, live)
|
||||
conn.commit()
|
||||
return
|
||||
lives = moonraker.snapshot(printers)
|
||||
with self._lock:
|
||||
self.latest = lives
|
||||
self._updated_at = time.monotonic()
|
||||
for live in lives:
|
||||
self._one(conn, live)
|
||||
conn.commit()
|
||||
finally:
|
||||
publish_live("drucker", {"at": datetime.now(timezone.utc).isoformat()})
|
||||
|
||||
def _one(self, conn, live):
|
||||
host = live.get("host") or ""
|
||||
@@ -100,9 +105,28 @@ class StateWatcher:
|
||||
and state == "printing"
|
||||
)
|
||||
if starting:
|
||||
store.abandon_live_runs(conn, host)
|
||||
if state in ("printing", "paused"):
|
||||
run = self._track_print(conn, host, name, filename, live, state)
|
||||
# Aufheiz-Proben behalten: Live-Lauf gleicher Datei nicht verwerfen
|
||||
keep = None
|
||||
existing = store.find_live_run(conn, host)
|
||||
if existing:
|
||||
same = (
|
||||
not filename
|
||||
or not existing.get("filename")
|
||||
or existing.get("filename") == filename
|
||||
)
|
||||
if same:
|
||||
keep = existing["id"]
|
||||
if filename and existing.get("filename") != filename:
|
||||
conn.execute(
|
||||
"UPDATE runs SET filename = %s WHERE id = %s",
|
||||
(filename, keep),
|
||||
)
|
||||
store.abandon_live_runs(conn, host, keep_id=keep)
|
||||
sample_now = self._should_sample(conn, host, live, state)
|
||||
heating = sample_now and state not in ("printing", "paused")
|
||||
if sample_now:
|
||||
force = starting or (heating and not prev.get("heating"))
|
||||
run = self._track_print(conn, host, name, filename, live, state, force_sample=force)
|
||||
run_id = run["id"] if run else None
|
||||
|
||||
if prev_offline:
|
||||
@@ -115,11 +139,22 @@ class StateWatcher:
|
||||
)
|
||||
self._ensure_start_segment(conn, host, filename, live, run_id)
|
||||
elif prev_state == "printing" and state == "paused":
|
||||
self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
|
||||
reason = (live.get("pause_reason") or "").strip()
|
||||
if live.get("filament_empty") or reason:
|
||||
detail = reason or "Filament leer oder gerissen"
|
||||
self._emit(
|
||||
conn, host, name, "spule",
|
||||
"%s pausiert — Material nachlegen (%s)." % (name, detail),
|
||||
filename, run_id,
|
||||
)
|
||||
else:
|
||||
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)
|
||||
elif prev_state == "printing" and state == "complete":
|
||||
store.ensure_bed_after_print(conn, host, message=filename or "fertig")
|
||||
# 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,
|
||||
host,
|
||||
@@ -135,26 +170,37 @@ class StateWatcher:
|
||||
jobs.sync_history(conn, self.cfg)
|
||||
except Exception as exc:
|
||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"):
|
||||
store.ensure_bed_after_print(conn, host, message=filename or state)
|
||||
self._emit(
|
||||
conn,
|
||||
host,
|
||||
name,
|
||||
"abbruch",
|
||||
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"),
|
||||
filename,
|
||||
run_id or prev.get("run_id"),
|
||||
)
|
||||
try:
|
||||
from grotti import jobs
|
||||
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:
|
||||
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)
|
||||
except Exception as exc:
|
||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||
jobs.sync_history(conn, self.cfg)
|
||||
except Exception as exc:
|
||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||
elif state == "error" and prev_state != "error":
|
||||
if prev_state == "printing" or prev_state == "paused":
|
||||
store.ensure_bed_after_print(conn, host, message=filename or "Fehler")
|
||||
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
|
||||
store.abandon_live_runs(conn, host)
|
||||
msg = (live.get("message") or "").strip()
|
||||
if not msg or msg.lower() in ("error", "fehler"):
|
||||
msg = "Ursache unbekannt — in Mainsail prüfen"
|
||||
@@ -178,7 +224,25 @@ class StateWatcher:
|
||||
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):
|
||||
store.ensure_bed_after_print(conn, host, message=filename or "fertig")
|
||||
# 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
|
||||
WHERE printer_host = %s
|
||||
AND state IN ('completed', 'cancelled', 'error', 'interrupted')
|
||||
AND ended_at IS NOT NULL
|
||||
ORDER BY ended_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(host,),
|
||||
).fetchone()
|
||||
store.ensure_bed_after_print(
|
||||
conn,
|
||||
host,
|
||||
ended_at=ended["ended_at"] if ended else None,
|
||||
message=filename or "fertig",
|
||||
)
|
||||
|
||||
spool_warn = bool(prev.get("spool_warn"))
|
||||
if state == "printing" and not spool_warn:
|
||||
@@ -187,12 +251,39 @@ class StateWatcher:
|
||||
self._prev[host] = {
|
||||
"state": state,
|
||||
"offline": False,
|
||||
"heating": heating,
|
||||
"filename": filename,
|
||||
"spool_warn": spool_warn if state == "printing" else False,
|
||||
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") else None),
|
||||
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") or heating else None),
|
||||
}
|
||||
|
||||
def _track_print(self, conn, host, name, filename, live, state):
|
||||
def _should_sample(self, conn, host, live, state):
|
||||
"""Proben bei Druck/Pause und beim Aufheizen mit Bezug zu einem Auftrag."""
|
||||
if state in ("printing", "paused"):
|
||||
return True
|
||||
if state in ("error", "offline", ""):
|
||||
return False
|
||||
nozzle_t = usage.as_float(live.get("nozzle_target"))
|
||||
bed_t = usage.as_float(live.get("bed_target"))
|
||||
if nozzle_t <= 40 and bed_t <= 30:
|
||||
return False
|
||||
if live.get("filename"):
|
||||
return True
|
||||
if store.running_print_on_host(conn, host):
|
||||
return True
|
||||
return bool(store.find_live_run(conn, host))
|
||||
|
||||
def _track_print(self, conn, host, name, filename, live, state, force_sample=False):
|
||||
if not filename:
|
||||
running = store.running_print_on_host(conn, host)
|
||||
if running:
|
||||
filename = running.get("filename") or ""
|
||||
if not filename:
|
||||
existing = store.find_live_run(conn, host)
|
||||
if existing:
|
||||
filename = existing.get("filename") or ""
|
||||
if not filename and state not in ("printing", "paused"):
|
||||
return None
|
||||
sent = store.latest_send(conn, host, filename) if filename else None
|
||||
spool_id = (sent or {}).get("spool_id")
|
||||
run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id)
|
||||
@@ -214,7 +305,7 @@ class StateWatcher:
|
||||
"duration_s": usage.as_float(live.get("elapsed")),
|
||||
})
|
||||
store.ensure_run_chargen(conn, run["id"])
|
||||
self._maybe_sample(conn, run["id"], live, used_mm)
|
||||
self._maybe_sample(conn, run["id"], live, used_mm, force=force_sample)
|
||||
return store.get_run_row(conn, run["id"])
|
||||
|
||||
def _merge_objects(self, current, exclude):
|
||||
@@ -243,10 +334,21 @@ class StateWatcher:
|
||||
known[name] = {"name": name, "excluded": True}
|
||||
return list(known.values())
|
||||
|
||||
def _maybe_sample(self, conn, run_id, live, used_mm):
|
||||
def _heat_gap(self, live):
|
||||
"""Abstand Ist↔Soll — während Aufheizen dichter samplen."""
|
||||
gaps = []
|
||||
for key, tkey in (("nozzle", "nozzle_target"), ("bed", "bed_target")):
|
||||
target = usage.as_float(live.get(tkey))
|
||||
if target <= 0:
|
||||
continue
|
||||
gaps.append(abs(usage.as_float(live.get(key)) - target))
|
||||
return max(gaps) if gaps else 0
|
||||
|
||||
def _maybe_sample(self, conn, run_id, live, used_mm, force=False):
|
||||
now = time.monotonic()
|
||||
last = self._last_sample.get(run_id, 0)
|
||||
if now - last < SAMPLE_EVERY_S:
|
||||
interval = SAMPLE_HEAT_S if self._heat_gap(live) > 3 else SAMPLE_EVERY_S
|
||||
if not force and now - last < interval:
|
||||
return
|
||||
self._last_sample[run_id] = now
|
||||
store.add_run_sample(conn, run_id, {
|
||||
@@ -263,6 +365,8 @@ class StateWatcher:
|
||||
"progress": live.get("progress"),
|
||||
"filament_used_mm": used_mm,
|
||||
"mmu_tool": live.get("mmu_tool"),
|
||||
"nozzle_power": live.get("nozzle_power"),
|
||||
"bed_power": live.get("bed_power"),
|
||||
})
|
||||
|
||||
def _ensure_start_segment(self, conn, host, filename, live, run_id):
|
||||
@@ -302,22 +406,29 @@ class StateWatcher:
|
||||
if need <= 0 or not filename:
|
||||
return False
|
||||
sent = store.latest_send(conn, host, filename)
|
||||
spool_id = (sent or {}).get("spool_id")
|
||||
if not spool_id:
|
||||
ace = (sent or {}).get("ace_map") or []
|
||||
from grotti import jobs as grotti_jobs
|
||||
|
||||
at_host = grotti_jobs.resolve_spools_at_host(conn, host, ace)
|
||||
if not at_host:
|
||||
spool_id = (sent or {}).get("spool_id")
|
||||
if spool_id:
|
||||
row = store.get_spool_row(conn, spool_id)
|
||||
if row:
|
||||
at_host = [row]
|
||||
if not at_host:
|
||||
return False
|
||||
spool = store.get_spool_row(conn, spool_id)
|
||||
if not spool:
|
||||
return False
|
||||
left = float(spool["remaining_g"] or 0)
|
||||
left = sum(float(s.get("remaining_g") or 0) for s in at_host)
|
||||
if left + 0.5 >= need:
|
||||
return False
|
||||
label = " / ".join(s.get("name") or "?" for s in at_host[:3])
|
||||
self._emit(
|
||||
conn,
|
||||
host,
|
||||
name,
|
||||
"spule",
|
||||
"%s: Spule „%s“ hat nur %.0f g, Druck braucht %.0f g."
|
||||
% (name, spool["name"], left, need),
|
||||
"%s: %s hat nur %.0f g, Druck braucht %.0f g."
|
||||
% (name, label, left, need),
|
||||
filename,
|
||||
)
|
||||
return True
|
||||
@@ -327,3 +438,4 @@ class StateWatcher:
|
||||
conn, self.cfg, host, printer, kind, message, filename,
|
||||
dedupe_s=60, run_id=run_id,
|
||||
)
|
||||
publish_live("ereignis", {})
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest==9.1.1
|
||||
@@ -1,6 +1,8 @@
|
||||
flask>=3.1
|
||||
psycopg[binary]>=3.2
|
||||
paho-mqtt>=2.1
|
||||
cryptography>=42
|
||||
markdown>=3.6
|
||||
flask==3.1.3
|
||||
psycopg[binary]==3.3.6
|
||||
psycopg-pool==3.3.3
|
||||
paho-mqtt==2.1.0
|
||||
cryptography==50.0.2
|
||||
markdown==3.11.1
|
||||
mcp>=1.2
|
||||
waitress==3.0.2
|
||||
@@ -0,0 +1 @@
|
||||
"""Hilfsskripte (Orca-Nachbearbeitung u. a.)."""
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Lucide-Icons als lokales SVG-Sprite bauen.
|
||||
|
||||
python scripts/build_icons.py
|
||||
|
||||
Schreibt web/static/icons.svg. IDs bleiben i-<grotti-name> (bestehende Templates).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT = ROOT / "web" / "static" / "icons.svg"
|
||||
CDN = "https://unpkg.com/[email protected]/icons/{name}.svg"
|
||||
|
||||
# grotti-id → lucide dateiname (ohne .svg)
|
||||
ICONS: dict[str, str] = {
|
||||
# bestehend
|
||||
"printer": "printer",
|
||||
"camera": "camera",
|
||||
"file": "file",
|
||||
"spool": "disc-3",
|
||||
"folder": "folder",
|
||||
"folder-open": "folder-open",
|
||||
"list": "list",
|
||||
"activity": "activity",
|
||||
"clock": "clock",
|
||||
"check": "check",
|
||||
"alert": "triangle-alert",
|
||||
"flame": "flame",
|
||||
"pause": "pause",
|
||||
"play": "play",
|
||||
"stop": "square",
|
||||
"layers": "layers",
|
||||
"x": "x",
|
||||
"up": "chevron-up",
|
||||
"down": "chevron-down",
|
||||
"upload": "upload",
|
||||
"refresh": "refresh-cw",
|
||||
"queue": "list-ordered",
|
||||
"send": "send",
|
||||
"book": "book-open",
|
||||
"cube": "box",
|
||||
"bell": "bell",
|
||||
"sweep": "brush",
|
||||
"thermo": "thermometer",
|
||||
"settings": "settings",
|
||||
"bulb": "lightbulb",
|
||||
"search": "search",
|
||||
"filter": "filter",
|
||||
"info": "info",
|
||||
"scan": "scan-line",
|
||||
"trash": "trash-2",
|
||||
"inbox": "inbox",
|
||||
"image": "image",
|
||||
"grid": "layout-grid",
|
||||
"menu": "menu",
|
||||
"panel": "panel-left",
|
||||
"nozzle": "pipette",
|
||||
"bed": "rectangle-horizontal",
|
||||
"chamber": "box",
|
||||
"fan": "fan",
|
||||
"fan-chamber": "air-vent",
|
||||
"fan-aux": "wind",
|
||||
# neu für Polish
|
||||
"plus": "plus",
|
||||
"minus": "minus",
|
||||
"edit": "pencil",
|
||||
"save": "save",
|
||||
"copy": "copy",
|
||||
"external": "external-link",
|
||||
"user": "user",
|
||||
"users": "users",
|
||||
"key": "key",
|
||||
"lock": "lock",
|
||||
"unlock": "lock-open",
|
||||
"wifi": "wifi",
|
||||
"wifi-off": "wifi-off",
|
||||
"power": "power",
|
||||
"zap": "zap",
|
||||
"gauge": "gauge",
|
||||
"chart": "chart-column",
|
||||
"calendar": "calendar",
|
||||
"timer": "timer",
|
||||
"weight": "weight",
|
||||
"package": "package",
|
||||
"tag": "tag",
|
||||
"hash": "hash",
|
||||
"link": "link",
|
||||
"download": "download",
|
||||
"more": "ellipsis",
|
||||
"chevron-right": "chevron-right",
|
||||
"chevron-left": "chevron-left",
|
||||
"home": "house",
|
||||
"wrench": "wrench",
|
||||
"hard-drive": "hard-drive",
|
||||
"server": "server",
|
||||
"message": "message-circle",
|
||||
"mail": "mail",
|
||||
"shield": "shield",
|
||||
"sliders": "sliders-horizontal",
|
||||
"palette": "palette",
|
||||
"moon": "moon",
|
||||
"sun": "sun",
|
||||
"monitor": "monitor",
|
||||
"smartphone": "smartphone",
|
||||
"keyboard": "keyboard",
|
||||
"undo": "undo-2",
|
||||
"redo": "redo-2",
|
||||
"circle-check": "circle-check",
|
||||
"circle-x": "circle-x",
|
||||
"circle-alert": "circle-alert",
|
||||
"loader": "loader-circle",
|
||||
"sparkles": "sparkles",
|
||||
"clipboard": "clipboard-list",
|
||||
"receipt": "receipt",
|
||||
"truck": "truck",
|
||||
"droplet": "droplet",
|
||||
"percent": "percent",
|
||||
"euro": "euro",
|
||||
"arrow-up": "arrow-up",
|
||||
"arrow-down": "arrow-down",
|
||||
"history": "history",
|
||||
"eye": "eye",
|
||||
"eye-off": "eye-off",
|
||||
"star": "star",
|
||||
"heart": "heart",
|
||||
"ban": "ban",
|
||||
"rotate-ccw": "rotate-ccw",
|
||||
"brush": "brush",
|
||||
}
|
||||
|
||||
|
||||
def fetch(name: str) -> str:
|
||||
url = CDN.format(name=name)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Grotti-icon-build/1"})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.read().decode("utf-8")
|
||||
|
||||
|
||||
def to_symbol(grotti: str, svg: str) -> str:
|
||||
# Lucide: optional comment + <svg ...>paths</svg>
|
||||
text = re.sub(r"<!--.*?-->", "", svg, flags=re.DOTALL).strip()
|
||||
match = re.search(r"<svg\b[^>]*>(.*)</svg>\s*$", text, flags=re.DOTALL | re.IGNORECASE)
|
||||
if not match:
|
||||
raise ValueError("kein SVG-Root")
|
||||
inner = match.group(1).strip()
|
||||
# verschachtelte svg-Attribute/Klassen aus Kind-Elementen nicht nötig
|
||||
return (
|
||||
f' <symbol id="i-{grotti}" viewBox="0 0 24 24" fill="none" '
|
||||
f'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
|
||||
f'stroke-linejoin="round">\n {inner}\n </symbol>'
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
symbols = []
|
||||
failed = []
|
||||
for grotti, lucide in sorted(ICONS.items()):
|
||||
try:
|
||||
raw = fetch(lucide)
|
||||
symbols.append(to_symbol(grotti, raw))
|
||||
print("ok", grotti, "<-", lucide)
|
||||
except urllib.error.HTTPError as exc:
|
||||
failed.append((grotti, lucide, str(exc)))
|
||||
print("FAIL", grotti, lucide, exc.code)
|
||||
except Exception as exc:
|
||||
failed.append((grotti, lucide, str(exc)))
|
||||
print("FAIL", grotti, lucide, type(exc).__name__)
|
||||
|
||||
if failed and len(failed) == len(ICONS):
|
||||
raise SystemExit("Keine Icons geladen.")
|
||||
|
||||
# Fallbacks für fehlgeschlagene: leeres symbol vermeiden — check als Ersatz
|
||||
ok_ids = {s.split('id="i-')[1].split('"')[0] for s in symbols}
|
||||
for grotti, lucide, _ in failed:
|
||||
if grotti in ok_ids:
|
||||
continue
|
||||
# einfaches X als Platzhalter
|
||||
symbols.append(
|
||||
f' <symbol id="i-{grotti}" viewBox="0 0 24 24" fill="none" '
|
||||
f'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
|
||||
f'stroke-linejoin="round">\n'
|
||||
f' <path d="M18 6 6 18"/><path d="m6 6 12 12"/>\n </symbol>'
|
||||
)
|
||||
print("placeholder", grotti)
|
||||
|
||||
body = "\n".join(symbols)
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
f'<!-- Lucide icons, built by scripts/build_icons.py — {len(symbols)} symbols -->\n'
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">\n'
|
||||
f"{body}\n"
|
||||
"</svg>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print("wrote", OUT, "symbols", len(symbols))
|
||||
if failed:
|
||||
print("warnings:", len(failed))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Orca/Anycubic-Nachbearbeitung: G-code nach Grotti laden und Senden-Seite öffnen.
|
||||
|
||||
Wenn SLIC3R_PP_OUTPUT_NAME unter dem Slice-Ordner liegt (auch Unterordner),
|
||||
wird die Datei dorthin geschrieben und der Watch neu gelesen — nicht als Upload.
|
||||
Sonst: Upload über die API.
|
||||
|
||||
Config: data/orca-senden.json mit url und token (grt_…).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import uuid
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CONFIG_PATH = REPO_ROOT / "data" / "orca-senden.json"
|
||||
DEBUG_LOG = REPO_ROOT / "data" / "orca-senden-last.log"
|
||||
ALLOWED_SUFFIXES = (".gcode", ".gcode.3mf", ".3mf", ".gcode.pp", ".pp")
|
||||
|
||||
|
||||
def show_error(message: str) -> None:
|
||||
text = str(message or "Unbekannter Fehler.")
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.user32.MessageBoxW(0, text, "Grotti · Orca senden", 0x10)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
print(text, file=sys.stderr)
|
||||
|
||||
|
||||
def write_debug(argv: list[str], extra: str = "") -> None:
|
||||
try:
|
||||
DEBUG_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [
|
||||
"argv:",
|
||||
*[repr(item) for item in argv],
|
||||
"",
|
||||
"env:",
|
||||
"SLIC3R_PP_HOST=%s" % (os.environ.get("SLIC3R_PP_HOST") or ""),
|
||||
"SLIC3R_PP_OUTPUT_NAME=%s" % (os.environ.get("SLIC3R_PP_OUTPUT_NAME") or ""),
|
||||
"",
|
||||
extra,
|
||||
]
|
||||
DEBUG_LOG.write_text("\n".join(lines), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def load_config(path: Path = CONFIG_PATH) -> dict[str, str]:
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(
|
||||
"Config fehlt: %s\nLege url und token (Einstellungen → API-Token) an." % path
|
||||
)
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("Config ungültig: %s" % exc) from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Config muss ein JSON-Objekt sein.")
|
||||
url = str(data.get("url") or "").strip().rstrip("/")
|
||||
token = str(data.get("token") or "").strip()
|
||||
if not url:
|
||||
raise ValueError("Config: url fehlt (z. B. http://127.0.0.1:8770).")
|
||||
if not token:
|
||||
raise ValueError("Config: token fehlt (grt_… aus Einstellungen).")
|
||||
return {"url": url, "token": token}
|
||||
|
||||
|
||||
def is_gcode_path(path: Path) -> bool:
|
||||
name = path.name.lower()
|
||||
return any(name.endswith(suffix) for suffix in ALLOWED_SUFFIXES)
|
||||
|
||||
|
||||
def output_target() -> Path | None:
|
||||
env_name = (os.environ.get("SLIC3R_PP_OUTPUT_NAME") or "").strip().strip('"')
|
||||
if not env_name:
|
||||
return None
|
||||
return Path(env_name)
|
||||
|
||||
|
||||
def upload_name_for(path: Path) -> str:
|
||||
"""Schöner Dateiname: SLIC3R_PP_OUTPUT_NAME, sonst Pfad ohne .pp."""
|
||||
target = output_target()
|
||||
if target is not None and target.name:
|
||||
base = target.name
|
||||
if not is_gcode_path(Path(base)) and path.name.lower().endswith(".pp"):
|
||||
if not base.lower().endswith((".gcode", ".3mf")):
|
||||
base = base + ".gcode"
|
||||
return base
|
||||
name = path.name
|
||||
if name.lower().endswith(".pp"):
|
||||
name = name[:-3]
|
||||
if not name.lower().endswith((".gcode", ".3mf")):
|
||||
name = name + ".gcode"
|
||||
return name
|
||||
|
||||
|
||||
def find_gcode_arg(argv: list[str]) -> Path:
|
||||
"""Pfad aus argv oder SLIC3R_PP_OUTPUT_NAME; Temp-Dateien enden oft auf .pp."""
|
||||
candidates: list[Path] = []
|
||||
for raw in argv[1:]:
|
||||
text = (raw or "").strip().strip('"')
|
||||
if not text or text.startswith("-"):
|
||||
continue
|
||||
candidates.append(Path(text))
|
||||
for candidate in reversed(candidates):
|
||||
if not is_gcode_path(candidate):
|
||||
continue
|
||||
if candidate.is_file():
|
||||
return candidate.resolve()
|
||||
raise FileNotFoundError("Datei fehlt: %s" % candidate)
|
||||
for candidate in reversed(candidates):
|
||||
if candidate.is_file():
|
||||
return candidate.resolve()
|
||||
env_path = output_target()
|
||||
if env_path is not None and env_path.is_file():
|
||||
return env_path.resolve()
|
||||
detail = "argv=%s" % (argv[1:] or ["(leer)"])
|
||||
raise ValueError(
|
||||
"Kein G-code-Pfad in den Argumenten.\n"
|
||||
"G-code exportieren (nicht nur slicen).\n"
|
||||
"%s\nDetails: data/orca-senden-last.log" % detail
|
||||
)
|
||||
|
||||
|
||||
def send_page_url(base_url: str, file_id: int) -> str:
|
||||
return "%s/dateien?file=%s" % (base_url.rstrip("/"), int(file_id))
|
||||
|
||||
|
||||
def _api_json(
|
||||
base_url: str,
|
||||
token: str,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
data: bytes | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: float = 120.0,
|
||||
) -> Any:
|
||||
hdrs = {
|
||||
"Authorization": "Bearer %s" % token,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if headers:
|
||||
hdrs.update(headers)
|
||||
request = Request(
|
||||
"%s/api/v1%s" % (base_url.rstrip("/"), path),
|
||||
data=data,
|
||||
method=method,
|
||||
headers=hdrs,
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read()
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
detail = ""
|
||||
try:
|
||||
detail = json.loads(exc.read().decode("utf-8")).get("error") or ""
|
||||
except Exception:
|
||||
detail = str(exc.reason or exc)
|
||||
raise RuntimeError("API %s fehlgeschlagen (%s): %s" % (path, exc.code, detail or exc)) from exc
|
||||
except URLError as exc:
|
||||
raise RuntimeError(
|
||||
"Grotti nicht erreichbar (%s). Läuft der Server?" % (exc.reason or exc)
|
||||
) from exc
|
||||
|
||||
|
||||
def fetch_watch_dir(base_url: str, token: str) -> Path | None:
|
||||
payload = _api_json(base_url, token, "GET", "/einstellungen")
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
text = str(payload.get("watch_dir") or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
path = Path(text)
|
||||
return path if path.is_dir() else None
|
||||
|
||||
|
||||
def under_watch(watch_dir: Path, target: Path) -> tuple[str, str] | None:
|
||||
"""(folder_relativ, dateiname) wenn target unter watch_dir liegt."""
|
||||
try:
|
||||
rel = target.resolve().relative_to(watch_dir.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
parts = rel.parts
|
||||
if not parts:
|
||||
return None
|
||||
name = parts[-1]
|
||||
folder = "/".join(parts[:-1]) if len(parts) > 1 else ""
|
||||
return folder, name
|
||||
|
||||
|
||||
def place_in_watch(src: Path, dest: Path) -> Path:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(str(src), str(dest))
|
||||
return dest.resolve()
|
||||
|
||||
|
||||
def rescan(base_url: str, token: str) -> None:
|
||||
_api_json(base_url, token, "POST", "/dateien/neu-lesen", data=b"{}", headers={"Content-Type": "application/json"})
|
||||
|
||||
|
||||
def find_library_file(base_url: str, token: str, *, name: str, folder: str) -> dict[str, Any] | None:
|
||||
rows = _api_json(base_url, token, "GET", "/dateien")
|
||||
if not isinstance(rows, list):
|
||||
return None
|
||||
folder_key = folder or ""
|
||||
matches = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if (row.get("name") or "") != name:
|
||||
continue
|
||||
row_folder = row.get("folder") or ""
|
||||
if row.get("source") == "watch":
|
||||
if row_folder == folder_key or (not folder_key and row_folder in ("", None)):
|
||||
matches.append(row)
|
||||
elif not folder_key and row_folder in ("", "Upload"):
|
||||
matches.append(row)
|
||||
if not matches:
|
||||
# Name reicht, neueste ID
|
||||
matches = [row for row in rows if isinstance(row, dict) and (row.get("name") or "") == name]
|
||||
if not matches:
|
||||
return None
|
||||
return max(matches, key=lambda row: int(row.get("id") or 0))
|
||||
|
||||
|
||||
def _multipart_body(filename: str, content: bytes, field: str = "file") -> tuple[bytes, str]:
|
||||
boundary = "----GrottiOrca%s" % uuid.uuid4().hex
|
||||
guessed = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
head = (
|
||||
"--%s\r\n"
|
||||
'Content-Disposition: form-data; name="%s"; filename="%s"\r\n'
|
||||
"Content-Type: %s\r\n"
|
||||
"\r\n"
|
||||
) % (boundary, field, filename.replace('"', ""), guessed)
|
||||
tail = "\r\n--%s--\r\n" % boundary
|
||||
body = head.encode("utf-8") + content + tail.encode("ascii")
|
||||
return body, "multipart/form-data; boundary=%s" % boundary
|
||||
|
||||
|
||||
def upload_file(
|
||||
base_url: str,
|
||||
token: str,
|
||||
path: Path,
|
||||
filename: str | None = None,
|
||||
timeout: float = 120.0,
|
||||
) -> dict[str, Any]:
|
||||
content = path.read_bytes()
|
||||
name = filename or upload_name_for(path)
|
||||
body, content_type = _multipart_body(name, content)
|
||||
payload = _api_json(
|
||||
base_url,
|
||||
token,
|
||||
"POST",
|
||||
"/dateien",
|
||||
data=body,
|
||||
headers={"Content-Type": content_type},
|
||||
timeout=timeout,
|
||||
)
|
||||
if not isinstance(payload, dict) or "id" not in payload:
|
||||
raise RuntimeError("Unerwartete Antwort vom Upload.")
|
||||
return payload
|
||||
|
||||
|
||||
def open_browser(url: str) -> None:
|
||||
webbrowser.open(url)
|
||||
|
||||
|
||||
def deliver(base_url: str, token: str, src: Path) -> int:
|
||||
"""Datei in Slice-Unterordner legen oder hochladen; gibt file_id zurück."""
|
||||
watch = fetch_watch_dir(base_url, token)
|
||||
target = output_target()
|
||||
if watch is not None and target is not None:
|
||||
placed = under_watch(watch, target)
|
||||
if placed is not None:
|
||||
folder, name = placed
|
||||
dest = place_in_watch(src, target if target.suffix else target.with_suffix(".gcode"))
|
||||
# Name aus Zielpfad, falls ohne Endung
|
||||
if dest.name:
|
||||
name = dest.name
|
||||
rescan(base_url, token)
|
||||
found = find_library_file(base_url, token, name=name, folder=folder)
|
||||
if not found:
|
||||
raise RuntimeError(
|
||||
"Datei liegt unter %s, aber Grotti kennt sie noch nicht. "
|
||||
"Unter Dateien „Ordner neu lesen“." % dest
|
||||
)
|
||||
return int(found["id"])
|
||||
saved = upload_file(base_url, token, src, filename=upload_name_for(src))
|
||||
return int(saved["id"])
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv = list(sys.argv if argv is None else argv)
|
||||
write_debug(argv)
|
||||
try:
|
||||
config = load_config()
|
||||
path = find_gcode_arg(argv)
|
||||
write_debug(
|
||||
argv,
|
||||
"chosen=%s\nupload_as=%s\noutput=%s"
|
||||
% (path, upload_name_for(path), output_target() or ""),
|
||||
)
|
||||
file_id = deliver(config["url"], config["token"], path)
|
||||
write_debug(argv, "file_id=%s" % file_id)
|
||||
open_browser(send_page_url(config["url"], file_id))
|
||||
except Exception as exc:
|
||||
write_debug(argv, "error=%s" % exc)
|
||||
show_error(str(exc))
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,19 +1,26 @@
|
||||
"""Grotti starten. Kern und Seiten liegen daneben."""
|
||||
|
||||
import atexit
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
|
||||
from grotti import jobs, library, store
|
||||
from waitress import serve
|
||||
|
||||
from grotti import db, jobs, library, store
|
||||
from grotti.config import load
|
||||
from grotti.db import connect, prepare
|
||||
from web.app import create_app
|
||||
|
||||
|
||||
def background(cfg):
|
||||
def work():
|
||||
try:
|
||||
with connect(cfg["database_url"]) as conn:
|
||||
with db.connect(cfg["database_url"]) as conn:
|
||||
library.sync_watch(conn, cfg, force=False)
|
||||
jobs.sync_history(conn, cfg)
|
||||
deleted = db.prune_run_samples(conn, keep_days=90)
|
||||
if deleted:
|
||||
print("Proben gelöscht: %s" % deleted, flush=True)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
print("Abgleich: %s" % exc, flush=True)
|
||||
@@ -21,19 +28,87 @@ def background(cfg):
|
||||
threading.Thread(target=work, name="grotti-abgleich", daemon=True).start()
|
||||
|
||||
|
||||
def _retention_loop(cfg, stop_event):
|
||||
"""Nächtlich alte run_samples entfernen."""
|
||||
while not stop_event.wait(3600):
|
||||
hour = time.localtime().tm_hour
|
||||
if hour != 3:
|
||||
continue
|
||||
try:
|
||||
with db.connect(cfg["database_url"]) as conn:
|
||||
n = db.prune_run_samples(conn, keep_days=90)
|
||||
conn.commit()
|
||||
if n:
|
||||
print("Retention: %s Proben gelöscht." % n, flush=True)
|
||||
except Exception as exc:
|
||||
print("Retention: %s" % exc, flush=True)
|
||||
stop_event.wait(3600)
|
||||
|
||||
|
||||
def main():
|
||||
cfg = load()
|
||||
cfg["data_dir"].mkdir(parents=True, exist_ok=True)
|
||||
(cfg["data_dir"] / "eingang").mkdir(exist_ok=True)
|
||||
prepare(cfg["database_url"])
|
||||
with connect(cfg["database_url"]) as conn:
|
||||
db.prepare(cfg["database_url"])
|
||||
db.init_pool(cfg["database_url"], min_size=2, max_size=16)
|
||||
with db.connect(cfg["database_url"]) as conn:
|
||||
store.seed_printers(conn, cfg["printers_file"])
|
||||
store.seed_materials(conn)
|
||||
conn.commit()
|
||||
|
||||
app = create_app(cfg)
|
||||
watcher = app.extensions["watcher"]
|
||||
dispatcher = app.extensions["print_queue"]
|
||||
send_queue = app.extensions["send_queue"]
|
||||
watcher.start()
|
||||
dispatcher.start()
|
||||
if hasattr(send_queue, "start"):
|
||||
send_queue.start()
|
||||
from grotti.daily_report import DailyReportThread
|
||||
daily = DailyReportThread(cfg["database_url"], cfg, watcher=watcher)
|
||||
daily.start()
|
||||
app.extensions["daily_report"] = daily
|
||||
|
||||
stop_event = threading.Event()
|
||||
retention = threading.Thread(
|
||||
target=_retention_loop,
|
||||
args=(cfg, stop_event),
|
||||
name="grotti-retention",
|
||||
daemon=True,
|
||||
)
|
||||
retention.start()
|
||||
background(cfg)
|
||||
|
||||
def shutdown(*_args):
|
||||
stop_event.set()
|
||||
try:
|
||||
watcher.stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
dispatcher.stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if hasattr(send_queue, "stop"):
|
||||
send_queue.stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
daily.stop()
|
||||
except Exception:
|
||||
pass
|
||||
db.close_pool()
|
||||
|
||||
atexit.register(shutdown)
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
signal.signal(sig, lambda *_: shutdown())
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
print("Grotti: http://%s:%s/" % (cfg["host"], cfg["port"]), flush=True)
|
||||
app.run(host=cfg["host"], port=cfg["port"], threaded=True, use_reloader=False)
|
||||
serve(app, host=cfg["host"], port=cfg["port"], threads=16, channel_timeout=120)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Urteil beim Abräumen (reine Hilfslogik ohne DB)."""
|
||||
|
||||
import unittest
|
||||
|
||||
from grotti.verdict import normalize_abraeumen_verdict
|
||||
|
||||
|
||||
class AbraeumenVerdictTests(unittest.TestCase):
|
||||
def test_gut_clears_reason(self):
|
||||
v, reason, ok, bad = normalize_abraeumen_verdict("gut", "warping", 2, 1)
|
||||
self.assertEqual(v, "gut")
|
||||
self.assertEqual(reason, "")
|
||||
self.assertEqual(bad, 0)
|
||||
|
||||
def test_ausschuss_requires_known_reason_if_set(self):
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_abraeumen_verdict("ausschuss", "xyz", 0, 0)
|
||||
|
||||
def test_ausschuss_defaults_bad_piece(self):
|
||||
v, reason, ok, bad = normalize_abraeumen_verdict("ausschuss", "spaghetti", 0, 0)
|
||||
self.assertEqual(v, "ausschuss")
|
||||
self.assertEqual(reason, "spaghetti")
|
||||
self.assertEqual(bad, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -48,6 +48,7 @@ class CameraFieldsTest(unittest.TestCase):
|
||||
moonraker.CAMERA_CACHE.clear()
|
||||
fields = moonraker._camera_fields("10.0.0.1", "10.0.0.1")
|
||||
self.assertTrue(fields["camera"])
|
||||
self.assertIn("/api/v1/kamera/stream?", fields["camera_stream"])
|
||||
self.assertIsNone(fields["has_camera"])
|
||||
|
||||
def test_known_dead_hides_url(self):
|
||||
@@ -56,6 +57,7 @@ class CameraFieldsTest(unittest.TestCase):
|
||||
moonraker.CAMERA_CACHE["10.0.0.2"] = (time.time(), 30, None)
|
||||
fields = moonraker._camera_fields("10.0.0.2", "10.0.0.2")
|
||||
self.assertEqual(fields["camera"], "")
|
||||
self.assertEqual(fields["camera_stream"], "")
|
||||
self.assertFalse(fields["has_camera"])
|
||||
moonraker.CAMERA_CACHE.pop("10.0.0.2", None)
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Filament-Bedarf aus Queue und Spulen."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
from grotti.bedarf import compute
|
||||
|
||||
|
||||
class BedarfTests(unittest.TestCase):
|
||||
def _rows(self, *lists):
|
||||
mocks = []
|
||||
for rows in lists:
|
||||
m = MagicMock()
|
||||
m.fetchall.return_value = rows
|
||||
mocks.append(m)
|
||||
return mocks
|
||||
|
||||
def test_shortfall_when_queue_exceeds_stock(self):
|
||||
conn = MagicMock()
|
||||
conn.execute.side_effect = self._rows(
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"spool_id": 10,
|
||||
"repeat_total": 2,
|
||||
"repeat_done": 0,
|
||||
"weight_g": 50.0,
|
||||
"filaments": [{"type": "PLA", "color": "Schwarz"}],
|
||||
"spool_material": "PLA",
|
||||
"spool_color": "Schwarz",
|
||||
},
|
||||
],
|
||||
[
|
||||
{"material_type": "PLA", "color": "Schwarz", "remaining_g": 40.0},
|
||||
],
|
||||
)
|
||||
out = compute(conn)
|
||||
self.assertEqual(len(out["needed"]), 1)
|
||||
self.assertAlmostEqual(out["needed"][0]["weight_g"], 100.0)
|
||||
self.assertEqual(len(out["shortfall"]), 1)
|
||||
self.assertAlmostEqual(out["shortfall"][0]["shortfall_g"], 60.0)
|
||||
|
||||
def test_no_shortfall_when_enough_filament(self):
|
||||
conn = MagicMock()
|
||||
conn.execute.side_effect = self._rows(
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"spool_id": None,
|
||||
"repeat_total": 1,
|
||||
"repeat_done": 0,
|
||||
"weight_g": 20.0,
|
||||
"filaments": [{"type": "PETG", "color": ""}],
|
||||
"spool_material": None,
|
||||
"spool_color": None,
|
||||
},
|
||||
],
|
||||
[
|
||||
{"material_type": "PETG", "color": "", "remaining_g": 500.0},
|
||||
],
|
||||
)
|
||||
out = compute(conn)
|
||||
self.assertEqual(out["shortfall"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Slice-Ordner (watch_dir) in config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from grotti import config
|
||||
from grotti.errors import GrottiError
|
||||
|
||||
|
||||
class WatchDirTest(unittest.TestCase):
|
||||
def test_empty(self):
|
||||
path, stored = config.parse_watch_dir("")
|
||||
self.assertIsNone(path)
|
||||
self.assertEqual(stored, "")
|
||||
|
||||
def test_relative_under_root(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
target = root / "slice"
|
||||
target.mkdir()
|
||||
with patch.object(config, "ROOT", root):
|
||||
path, stored = config.parse_watch_dir("slice", create=False)
|
||||
self.assertEqual(path, target.resolve())
|
||||
self.assertEqual(stored, "slice")
|
||||
|
||||
def test_create(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
with patch.object(config, "ROOT", root):
|
||||
path, stored = config.parse_watch_dir("slice", create=True)
|
||||
self.assertTrue(path.is_dir())
|
||||
self.assertEqual(stored, "slice")
|
||||
|
||||
def test_file_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
file_path = root / "not-a-dir"
|
||||
file_path.write_text("x", encoding="utf-8")
|
||||
with patch.object(config, "ROOT", root):
|
||||
with self.assertRaises(GrottiError):
|
||||
config.parse_watch_dir(str(file_path), create=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""ED-Stromschätzung."""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from grotti.cost import energy_estimate
|
||||
|
||||
|
||||
def sample_at(offset_s, np=None, bp=None):
|
||||
base = datetime(2026, 10, 17, 15, 0, tzinfo=timezone.utc).timestamp()
|
||||
at = datetime.fromtimestamp(base + offset_s, tz=timezone.utc).isoformat()
|
||||
return {"sampled_at": at, "nozzle_power": np, "bed_power": bp}
|
||||
|
||||
|
||||
class EnergyEdTests(unittest.TestCase):
|
||||
def test_flat_without_heater_watts(self):
|
||||
est = energy_estimate(3600, {"watts": 100, "euro_per_kwh": 0.5}, [
|
||||
sample_at(0, np=1.0, bp=1.0),
|
||||
sample_at(1800, np=1.0, bp=1.0),
|
||||
])
|
||||
self.assertEqual(est["source"], "flat")
|
||||
self.assertAlmostEqual(est["kwh"], 0.1)
|
||||
self.assertAlmostEqual(est["cost"], 0.05)
|
||||
|
||||
def test_ed_caps_long_gaps(self):
|
||||
# 60 min Lücke → nur 90 s werden gezählt
|
||||
samples = [sample_at(0, np=0.5, bp=0.0), sample_at(3600, np=0.5, bp=0.0)]
|
||||
est = energy_estimate(3600, {
|
||||
"watts": 0,
|
||||
"nozzle_watts": 100,
|
||||
"bed_watts": 0,
|
||||
"euro_per_kwh": 1,
|
||||
}, samples)
|
||||
self.assertEqual(est["source"], "ed")
|
||||
self.assertAlmostEqual(est["print_kwh"], (100 * 0.5 / 1000) * (90 / 3600), places=6)
|
||||
|
||||
def test_ed_dense_samples_full_hour(self):
|
||||
samples = [sample_at(i * 30, np=1.0, bp=0.0) for i in range(0, 121)]
|
||||
est = energy_estimate(3600, {
|
||||
"watts": 0,
|
||||
"nozzle_watts": 200,
|
||||
"bed_watts": 0,
|
||||
"euro_per_kwh": 2,
|
||||
}, samples)
|
||||
self.assertEqual(est["source"], "ed")
|
||||
self.assertAlmostEqual(est["print_kwh"], 0.2, places=3)
|
||||
self.assertAlmostEqual(est["cost"], 0.4, places=3)
|
||||
|
||||
def test_ed_parts_split_base_nozzle_bed(self):
|
||||
samples = [sample_at(i * 30, np=1.0, bp=0.5) for i in range(0, 121)]
|
||||
est = energy_estimate(3600, {
|
||||
"watts": 40,
|
||||
"nozzle_watts": 60,
|
||||
"bed_watts": 220,
|
||||
"euro_per_kwh": 0.35,
|
||||
}, samples)
|
||||
self.assertEqual(est["source"], "ed")
|
||||
self.assertAlmostEqual(est["base_kwh"], 0.04, places=3)
|
||||
self.assertAlmostEqual(est["nozzle_kwh"], 0.06, places=3)
|
||||
self.assertAlmostEqual(est["bed_kwh"], 0.11, places=3)
|
||||
keys = {p["key"] for p in est["parts"]}
|
||||
self.assertEqual(keys, {"base", "nozzle", "bed"})
|
||||
self.assertAlmostEqual(sum(p["kwh"] for p in est["parts"]), est["kwh"], places=6)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Ops-Alerts: Dedup-Art muss zum gespeicherten Event-kind passen."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from grotti.daily_report import push_ops_alerts
|
||||
|
||||
|
||||
class OpsAlertDedupeTests(unittest.TestCase):
|
||||
def test_bedarf_records_matching_kind(self):
|
||||
conn = MagicMock()
|
||||
cfg = {"alerts": {"queue": True}}
|
||||
shortfall = [{
|
||||
"material_type": "PETG-CF",
|
||||
"color": "#161616",
|
||||
"shortfall_g": 15,
|
||||
}]
|
||||
with patch("grotti.daily_report.jetzt_mod.overdue_orders", return_value=[]), \
|
||||
patch("grotti.daily_report.jetzt_mod.overdue_maintenance", return_value=[]), \
|
||||
patch("grotti.daily_report.bedarf_mod.compute", return_value={"shortfall": shortfall}), \
|
||||
patch("grotti.daily_report.store.list_printers", return_value=[]), \
|
||||
patch("grotti.daily_report.store.recent_event_kind", return_value=False) as recent, \
|
||||
patch("grotti.daily_report.notify.record") as record:
|
||||
push_ops_alerts(conn, cfg, printers=[])
|
||||
recent.assert_called_with(
|
||||
conn, "bedarf:PETG-CF:#161616", "bedarf", within_s=21600,
|
||||
)
|
||||
record.assert_called_once()
|
||||
args = record.call_args
|
||||
self.assertEqual(args.args[4], "bedarf")
|
||||
self.assertIn("15 g fehlen", args.args[5])
|
||||
|
||||
def test_bedarf_skips_when_recent(self):
|
||||
conn = MagicMock()
|
||||
cfg = {"alerts": {"queue": True}}
|
||||
shortfall = [{"material_type": "PLA", "color": "", "shortfall_g": 10}]
|
||||
with patch("grotti.daily_report.jetzt_mod.overdue_orders", return_value=[]), \
|
||||
patch("grotti.daily_report.jetzt_mod.overdue_maintenance", return_value=[]), \
|
||||
patch("grotti.daily_report.bedarf_mod.compute", return_value={"shortfall": shortfall}), \
|
||||
patch("grotti.daily_report.store.list_printers", return_value=[]), \
|
||||
patch("grotti.daily_report.store.recent_event_kind", return_value=True), \
|
||||
patch("grotti.daily_report.notify.record") as record:
|
||||
push_ops_alerts(conn, cfg, printers=[])
|
||||
record.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -61,7 +61,10 @@ class EjectTests(unittest.TestCase):
|
||||
self.assertFalse(eject.cool_parked({"position": [250, 220, 50]}, before_z=10, lift_mm=40))
|
||||
|
||||
def test_sweep_does_not_lower_before_back(self):
|
||||
script = eject.sweep_probe_height_script(eject.normalize_sweep({}))
|
||||
sweep = eject.normalize_sweep({})
|
||||
back = eject.sweep_go_back_high_script(sweep)
|
||||
lower = eject.sweep_lower_to_push_script(sweep)
|
||||
script = back + "\n" + lower
|
||||
# Y hinten (Default 250) vor dem Senken auf z_push
|
||||
y_idx = script.find("Y250")
|
||||
z_push_idx = script.find("Z2.00")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Filament-Pause-Heuristik aus ACE/mmu und Meldungen."""
|
||||
|
||||
import unittest
|
||||
|
||||
from grotti.moonraker import filament_out_hint
|
||||
|
||||
|
||||
class FilamentOutHintTests(unittest.TestCase):
|
||||
def test_message_10107(self):
|
||||
code, text = filament_out_hint({}, "Report code 10107")
|
||||
self.assertEqual(code, "message")
|
||||
self.assertIn("10107", text)
|
||||
|
||||
def test_active_filament_empty(self):
|
||||
code, text = filament_out_hint({"active_filament": {"empty": True}})
|
||||
self.assertEqual(code, "active_empty")
|
||||
self.assertIn("leer", text.lower())
|
||||
|
||||
def test_gate_status_zero_for_tool(self):
|
||||
code, text = filament_out_hint({
|
||||
"tool": 2,
|
||||
"gate_status": [1, 1, 0, 1],
|
||||
"active_filament": {"type": "PLA"},
|
||||
})
|
||||
self.assertEqual(code, "gate_empty")
|
||||
self.assertIn("3", text)
|
||||
|
||||
def test_mmu_paused_without_active_type(self):
|
||||
code, text = filament_out_hint({
|
||||
"is_paused": True,
|
||||
"active_filament": {},
|
||||
})
|
||||
self.assertEqual(code, "mmu_paused")
|
||||
self.assertTrue(text)
|
||||
|
||||
def test_no_hint_for_plain_pause(self):
|
||||
code, text = filament_out_hint({"tool": 0, "gate_status": [1, 1]}, "paused")
|
||||
self.assertIsNone(code)
|
||||
self.assertIsNone(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Unit-Tests für Spulenwahl und Steuer-Parameter-Aliase."""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from grotti import jobs
|
||||
from grotti import moonraker
|
||||
|
||||
|
||||
class ChooseSpoolTests(unittest.TestCase):
|
||||
def test_rejects_spool_created_after_print_end(self):
|
||||
end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
|
||||
old = {
|
||||
"id": 1,
|
||||
"material_type": "PLA",
|
||||
"created_at": end - timedelta(days=2),
|
||||
}
|
||||
new = {
|
||||
"id": 2,
|
||||
"material_type": "PLA",
|
||||
"created_at": end + timedelta(hours=1),
|
||||
}
|
||||
conn = MagicMock()
|
||||
with patch("grotti.jobs.store.spools_for_printer", return_value=[new]):
|
||||
with patch("grotti.jobs.store.get_spool_row", return_value=new):
|
||||
self.assertIsNone(jobs.choose_spool(conn, "h", "PLA", 2, ended_at=end))
|
||||
with patch("grotti.jobs.store.spools_for_printer", return_value=[old]):
|
||||
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", None, ended_at=end)["id"], 1)
|
||||
|
||||
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)}
|
||||
self.assertTrue(jobs._spool_valid_for_end(spool, end))
|
||||
|
||||
|
||||
class SteuernAliasTests(unittest.TestCase):
|
||||
@patch("grotti.moonraker.gcode_script", return_value={"ok": True})
|
||||
def test_value_alias_for_temp(self, gcode):
|
||||
moonraker.steuern("10.0.0.1", "nozzle", {"value": 210})
|
||||
script = gcode.call_args[0][1]
|
||||
self.assertIn("TARGET=210.0", script)
|
||||
|
||||
@patch("grotti.moonraker.gcode_script", return_value={"ok": True})
|
||||
def test_percent_alias_for_speed(self, gcode):
|
||||
moonraker.steuern("10.0.0.1", "speed", {"value": 120})
|
||||
script = gcode.call_args[0][1]
|
||||
self.assertIn("M220 S120", script)
|
||||
|
||||
|
||||
class HostValidationTests(unittest.TestCase):
|
||||
def test_validate_printer_host(self):
|
||||
from web.app import validate_printer_host
|
||||
from grotti.errors import GrottiError
|
||||
|
||||
self.assertEqual(validate_printer_host("10.10.1.60"), "10.10.1.60")
|
||||
self.assertEqual(validate_printer_host("kobra.local:80"), "kobra.local:80")
|
||||
with self.assertRaises(GrottiError):
|
||||
validate_printer_host("http://evil")
|
||||
with self.assertRaises(GrottiError):
|
||||
validate_printer_host("../etc/passwd")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Pfad- und Dateiname-Grenzen."""
|
||||
|
||||
import unittest
|
||||
|
||||
from grotti import library
|
||||
from grotti.errors import GrottiError
|
||||
|
||||
|
||||
class SafeFilenameTests(unittest.TestCase):
|
||||
def test_rejects_dotdot(self):
|
||||
self.assertEqual(library.safe_filename(".."), "datei.gcode")
|
||||
self.assertEqual(library.safe_filename("../x.gcode"), "x.gcode")
|
||||
|
||||
def test_normalize_folder_blocks_escape(self):
|
||||
with self.assertRaises(GrottiError):
|
||||
library.normalize_folder("../secret")
|
||||
self.assertEqual(library.normalize_folder("Gridfinity/A"), "Gridfinity/A")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Wartungs-Zähler."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from grotti.maintenance import counters_for_host
|
||||
|
||||
|
||||
class MaintenanceCounterTests(unittest.TestCase):
|
||||
def test_counters_from_aggregate_row(self):
|
||||
conn = MagicMock()
|
||||
conn.execute.return_value.fetchone.return_value = {
|
||||
"duration_s": 7200.0,
|
||||
"meters": 150000.0,
|
||||
"runs": 12,
|
||||
}
|
||||
out = counters_for_host(conn, "10.0.0.1")
|
||||
self.assertAlmostEqual(out["hours"], 2.0)
|
||||
self.assertAlmostEqual(out["meters"], 150000.0)
|
||||
self.assertEqual(out["runs"], 12)
|
||||
|
||||
def test_empty_host(self):
|
||||
out = counters_for_host(MagicMock(), "")
|
||||
self.assertEqual(out, {"hours": 0.0, "meters": 0.0, "runs": 0})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests für scripts/orca_senden.py ohne laufenden Server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts import orca_senden as orca # noqa: E402
|
||||
|
||||
|
||||
class OrcaSendenHelpersTest(unittest.TestCase):
|
||||
def test_send_page_url(self):
|
||||
self.assertEqual(
|
||||
orca.send_page_url("http://127.0.0.1:8770/", 42),
|
||||
"http://127.0.0.1:8770/dateien?file=42",
|
||||
)
|
||||
|
||||
def test_find_gcode_arg_last_matching(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
folder = Path(tmp)
|
||||
gcode = folder / "teil.gcode"
|
||||
gcode.write_text("; test\n", encoding="utf-8")
|
||||
other = folder / "notes.txt"
|
||||
other.write_text("x", encoding="utf-8")
|
||||
found = orca.find_gcode_arg(["orca_senden.py", str(other), str(gcode)])
|
||||
self.assertEqual(found, gcode.resolve())
|
||||
|
||||
def test_find_gcode_arg_pp_temp(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pp = Path(tmp) / "platte.gcode.pp"
|
||||
pp.write_text("; temp\n", encoding="utf-8")
|
||||
found = orca.find_gcode_arg(["orca_senden.py", str(pp)])
|
||||
self.assertEqual(found, pp.resolve())
|
||||
self.assertEqual(orca.upload_name_for(pp), "platte.gcode")
|
||||
|
||||
def test_under_watch_subfolder(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp) / "slice"
|
||||
dest = root / "Gridfinity" / "teil.gcode"
|
||||
dest.parent.mkdir(parents=True)
|
||||
dest.write_text("; x\n", encoding="utf-8")
|
||||
placed = orca.under_watch(root, dest)
|
||||
self.assertEqual(placed, ("Gridfinity", "teil.gcode"))
|
||||
|
||||
def test_under_watch_outside(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp) / "slice"
|
||||
root.mkdir()
|
||||
other = Path(tmp) / "elsewhere" / "teil.gcode"
|
||||
other.parent.mkdir()
|
||||
other.write_text("; x\n", encoding="utf-8")
|
||||
self.assertIsNone(orca.under_watch(root, other))
|
||||
|
||||
def test_find_gcode_arg_missing(self):
|
||||
with self.assertRaises(ValueError):
|
||||
orca.find_gcode_arg(["orca_senden.py"])
|
||||
|
||||
def test_find_gcode_arg_file_gone(self):
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
orca.find_gcode_arg(["orca_senden.py", r"C:\gibt\es\nicht.gcode"])
|
||||
|
||||
def test_load_config_ok(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "orca-senden.json"
|
||||
path.write_text(
|
||||
json.dumps({"url": "http://127.0.0.1:8770/", "token": "grt_abc"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cfg = orca.load_config(path)
|
||||
self.assertEqual(cfg["url"], "http://127.0.0.1:8770")
|
||||
self.assertEqual(cfg["token"], "grt_abc")
|
||||
|
||||
def test_load_config_missing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "fehlt.json"
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
orca.load_config(path)
|
||||
|
||||
def test_load_config_empty_token(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "orca-senden.json"
|
||||
path.write_text(json.dumps({"url": "http://x", "token": ""}), encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
orca.load_config(path)
|
||||
|
||||
def test_main_shows_error_without_config(self):
|
||||
with patch.object(orca, "CONFIG_PATH", Path("/tmp/does-not-exist-orca.json")):
|
||||
with patch.object(orca, "show_error") as shown:
|
||||
code = orca.main(["orca_senden.py", "x.gcode"])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertTrue(shown.called)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Auftrags-Status und Angebotsrechnung."""
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from grotti.cost import offer_from_file
|
||||
from grotti.orders import ORDER_STATUSES, IDEA_TO_ORDER, PROJECT_TO_ORDER, normalize_status
|
||||
|
||||
|
||||
class OrderStatusTests(unittest.TestCase):
|
||||
def test_all_status_values(self):
|
||||
for s in ("idee", "angebot", "bestaetigt", "druckt", "fertig", "geliefert", "bezahlt"):
|
||||
self.assertIn(s, ORDER_STATUSES)
|
||||
self.assertEqual(normalize_status(s), s)
|
||||
|
||||
def test_unknown_status(self):
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_status("xyz")
|
||||
|
||||
def test_legacy_project_map(self):
|
||||
self.assertEqual(PROJECT_TO_ORDER["bereit"], "angebot")
|
||||
self.assertEqual(PROJECT_TO_ORDER["druckt"], "druckt")
|
||||
|
||||
def test_legacy_idea_map(self):
|
||||
self.assertEqual(IDEA_TO_ORDER["erledigt"], "fertig")
|
||||
self.assertEqual(IDEA_TO_ORDER["spaeter"], "idee")
|
||||
|
||||
|
||||
class OfferFromFileTests(unittest.TestCase):
|
||||
def test_basic_offer(self):
|
||||
file_row = {"weight_g": 50, "estimated_s": 3600}
|
||||
out = offer_from_file(file_row, material_price=20, machine_rate_per_h=10, setup_min=15, margin=20)
|
||||
self.assertAlmostEqual(out["material_cost"], 1.0)
|
||||
self.assertAlmostEqual(out["hours"], 1.0)
|
||||
self.assertAlmostEqual(out["machine_cost"], 10 + 2.5)
|
||||
self.assertAlmostEqual(out["total"], out["subtotal"] * 1.2)
|
||||
|
||||
def test_empty_file(self):
|
||||
out = offer_from_file({}, 0, 0, 0, 0)
|
||||
self.assertEqual(out["total"], 0.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -38,12 +38,16 @@ class DispatcherHelpers(unittest.TestCase):
|
||||
class ResolveSpoolTests(unittest.TestCase):
|
||||
def test_material_match(self):
|
||||
from grotti.jobs import assert_material_match
|
||||
from grotti.errors import GrottiError
|
||||
|
||||
file_row = {"filaments": [{"type": "PLA"}]}
|
||||
assert_material_match(file_row, [{"material_type": "PLA"}])
|
||||
with self.assertRaises(GrottiError):
|
||||
assert_material_match(file_row, [{"material_type": "PETG"}])
|
||||
assert_material_match(file_row, [{"material_type": "PETG"}])
|
||||
|
||||
def test_ace_type_for_pa12(self):
|
||||
from grotti.catalog import ace_material_type
|
||||
|
||||
self.assertEqual(ace_material_type("PA12-CF"), "PA6-CF")
|
||||
self.assertEqual(ace_material_type("PETG"), "PETG")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Start-Zeitfenster."""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from grotti.time_window import allowed_now
|
||||
|
||||
|
||||
class TimeWindowTests(unittest.TestCase):
|
||||
def test_overnight_window(self):
|
||||
window = {"allowed": [{"from": "22:00", "to": "06:00"}]}
|
||||
inside = datetime(2026, 10, 18, 23, 0, tzinfo=ZoneInfo("Europe/Berlin"))
|
||||
outside = datetime(2026, 10, 18, 12, 0, tzinfo=ZoneInfo("Europe/Berlin"))
|
||||
self.assertTrue(allowed_now(window, now=inside))
|
||||
self.assertFalse(allowed_now(window, now=outside))
|
||||
|
||||
def test_empty_allows_always(self):
|
||||
self.assertTrue(allowed_now({}))
|
||||
self.assertTrue(allowed_now(None))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -12,7 +12,8 @@ function escapeHtml(value) {
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
@@ -27,13 +28,29 @@ async function api(path, options = {}) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
body = JSON.stringify(options.json);
|
||||
}
|
||||
const response = await fetch("/api/v1" + path, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const timeoutMs = options.timeoutMs != null ? Number(options.timeoutMs) : 20000;
|
||||
const ctrl = options.signal ? null : new AbortController();
|
||||
const timer = ctrl && timeoutMs > 0
|
||||
? setTimeout(() => ctrl.abort(), timeoutMs)
|
||||
: 0;
|
||||
let response;
|
||||
try {
|
||||
response = await fetch("/api/v1" + path, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
signal: options.signal || (ctrl && ctrl.signal),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error && error.name === "AbortError") {
|
||||
throw new Error("Zeitüberschreitung — Server antwortet nicht.");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
if (response.status === 401) {
|
||||
const here = location.pathname + location.search;
|
||||
if (!here.startsWith("/login") && !here.startsWith("/setup")) {
|
||||
@@ -62,6 +79,34 @@ function withToken(url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const CAMERA_MODE_KEY = "grotti-camera";
|
||||
|
||||
function cameraMode() {
|
||||
try {
|
||||
return localStorage.getItem(CAMERA_MODE_KEY) === "stream" ? "stream" : "snapshot";
|
||||
} catch (error) {
|
||||
return "snapshot";
|
||||
}
|
||||
}
|
||||
|
||||
function setCameraMode(mode) {
|
||||
const value = mode === "stream" ? "stream" : "snapshot";
|
||||
try { localStorage.setItem(CAMERA_MODE_KEY, value); } catch (error) {}
|
||||
window.dispatchEvent(new CustomEvent("grotti-camera-mode", { detail: value }));
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Snapshot oder Stream-URL je nach Browser-Einstellung. bustMs nur für Snapshot. */
|
||||
function cameraUrl(printer, bustMs) {
|
||||
if (!printer || !printer.camera) return "";
|
||||
if (cameraMode() === "stream" && printer.camera_stream) {
|
||||
return withToken(printer.camera_stream);
|
||||
}
|
||||
let url = withToken(printer.camera);
|
||||
if (bustMs) url += "&t=" + Math.floor(Date.now() / bustMs);
|
||||
return url;
|
||||
}
|
||||
|
||||
function euro(value) {
|
||||
return Number(value || 0).toLocaleString("de-DE", { style: "currency", currency: "EUR" });
|
||||
}
|
||||
@@ -89,7 +134,126 @@ function clockOf(iso) {
|
||||
if (!iso) return "";
|
||||
const at = new Date(iso);
|
||||
if (Number.isNaN(at.getTime())) return "";
|
||||
return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: appTimeZone() });
|
||||
}
|
||||
|
||||
const TZ_KEY = "grotti-timezone";
|
||||
|
||||
function appTimeZone() {
|
||||
try {
|
||||
const value = localStorage.getItem(TZ_KEY);
|
||||
if (value && value !== "auto") return value;
|
||||
} catch (error) { /* Browser */ }
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatDateTime(iso, opts) {
|
||||
if (!iso) return "—";
|
||||
const at = new Date(iso);
|
||||
if (Number.isNaN(at.getTime())) return "—";
|
||||
const options = {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: appTimeZone(),
|
||||
...(opts || {}),
|
||||
};
|
||||
return at.toLocaleString("de-DE", options);
|
||||
}
|
||||
|
||||
function formatDateRange(startIso, endIso) {
|
||||
const start = startIso ? new Date(startIso) : null;
|
||||
const end = endIso ? new Date(endIso) : null;
|
||||
if (start && Number.isNaN(start.getTime())) return "—";
|
||||
if (!start && !end) return "—";
|
||||
if (start && !end) return formatDateTime(startIso) + " → …";
|
||||
if (!start && end) return "… → " + formatDateTime(endIso);
|
||||
const sameDay = start.toDateString() === end.toDateString();
|
||||
const left = formatDateTime(startIso);
|
||||
if (sameDay) {
|
||||
const right = end.toLocaleTimeString("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: appTimeZone(),
|
||||
});
|
||||
return left + " – " + right;
|
||||
}
|
||||
return left + " – " + formatDateTime(endIso);
|
||||
}
|
||||
|
||||
function tempChartHtml(samples) {
|
||||
if (!samples || samples.length < 2) {
|
||||
return '<p class="tiny">Noch zu wenig Proben für den Verlauf.</p>';
|
||||
}
|
||||
const times = samples.map((s) => new Date(s.sampled_at).getTime());
|
||||
const t0 = times[0];
|
||||
const t1 = times[times.length - 1];
|
||||
const span = Math.max(1, t1 - t0);
|
||||
const temps = samples.flatMap((s) => [s.nozzle, s.bed, s.chamber, s.nozzle_target, s.bed_target].filter((v) => v != null));
|
||||
const minT = Math.min(...temps, 0);
|
||||
const maxT = Math.max(...temps, 100);
|
||||
const hasEd = samples.some((s) => s.nozzle_power != null || s.bed_power != null);
|
||||
const w = 640;
|
||||
const h = 210;
|
||||
const padL = 44;
|
||||
const padR = hasEd ? 44 : 28;
|
||||
const padT = 22;
|
||||
const padB = 30;
|
||||
const x = (ms) => padL + ((ms - t0) / span) * (w - padL - padR);
|
||||
const yT = (v) => padT + (1 - (Number(v) - minT) / Math.max(1, maxT - minT)) * (h - padT - padB);
|
||||
const yEd = (v) => padT + (1 - Math.max(0, Math.min(1, Number(v) || 0))) * (h - padT - padB);
|
||||
|
||||
function poly(key, color, dashed, scaleEd) {
|
||||
const pts = [];
|
||||
samples.forEach((s, i) => {
|
||||
const v = s[key];
|
||||
if (v == null) return;
|
||||
const yy = scaleEd ? yEd(Number(v)) : yT(v);
|
||||
pts.push(`${x(times[i]).toFixed(1)},${yy.toFixed(1)}`);
|
||||
});
|
||||
if (pts.length < 2) return "";
|
||||
const dash = dashed ? ' stroke-dasharray="5 4"' : "";
|
||||
return `<polyline fill="none" stroke="${color}" stroke-width="${dashed ? 1.2 : 1.7}"${dash} points="${pts.join(" ")}" />`;
|
||||
}
|
||||
|
||||
const tickCount = Math.min(5, samples.length);
|
||||
const timeTicks = [];
|
||||
for (let i = 0; i < tickCount; i++) {
|
||||
const ms = t0 + (span * i) / Math.max(1, tickCount - 1);
|
||||
const label = new Date(ms).toLocaleTimeString("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: appTimeZone(),
|
||||
});
|
||||
timeTicks.push(`<text x="${x(ms).toFixed(1)}" y="${h - 6}" text-anchor="middle" class="chart-label">${escapeHtml(label)}</text>`);
|
||||
}
|
||||
const midY = (padT + h - padB) / 2;
|
||||
|
||||
return `<svg class="temp-chart" viewBox="0 0 ${w} ${h}" role="img" aria-label="Temperatur links in °C, Einschaltdauer rechts in Prozent">
|
||||
${poly("nozzle_target", "#fdba74", true, false)}
|
||||
${poly("bed_target", "#93c5fd", true, false)}
|
||||
${poly("nozzle", "#ea580c", false, false)}
|
||||
${poly("bed", "#2563eb", false, false)}
|
||||
${poly("chamber", "#16a34a", false, false)}
|
||||
${hasEd ? poly("nozzle_power", "#c084fc", false, true) : ""}
|
||||
${hasEd ? poly("bed_power", "#22d3ee", false, true) : ""}
|
||||
<text x="10" y="${midY}" class="chart-axis" transform="rotate(-90 10 ${midY})">Temp °C</text>
|
||||
<text x="${padL}" y="14" class="chart-label">${Math.round(maxT)}</text>
|
||||
<text x="${padL}" y="${h - padB + 2}" class="chart-label">${Math.round(minT)}</text>
|
||||
${hasEd ? `<text x="${w - 10}" y="${midY}" class="chart-axis" text-anchor="middle" transform="rotate(90 ${w - 10} ${midY})">ED %</text>` : ""}
|
||||
${hasEd ? `<text x="${w - padR}" y="14" text-anchor="end" class="chart-label">100</text>` : ""}
|
||||
${hasEd ? `<text x="${w - padR}" y="${h - padB + 2}" text-anchor="end" class="chart-label">0</text>` : ""}
|
||||
${timeTicks.join("")}
|
||||
</svg>
|
||||
<p class="tiny chart-legend">
|
||||
<span class="leg nozzle">Düse</span>
|
||||
<span class="leg bed">Bett</span>
|
||||
<span class="leg chamber">Kammer</span>
|
||||
${hasEd ? '<span class="leg ed-n">ED Düse</span><span class="leg ed-b">ED Bett</span>' : ""}
|
||||
· links °C · rechts % · gestrichelt ≈ Soll
|
||||
</p>`;
|
||||
}
|
||||
|
||||
function showNote(id, error, ok) {
|
||||
@@ -111,9 +275,10 @@ function icon(name, cls) {
|
||||
return `<svg class="ico${cls ? " " + cls : ""}" aria-hidden="true"><use href="#i-${name}"/></svg>`;
|
||||
}
|
||||
|
||||
function chipHtml(state, label, small) {
|
||||
function chipHtml(state, label, small, iconName) {
|
||||
const text = label || STATE_LABELS[state] || state || "";
|
||||
return `<span class="chip ${escapeHtml(state || "")}${small ? " sm" : ""}">${escapeHtml(text)}</span>`;
|
||||
const ico = iconName ? icon(iconName, "chip-ico") : "";
|
||||
return `<span class="chip ${escapeHtml(state || "")}${small ? " sm" : ""}">${ico}${escapeHtml(text)}</span>`;
|
||||
}
|
||||
|
||||
function shownState(printer) {
|
||||
@@ -158,10 +323,16 @@ function toast(message, kind) {
|
||||
host.setAttribute("aria-live", "polite");
|
||||
document.body.appendChild(host);
|
||||
}
|
||||
const text = String(message ?? "");
|
||||
host.querySelectorAll(".toast").forEach((node) => {
|
||||
const body = node.querySelector("div");
|
||||
if (body && body.textContent === text) node.remove();
|
||||
});
|
||||
while (host.children.length >= 3) host.firstElementChild?.remove();
|
||||
const node = document.createElement("div");
|
||||
node.className = "toast " + (kind || "good");
|
||||
node.innerHTML = icon(kind === "bad" ? "alert" : "check", "lg") + "<div></div>";
|
||||
node.querySelector("div").textContent = message;
|
||||
node.querySelector("div").textContent = text;
|
||||
host.appendChild(node);
|
||||
const ttl = kind === "bad" ? 7000 : 4000;
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,528 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Lucide icons, built by scripts/build_icons.py — 111 symbols -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<symbol id="i-activity" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
|
||||
</symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</symbol>
|
||||
<symbol id="i-arrow-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5v14" />
|
||||
<path d="m19 12-7 7-7-7" />
|
||||
</symbol>
|
||||
<symbol id="i-arrow-up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
<path d="M12 19V5" />
|
||||
</symbol>
|
||||
<symbol id="i-ban" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m4.9 4.9 14.2 14.2" />
|
||||
</symbol>
|
||||
<symbol id="i-bed" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="12" x="2" y="6" rx="2" />
|
||||
</symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.268 21a2 2 0 0 0 3.464 0" />
|
||||
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" />
|
||||
</symbol>
|
||||
<symbol id="i-book" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 7v14" />
|
||||
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
|
||||
</symbol>
|
||||
<symbol id="i-brush" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08" />
|
||||
<path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z" />
|
||||
</symbol>
|
||||
<symbol id="i-bulb" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" />
|
||||
<path d="M9 18h6" />
|
||||
<path d="M10 22h4" />
|
||||
</symbol>
|
||||
<symbol id="i-calendar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 2v4" />
|
||||
<path d="M16 2v4" />
|
||||
<rect width="18" height="18" x="3" y="4" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
</symbol>
|
||||
<symbol id="i-camera" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
|
||||
<circle cx="12" cy="13" r="3" />
|
||||
</symbol>
|
||||
<symbol id="i-chamber" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
||||
<path d="m3.3 7 8.7 5 8.7-5" />
|
||||
<path d="M12 22V12" />
|
||||
</symbol>
|
||||
<symbol id="i-chart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
|
||||
<path d="M18 17V9" />
|
||||
<path d="M13 17V5" />
|
||||
<path d="M8 17v-3" />
|
||||
</symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</symbol>
|
||||
<symbol id="i-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</symbol>
|
||||
<symbol id="i-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</symbol>
|
||||
<symbol id="i-circle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</symbol>
|
||||
<symbol id="i-circle-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</symbol>
|
||||
<symbol id="i-circle-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m15 9-6 6" />
|
||||
<path d="m9 9 6 6" />
|
||||
</symbol>
|
||||
<symbol id="i-clipboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<path d="M12 11h4" />
|
||||
<path d="M12 16h4" />
|
||||
<path d="M8 11h.01" />
|
||||
<path d="M8 16h.01" />
|
||||
</symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</symbol>
|
||||
<symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</symbol>
|
||||
<symbol id="i-cube" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
||||
<path d="m3.3 7 8.7 5 8.7-5" />
|
||||
<path d="M12 22V12" />
|
||||
</symbol>
|
||||
<symbol id="i-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" x2="12" y1="15" y2="3" />
|
||||
</symbol>
|
||||
<symbol id="i-droplet" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z" />
|
||||
</symbol>
|
||||
<symbol id="i-edit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</symbol>
|
||||
<symbol id="i-euro" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 10h12" />
|
||||
<path d="M4 14h9" />
|
||||
<path d="M19 6a7.7 7.7 0 0 0-5.2-2A7.9 7.9 0 0 0 6 12c0 4.4 3.5 8 7.8 8 2 0 3.8-.8 5.2-2" />
|
||||
</symbol>
|
||||
<symbol id="i-external" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14 21 3" />
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</symbol>
|
||||
<symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</symbol>
|
||||
<symbol id="i-eye-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
|
||||
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
|
||||
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
|
||||
<path d="m2 2 20 20" />
|
||||
</symbol>
|
||||
<symbol id="i-fan" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z" />
|
||||
<path d="M12 12v.01" />
|
||||
</symbol>
|
||||
<symbol id="i-fan-aux" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.8 19.6A2 2 0 1 0 14 16H2" />
|
||||
<path d="M17.5 8a2.5 2.5 0 1 1 2 4H2" />
|
||||
<path d="M9.8 4.4A2 2 0 1 1 11 8H2" />
|
||||
</symbol>
|
||||
<symbol id="i-fan-chamber" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 12H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<path d="M6 8h12" />
|
||||
<path d="M18.3 17.7a2.5 2.5 0 0 1-3.16 3.83 2.53 2.53 0 0 1-1.14-2V12" />
|
||||
<path d="M6.6 15.6A2 2 0 1 0 10 17v-5" />
|
||||
</symbol>
|
||||
<symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</symbol>
|
||||
<symbol id="i-filter" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
|
||||
</symbol>
|
||||
<symbol id="i-flame" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />
|
||||
</symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</symbol>
|
||||
<symbol id="i-folder-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
|
||||
</symbol>
|
||||
<symbol id="i-gauge" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m12 14 4-4" />
|
||||
<path d="M3.34 19a10 10 0 1 1 17.32 0" />
|
||||
</symbol>
|
||||
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</symbol>
|
||||
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" x2="2" y1="12" y2="12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
<line x1="6" x2="6.01" y1="16" y2="16" />
|
||||
<line x1="10" x2="10.01" y1="16" y2="16" />
|
||||
</symbol>
|
||||
<symbol id="i-hash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" x2="20" y1="9" y2="9" />
|
||||
<line x1="4" x2="20" y1="15" y2="15" />
|
||||
<line x1="10" x2="8" y1="3" y2="21" />
|
||||
<line x1="16" x2="14" y1="3" y2="21" />
|
||||
</symbol>
|
||||
<symbol id="i-heart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
|
||||
</symbol>
|
||||
<symbol id="i-history" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M12 7v5l4 2" />
|
||||
</symbol>
|
||||
<symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
|
||||
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
</symbol>
|
||||
<symbol id="i-image" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</symbol>
|
||||
<symbol id="i-inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</symbol>
|
||||
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</symbol>
|
||||
<symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" />
|
||||
<path d="m21 2-9.6 9.6" />
|
||||
<circle cx="7.5" cy="15.5" r="5.5" />
|
||||
</symbol>
|
||||
<symbol id="i-keyboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 8h.01" />
|
||||
<path d="M12 12h.01" />
|
||||
<path d="M14 8h.01" />
|
||||
<path d="M16 12h.01" />
|
||||
<path d="M18 8h.01" />
|
||||
<path d="M6 8h.01" />
|
||||
<path d="M7 16h10" />
|
||||
<path d="M8 12h.01" />
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
</symbol>
|
||||
<symbol id="i-layers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
|
||||
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
|
||||
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
|
||||
</symbol>
|
||||
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</symbol>
|
||||
<symbol id="i-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12h.01" />
|
||||
<path d="M3 18h.01" />
|
||||
<path d="M3 6h.01" />
|
||||
<path d="M8 12h13" />
|
||||
<path d="M8 18h13" />
|
||||
<path d="M8 6h13" />
|
||||
</symbol>
|
||||
<symbol id="i-loader" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</symbol>
|
||||
<symbol id="i-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" x2="20" y1="12" y2="12" />
|
||||
<line x1="4" x2="20" y1="6" y2="6" />
|
||||
<line x1="4" x2="20" y1="18" y2="18" />
|
||||
</symbol>
|
||||
<symbol id="i-message" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
|
||||
</symbol>
|
||||
<symbol id="i-minus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" />
|
||||
</symbol>
|
||||
<symbol id="i-monitor" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</symbol>
|
||||
<symbol id="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</symbol>
|
||||
<symbol id="i-more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
</symbol>
|
||||
<symbol id="i-nozzle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m2 22 1-1h3l9-9" />
|
||||
<path d="M3 21v-3l9-9" />
|
||||
<path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.4.4a2.1 2.1 0 1 1-3 3l-3.8-3.8a2.1 2.1 0 1 1 3-3l.4.4Z" />
|
||||
</symbol>
|
||||
<symbol id="i-package" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" />
|
||||
<path d="M12 22V12" />
|
||||
<path d="m3.3 7 7.703 4.734a2 2 0 0 0 1.994 0L20.7 7" />
|
||||
<path d="m7.5 4.27 9 5.15" />
|
||||
</symbol>
|
||||
<symbol id="i-palette" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
|
||||
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
|
||||
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
|
||||
</symbol>
|
||||
<symbol id="i-panel" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="14" y="4" width="4" height="16" rx="1" />
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" />
|
||||
</symbol>
|
||||
<symbol id="i-percent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" x2="5" y1="5" y2="19" />
|
||||
<circle cx="6.5" cy="6.5" r="2.5" />
|
||||
<circle cx="17.5" cy="17.5" r="2.5" />
|
||||
</symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="6 3 20 12 6 21 6 3" />
|
||||
</symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</symbol>
|
||||
<symbol id="i-power" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2v10" />
|
||||
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
|
||||
</symbol>
|
||||
<symbol id="i-printer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6" />
|
||||
<rect x="6" y="14" width="12" height="8" rx="1" />
|
||||
</symbol>
|
||||
<symbol id="i-queue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 12h11" />
|
||||
<path d="M10 18h11" />
|
||||
<path d="M10 6h11" />
|
||||
<path d="M4 10h2" />
|
||||
<path d="M4 6h1v4" />
|
||||
<path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
|
||||
</symbol>
|
||||
<symbol id="i-receipt" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z" />
|
||||
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8" />
|
||||
<path d="M12 17.5v-11" />
|
||||
</symbol>
|
||||
<symbol id="i-redo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m15 14 5-5-5-5" />
|
||||
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13" />
|
||||
</symbol>
|
||||
<symbol id="i-refresh" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M8 16H3v5" />
|
||||
</symbol>
|
||||
<symbol id="i-rotate-ccw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</symbol>
|
||||
<symbol id="i-save" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" />
|
||||
<path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7" />
|
||||
<path d="M7 3v4a1 1 0 0 0 1 1h7" />
|
||||
</symbol>
|
||||
<symbol id="i-scan" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
||||
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
||||
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
||||
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
||||
<path d="M7 12h10" />
|
||||
</symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</symbol>
|
||||
<symbol id="i-send" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z" />
|
||||
<path d="m21.854 2.147-10.94 10.939" />
|
||||
</symbol>
|
||||
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
|
||||
<rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
|
||||
<line x1="6" x2="6.01" y1="6" y2="6" />
|
||||
<line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
</symbol>
|
||||
<symbol id="i-sliders" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="21" x2="14" y1="4" y2="4" />
|
||||
<line x1="10" x2="3" y1="4" y2="4" />
|
||||
<line x1="21" x2="12" y1="12" y2="12" />
|
||||
<line x1="8" x2="3" y1="12" y2="12" />
|
||||
<line x1="21" x2="16" y1="20" y2="20" />
|
||||
<line x1="12" x2="3" y1="20" y2="20" />
|
||||
<line x1="14" x2="14" y1="2" y2="6" />
|
||||
<line x1="8" x2="8" y1="10" y2="14" />
|
||||
<line x1="16" x2="16" y1="18" y2="22" />
|
||||
</symbol>
|
||||
<symbol id="i-smartphone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
|
||||
<path d="M12 18h.01" />
|
||||
</symbol>
|
||||
<symbol id="i-sparkles" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
|
||||
<path d="M20 3v4" />
|
||||
<path d="M22 5h-4" />
|
||||
<path d="M4 17v2" />
|
||||
<path d="M5 18H3" />
|
||||
</symbol>
|
||||
<symbol id="i-spool" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M6 12c0-1.7.7-3.2 1.8-4.2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<path d="M18 12c0 1.7-.7 3.2-1.8 4.2" />
|
||||
</symbol>
|
||||
<symbol id="i-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
|
||||
</symbol>
|
||||
<symbol id="i-stop" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
</symbol>
|
||||
<symbol id="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</symbol>
|
||||
<symbol id="i-sweep" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08" />
|
||||
<path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z" />
|
||||
</symbol>
|
||||
<symbol id="i-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" />
|
||||
<circle cx="7.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
</symbol>
|
||||
<symbol id="i-thermo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" />
|
||||
</symbol>
|
||||
<symbol id="i-timer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="10" x2="14" y1="2" y2="2" />
|
||||
<line x1="12" x2="15" y1="14" y2="11" />
|
||||
<circle cx="12" cy="14" r="8" />
|
||||
</symbol>
|
||||
<symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
</symbol>
|
||||
<symbol id="i-truck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2" />
|
||||
<path d="M15 18H9" />
|
||||
<path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14" />
|
||||
<circle cx="17" cy="18" r="2" />
|
||||
<circle cx="7" cy="18" r="2" />
|
||||
</symbol>
|
||||
<symbol id="i-undo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 14 4 9l5-5" />
|
||||
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11" />
|
||||
</symbol>
|
||||
<symbol id="i-unlock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
||||
</symbol>
|
||||
<symbol id="i-up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" x2="12" y1="3" y2="15" />
|
||||
</symbol>
|
||||
<symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</symbol>
|
||||
<symbol id="i-users" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</symbol>
|
||||
<symbol id="i-weight" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="5" r="3" />
|
||||
<path d="M6.5 8a2 2 0 0 0-1.905 1.46L2.1 18.5A2 2 0 0 0 4 21h16a2 2 0 0 0 1.925-2.54L19.4 9.5A2 2 0 0 0 17.48 8Z" />
|
||||
</symbol>
|
||||
<symbol id="i-wifi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h.01" />
|
||||
<path d="M2 8.82a15 15 0 0 1 20 0" />
|
||||
<path d="M5 12.859a10 10 0 0 1 14 0" />
|
||||
<path d="M8.5 16.429a5 5 0 0 1 7 0" />
|
||||
</symbol>
|
||||
<symbol id="i-wifi-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h.01" />
|
||||
<path d="M8.5 16.429a5 5 0 0 1 7 0" />
|
||||
<path d="M5 12.859a10 10 0 0 1 5.17-2.69" />
|
||||
<path d="M19 12.859a10 10 0 0 0-2.007-1.523" />
|
||||
<path d="M2 8.82a15 15 0 0 1 4.177-2.643" />
|
||||
<path d="M22 8.82a15 15 0 0 0-11.288-3.764" />
|
||||
<path d="m2 2 20 20" />
|
||||
</symbol>
|
||||
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</symbol>
|
||||
<symbol id="i-zap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" />
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Grotti",
|
||||
"short_name": "Grotti",
|
||||
"description": "3D-Druck-Farm steuern",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0c",
|
||||
"theme_color": "#0a0a0c",
|
||||
"lang": "de",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/brand/favicon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/brand/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/* Nur Static-Assets cachen — nie HTML-Navigation abfangen (sonst falsche Seite in Chrome). */
|
||||
const CACHE = "grotti-shell-v3";
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
self.skipWaiting();
|
||||
event.waitUntil(
|
||||
caches.open(CACHE).then((cache) =>
|
||||
cache.addAll([
|
||||
"/static/grotti.css?v=62",
|
||||
"/static/api.js?v=32",
|
||||
"/static/ui.js?v=5",
|
||||
"/static/icons.svg",
|
||||
"/static/fonts/InterVariable.woff2",
|
||||
]).catch(() => undefined)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
// Dokument-Navigation immer ans Netz — kein Fallback auf „/“.
|
||||
if (req.mode === "navigate") return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
if (!url.pathname.startsWith("/static/")) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(req)
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {});
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() => caches.match(req))
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
/** Live-UI: Verbindungsbanner, Poller, gemeinsame Zustands-Labels. */
|
||||
|
||||
const RUN_STATES = {
|
||||
completed: "fertig",
|
||||
cancelled: "abgebrochen",
|
||||
error: "Fehler",
|
||||
in_progress: "druckt",
|
||||
printing: "druckt",
|
||||
paused: "pausiert",
|
||||
interrupted: "unterbrochen",
|
||||
standby: "bereit",
|
||||
heating: "heizt",
|
||||
complete: "fertig",
|
||||
offline: "offline",
|
||||
waiting: "wartet",
|
||||
running: "läuft",
|
||||
};
|
||||
|
||||
let connBannerOk = true;
|
||||
let connBannerTimer = 0;
|
||||
|
||||
function setConnectionBanner(ok, text) {
|
||||
let el = document.getElementById("conn-banner");
|
||||
if (!el) {
|
||||
el = document.createElement("div");
|
||||
el.id = "conn-banner";
|
||||
el.className = "conn-banner";
|
||||
el.setAttribute("role", "status");
|
||||
el.hidden = true;
|
||||
document.body.prepend(el);
|
||||
}
|
||||
clearTimeout(connBannerTimer);
|
||||
if (ok) {
|
||||
const wasBad = !connBannerOk;
|
||||
connBannerOk = true;
|
||||
if (wasBad && text) {
|
||||
el.textContent = text;
|
||||
el.className = "conn-banner ok";
|
||||
el.hidden = false;
|
||||
connBannerTimer = setTimeout(() => {
|
||||
el.hidden = true;
|
||||
el.textContent = "";
|
||||
}, 2800);
|
||||
return;
|
||||
}
|
||||
el.hidden = true;
|
||||
el.textContent = "";
|
||||
el.className = "conn-banner";
|
||||
return;
|
||||
}
|
||||
connBannerOk = false;
|
||||
el.textContent = text || "Keine Verbindung zum Server.";
|
||||
el.className = "conn-banner bad";
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {() => void | Promise<void>} fn
|
||||
* @param {number} intervalMs
|
||||
* @returns {{ start: () => void, stop: () => void }}
|
||||
*/
|
||||
function createPoller(fn, intervalMs) {
|
||||
const base = Math.max(1000, Number(intervalMs) || 5000);
|
||||
const maxMs = 60000;
|
||||
let timer = 0;
|
||||
let busy = false;
|
||||
let delay = base;
|
||||
let fails = 0;
|
||||
let running = false;
|
||||
|
||||
function schedule() {
|
||||
if (!running) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(tick, delay);
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (!running || document.hidden || busy) {
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
await fn();
|
||||
delay = base;
|
||||
fails = 0;
|
||||
if (!connBannerOk) setConnectionBanner(true, "Wieder verbunden.");
|
||||
else setConnectionBanner(true, "");
|
||||
} catch (error) {
|
||||
fails += 1;
|
||||
delay = Math.min(maxMs, Math.max(base, delay * 2));
|
||||
if (fails >= 2) {
|
||||
setConnectionBanner(false, (error && error.message) || "Daten konnten nicht geladen werden.");
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
|
||||
function onVis() {
|
||||
if (!document.hidden && running) tick();
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
tick();
|
||||
},
|
||||
stop() {
|
||||
running = false;
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
clearTimeout(timer);
|
||||
timer = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const VERDICT_REASON_LABELS = {
|
||||
haftung: "Haftung",
|
||||
warping: "Warping",
|
||||
spaghetti: "Spaghetti",
|
||||
layer_shift: "Layer-Shift",
|
||||
verstopfung: "Verstopfung",
|
||||
farbe: "Farbe",
|
||||
sonstiges: "Sonstiges",
|
||||
};
|
||||
|
||||
/**
|
||||
* Dialog vor „Platte geräumt“: Urteil, optional Grund und Stückzahlen.
|
||||
* @returns {Promise<object|null>} Payload für /abraeumen oder null bei Abbruch
|
||||
*/
|
||||
function promptAbraeumen(hostLabel) {
|
||||
return new Promise((resolve) => {
|
||||
const existing = document.getElementById("abraeumen-dlg");
|
||||
if (existing) existing.remove();
|
||||
const dlg = document.createElement("dialog");
|
||||
dlg.id = "abraeumen-dlg";
|
||||
dlg.className = "abraeumen-dlg";
|
||||
const reasons = Object.entries(VERDICT_REASON_LABELS)
|
||||
.map(([k, label]) => `<option value="${k}">${label}</option>`)
|
||||
.join("");
|
||||
dlg.innerHTML = `
|
||||
<form method="dialog" class="abraeumen-form">
|
||||
<h2>Platte geräumt</h2>
|
||||
<p class="tiny">${escapeHtml(hostLabel || "Drucker")}</p>
|
||||
<div class="field"><label for="ab-verdict">Urteil</label>
|
||||
<select id="ab-verdict" name="verdict">
|
||||
<option value="gut">gut</option>
|
||||
<option value="ausschuss">Ausschuss</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" id="ab-reason-wrap" hidden>
|
||||
<label for="ab-reason">Grund</label>
|
||||
<select id="ab-reason">${reasons}</select>
|
||||
</div>
|
||||
<div class="grid-2" id="ab-pieces" hidden>
|
||||
<div class="field"><label for="ab-ok">Stück gut</label><input id="ab-ok" type="number" min="0" step="1" value="0"></div>
|
||||
<div class="field"><label for="ab-bad">Stück schlecht</label><input id="ab-bad" type="number" min="0" step="1" value="1"></div>
|
||||
</div>
|
||||
<label class="check"><input type="checkbox" id="ab-reprint"> Gleich nochmal drucken</label>
|
||||
<div class="actions">
|
||||
<button type="submit" value="ok" class="btn-primary">Quittieren</button>
|
||||
<button type="button" class="ghost" id="ab-cancel">Abbrechen</button>
|
||||
</div>
|
||||
</form>`;
|
||||
document.body.appendChild(dlg);
|
||||
const verdict = dlg.querySelector("#ab-verdict");
|
||||
const reasonWrap = dlg.querySelector("#ab-reason-wrap");
|
||||
const pieces = dlg.querySelector("#ab-pieces");
|
||||
function sync() {
|
||||
const scrap = verdict.value === "ausschuss";
|
||||
reasonWrap.hidden = !scrap;
|
||||
pieces.hidden = !scrap;
|
||||
}
|
||||
verdict.addEventListener("change", sync);
|
||||
sync();
|
||||
dlg.querySelector("#ab-cancel").addEventListener("click", () => {
|
||||
dlg.close();
|
||||
dlg.remove();
|
||||
resolve(null);
|
||||
});
|
||||
dlg.addEventListener("close", () => {
|
||||
if (dlg.returnValue !== "ok") {
|
||||
dlg.remove();
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
confirm: true,
|
||||
verdict: verdict.value,
|
||||
verdict_reason: verdict.value === "ausschuss" ? dlg.querySelector("#ab-reason").value : "",
|
||||
pieces_ok: Number(dlg.querySelector("#ab-ok").value) || 0,
|
||||
pieces_bad: Number(dlg.querySelector("#ab-bad").value) || 0,
|
||||
reprint: !!dlg.querySelector("#ab-reprint").checked,
|
||||
};
|
||||
dlg.remove();
|
||||
resolve(payload);
|
||||
});
|
||||
dlg.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast mit Rückgängig für umkehrbare Aktionen.
|
||||
* @param {string} message
|
||||
* @param {() => void | Promise<void>} onUndo
|
||||
* @param {number} [ttlMs]
|
||||
*/
|
||||
function undoToast(message, onUndo, ttlMs) {
|
||||
let host = document.querySelector(".toast-host");
|
||||
if (!host) {
|
||||
host = document.createElement("div");
|
||||
host.className = "toast-host";
|
||||
host.setAttribute("role", "status");
|
||||
host.setAttribute("aria-live", "polite");
|
||||
document.body.appendChild(host);
|
||||
}
|
||||
const node = document.createElement("div");
|
||||
node.className = "toast good undo-toast";
|
||||
node.innerHTML = '<div></div><button type="button">Rückgängig</button>';
|
||||
node.querySelector("div").textContent = String(message ?? "");
|
||||
let undone = false;
|
||||
const btn = node.querySelector("button");
|
||||
btn.addEventListener("click", async () => {
|
||||
if (undone) return;
|
||||
undone = true;
|
||||
node.remove();
|
||||
try { await onUndo(); } catch (error) { toast(error.message || String(error), "bad"); }
|
||||
});
|
||||
host.appendChild(node);
|
||||
setTimeout(() => {
|
||||
if (undone) return;
|
||||
node.classList.add("out");
|
||||
setTimeout(() => node.remove(), 260);
|
||||
}, ttlMs || 6000);
|
||||
}
|
||||
|
||||
/** Globale Suche Ctrl+K / Cmd+K. */
|
||||
function openCommandPalette() {
|
||||
const existing = document.getElementById("cmdk-dlg");
|
||||
if (existing) {
|
||||
existing.showModal();
|
||||
existing.querySelector("input")?.focus();
|
||||
return;
|
||||
}
|
||||
const dlg = document.createElement("dialog");
|
||||
dlg.id = "cmdk-dlg";
|
||||
dlg.className = "cmdk-dlg";
|
||||
dlg.innerHTML = `<div class="cmdk-wrap">
|
||||
<input type="search" placeholder="Drucker, Datei, Spule, Charge, Auftrag…" autocomplete="off">
|
||||
<ul class="cmdk-list"></ul>
|
||||
</div>`;
|
||||
document.body.appendChild(dlg);
|
||||
const input = dlg.querySelector("input");
|
||||
const list = dlg.querySelector(".cmdk-list");
|
||||
let items = [];
|
||||
let active = 0;
|
||||
let timer = 0;
|
||||
|
||||
function paint() {
|
||||
list.innerHTML = items.map((item, i) =>
|
||||
`<li class="${i === active ? "on" : ""}" data-i="${i}">
|
||||
<span>${escapeHtml(item.title)}</span>
|
||||
<span class="tiny">${escapeHtml(item.subtitle || "")}</span>
|
||||
</li>`
|
||||
).join("") || '<li class="tiny">Nichts gefunden.</li>';
|
||||
}
|
||||
|
||||
async function search(q) {
|
||||
q = (q || "").trim();
|
||||
if (!q) { items = []; paint(); return; }
|
||||
try {
|
||||
const data = await api("/suche?q=" + encodeURIComponent(q));
|
||||
items = data.items || [];
|
||||
} catch (error) {
|
||||
items = [];
|
||||
}
|
||||
active = 0;
|
||||
paint();
|
||||
}
|
||||
|
||||
function go(item) {
|
||||
if (!item || !item.href) return;
|
||||
dlg.close();
|
||||
location.href = item.href;
|
||||
}
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => search(input.value), 180);
|
||||
});
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
active = Math.min(items.length - 1, active + 1);
|
||||
paint();
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
active = Math.max(0, active - 1);
|
||||
paint();
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
go(items[active]);
|
||||
} else if (event.key === "Escape") {
|
||||
dlg.close();
|
||||
}
|
||||
});
|
||||
list.addEventListener("click", (event) => {
|
||||
const li = event.target.closest("[data-i]");
|
||||
if (!li) return;
|
||||
go(items[Number(li.dataset.i)]);
|
||||
});
|
||||
dlg.addEventListener("close", () => { /* bleibt im DOM */ });
|
||||
dlg.showModal();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && (event.key === "k" || event.key === "K")) {
|
||||
event.preventDefault();
|
||||
openCommandPalette();
|
||||
}
|
||||
});
|
||||
|
||||
window.undoToast = undoToast;
|
||||
window.openCommandPalette = openCommandPalette;
|
||||
|
||||
window.RUN_STATES = RUN_STATES;
|
||||
window.setConnectionBanner = setConnectionBanner;
|
||||
window.createPoller = createPoller;
|
||||
window.promptAbraeumen = promptAbraeumen;
|
||||
window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS;
|
||||
@@ -0,0 +1,18 @@
|
||||
<div class="jetzt-eta" id="jetzt-eta" hidden></div>
|
||||
<section class="queue-box is-empty" data-box="jetzt" id="jetzt-box">
|
||||
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-sparkles"/></svg><span class="grow">Jetzt</span><span class="count todo" id="jetzt-count">0</span></h2>
|
||||
<ul class="queue-list" id="jetzt-list"></ul>
|
||||
</section>
|
||||
<section class="run-stack" id="run-box" hidden>
|
||||
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-play"/></svg><span class="grow">Läuft</span></h2>
|
||||
<ul class="queue-list" id="run-list"></ul>
|
||||
</section>
|
||||
<section class="queue-box" data-box="send" id="send-box" hidden>
|
||||
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-send"/></svg><span class="grow">Senden</span><span class="count" id="send-count">0</span></h2>
|
||||
<ul class="queue-list" id="send-list"></ul>
|
||||
</section>
|
||||
<section class="queue-box" data-box="alerts" id="alert-box" hidden>
|
||||
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-bell"/></svg><span class="grow">Meldungen</span><span class="count bad" id="alert-count">0</span></h2>
|
||||
<ul class="queue-list" id="alert-list"></ul>
|
||||
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button> <a href="/aktivitaet">alle →</a></div>
|
||||
</section>
|
||||