Grotti 2.2.0: Auth, API-Tokens, MCP und Doku-Trennung.
No files matched your search
@@ -10,8 +10,9 @@ 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`.
|
||||
- Technische Doku liegt in `docs/` und wird unter `/doku/<name>` gerendert. Neue Erkenntnisse zum Tool dort ergänzen.
|
||||
- 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`.
|
||||
- Auth: Session oder API-Token (`grt_…`); Rollen admin/operator.
|
||||
|
||||
Druckbeobachtungen und Slicer-Profile gehören nicht hierher, sondern in den FDM-Wissen-Tresor (`D:\Projekte\3DP\FDM\Wissen\`).
|
||||
@@ -5,6 +5,6 @@ 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`.
|
||||
- Technische Doku liegt in `docs/` und wird unter `/doku/<name>` gerendert. Neue Erkenntnisse zum Tool dort ergänzen.
|
||||
- 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,13 +4,35 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.2.0] – 2026-10-15
|
||||
|
||||
### Neu
|
||||
|
||||
- Task-Hygiene wie Rinkhals (Kobra 3 und S1): `ensure_task_free` vor MQTT- und Moonraker-Start; bei Report **10101** einmal cancel + Retry; klarere Codes (**10101**, **10107**); LAN-Ausfall weich (negativer Cache).
|
||||
- Live-Snapshot `anycubic_task` (gkapi-State/task_id) auf der Wand.
|
||||
- Cursor-Regel `.cursor/rules/grotti.mdc` (Changelog, Doku, Version, Sicherheit).
|
||||
- Doku [Anycubic-Stack](docs/Anycubic-Stack.md): Schichten gklib/gkapi/Rinkhals, MQTT Start/Stop/10101, ACE/`.acm`, FIRMWARE_RESTART — aus Klipper-go und Rinkhals-Quellcode; Abschnitt Task-Hygiene.
|
||||
- Doku [Glossar](docs/Glossar.md): Moonraker, Mainsail, gklib, Rinkhals, OctoPrint und Nachbarn.
|
||||
- **Benutzerverwaltung:** lokaler Login (Admin/Operator), Bootstrap „Ersten Admin anlegen“, Session-Cookies, CSRF, Login-Rate-Limit.
|
||||
- **API-Tokens** (`grt_…`) unter Einstellungen für App, Home-Automation und MCP; Shared-`config.token` bleibt als Migrationsbrücke (Admin).
|
||||
- **MCP-Server** (`python -m grotti.mcp_server`): Lesen + bestätigte Steuer-Tools; `GROTTI_MCP_READ_ONLY`; Doku `docs/internal/MCP.md`.
|
||||
- OIDC-Platzhalter in Config (`oauth`) für späteren Anschluss.
|
||||
- Schema: `users`, `api_tokens`, `oauth_identities`.
|
||||
- **Logo / Favicon:** Markenzeichen in der Sidebar und als Favicon (`web/static/brand/`).
|
||||
- Login: statt Nutzhinweis wechselnde Ingenieurszitate (ein Zitat pro Tag).
|
||||
- Umfangreiche Bedienungsanleitung unter `/doku` (`docs/Anleitung-*.md`) mit UI-Screenshots.
|
||||
- Task-Hygiene wie Rinkhals (Kobra 3 und S1): `ensure_task_free` vor Start; bei **10101** cancel + Retry.
|
||||
- Live-Snapshot `anycubic_task` auf der Wand.
|
||||
- Cursor-Regel `.cursor/rules/grotti.mdc`.
|
||||
|
||||
### Geändert
|
||||
|
||||
- `/doku` zeigt nur noch `Anleitung-*.md`; Technik/Debug unter `docs/internal/` (nicht in der App). Changelog nicht mehr in der UI.
|
||||
- Anleitungen ohne interne Stack-/Debug-Details.
|
||||
- HTML und API hinter Auth (außer Setup/Login); kein Token mehr im Meta-Tag.
|
||||
- Kamera im Cockpit: Layout-Klassen bleiben erhalten; ohne erreichbare Webcam kein Platzreservierung; `has_camera` im Drucker-Payload.
|
||||
- `POST /api/v1/druck-queue` braucht `confirm: true` (wie Senden).
|
||||
|
||||
### Behoben
|
||||
|
||||
- Cockpit: periodisches Layout-Ruckeln durch `className`-Reset der Kamera-Folds.
|
||||
- Kameras-Seite und leere Flotte: echte Empty-States statt Raw-API-URL / leerem Raster.
|
||||
- Spulen-Etikett: QR-Code wieder sichtbar — `qrcode.min.js` lokal.
|
||||
|
||||
## [2.0.0] – 2026-10-15
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen.
|
||||
.\.venv\Scripts\python.exe server.py
|
||||
```
|
||||
|
||||
Dann <http://127.0.0.1:8770/> öffnen. Handbuch in der Oberfläche unter **Doku**.
|
||||
Dann <http://127.0.0.1:8770/> öffnen. Bedienungsanleitung mit Screenshots in der Oberfläche unter **Doku** (Kapitel `Anleitung-*`), Technik-Notizen darunter.
|
||||
|
||||
`config.json` und `data/` sind lokal und nicht im Git.
|
||||
|
||||
@@ -62,33 +62,39 @@ Dann <http://127.0.0.1:8770/> öffnen. Handbuch in der Oberfläche unter **Doku*
|
||||
| `server.py` | Start, Datenbank vorbereiten, Hintergrund-Threads |
|
||||
| `grotti/` | Kern: Moonraker, Warteschlange (`print_queue.py`), Senden (`send_queue.py`), Abräumen (`eject.py`), Zustandswächter (`watcher.py`), Ereignisse und Telegram (`notify.py`), Datenbank (`store.py`, `schema.sql`) |
|
||||
| `web/` | Flask-App (`app.py`), Seiten (`templates/`), CSS und JS (`static/`) |
|
||||
| `docs/` | Technische Notizen, gerendert unter `/doku/<name>` |
|
||||
| `docs/` | Bedienungsanleitung (`Anleitung-*.md`) unter `/doku`; Technik unter `docs/internal/` (nicht in der App) |
|
||||
| `web/static/doku/` | Screenshots für die Anleitung |
|
||||
| `CHANGELOG.md` | Änderungen je Version |
|
||||
|
||||
## API
|
||||
|
||||
Alles unter `/api/v1`, JSON. Die wichtigsten Endpunkte:
|
||||
Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bearer grt_…` (API-Token unter Einstellungen). Optional noch Shared-`token` in der Config (Migrationsbrücke).
|
||||
|
||||
| Methode und Pfad | Zweck |
|
||||
| --- | --- |
|
||||
| `POST /auth/setup`, `/auth/login`, `/auth/logout` | Ersten Admin, Login, Logout |
|
||||
| `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens |
|
||||
| `GET /version` | Version |
|
||||
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag |
|
||||
| `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`, `PATCH /druck-queue/<id>` | Warteschlange, Abbrechen (`status: cancelled`), Ablauf fortsetzen (`resume`) |
|
||||
| `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 |
|
||||
|
||||
MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN` (siehe `docs/internal/MCP.md`).
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Login Pflicht (Setup legt den ersten Admin an). Rollen: Admin, Operator.
|
||||
- Druckstarts brauchen `confirm: true`. Die Oberfläche fragt per Zwei-Klick.
|
||||
- Keine freie G-code-Konsole.
|
||||
- Kein `FIRMWARE_RESTART`, GoKlipper hängt sich dabei auf. Fehler werden über „Fehler löschen“ geräumt.
|
||||
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
|
||||
- Für Internet: Reverse Proxy mit TLS; nicht den Entwickler-Server öffentlich freigeben.
|
||||
|
||||
## Versionen und Changelog
|
||||
|
||||
|
||||
@@ -5,8 +5,16 @@
|
||||
"watch_dir": "",
|
||||
"database_url": "postgresql://[email protected]:5432/grotti",
|
||||
"token": "",
|
||||
"secret_key": "",
|
||||
"fqdn": "",
|
||||
"auto_queue": true,
|
||||
"oauth": {
|
||||
"enabled": false,
|
||||
"issuer": "",
|
||||
"client_id": "",
|
||||
"client_secret": "",
|
||||
"redirect_uri": ""
|
||||
},
|
||||
"energy": {
|
||||
"watts": 120,
|
||||
"euro_per_kwh": 0.35,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# ACE und Spulen
|
||||
|
||||
Stand **15.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
|
||||
|
||||

|
||||
|
||||
## Spule einlegen
|
||||
|
||||
1. Unter **Material → Spulen** die Rolle wählen oder anlegen.
|
||||
2. Ort = Drucker und **ACE-Platz** (1–8) oder externe Spule.
|
||||
3. Materialtyp und Farbe sollten zur Slice-Datei passen.
|
||||
|
||||
Es gibt **kein freies Spulen-Dropdown** beim Start: Grotti nimmt die Rolle(n), die am Ziel-Drucker liegen. Material-Mismatch oder zu wenig Rest → Warnung vor dem Start.
|
||||
|
||||
Etiketten mit QR: [Material und mehr](Anleitung-Material).
|
||||
|
||||
## ACE-Map beim Senden
|
||||
|
||||
Unter **Dateien → Optionen → ACE-Zuordnung**:
|
||||
|
||||
- Jede Farbe aus der Datei (`paint_index`) einem **ACE-Platz** zuordnen
|
||||
- Ohne passende Map schreibt der Drucker oft **„cannot parse the file“** und startet nicht
|
||||
- Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit
|
||||
|
||||
Externe Spule / keine ACE-Slots: keine `.acm` nötig.
|
||||
|
||||
## Typische Fehler
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| „cannot parse the file“ | ACE-Zuordnung vollständig? Farben richtig? |
|
||||
| Material-Warnung | Eingelegte Sorte ≠ Datei — Spule wechseln oder Slice prüfen |
|
||||
| Rest zu gering | Andere Spule oder leichteres Teil |
|
||||
| ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten |
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Dateien senden](Anleitung-Dateien)
|
||||
- [Material](Anleitung-Material)
|
||||
@@ -0,0 +1,49 @@
|
||||
# Dateien senden
|
||||
|
||||
Stand **15.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
|
||||
|
||||

|
||||
|
||||
## Bibliothek
|
||||
|
||||
- Suche, Filter nach Maschine und Projekt
|
||||
- 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
|
||||
|
||||
Ohne `watch_dir` geht nur manueller Upload — das reicht für den Alltag.
|
||||
|
||||
## Senden: drei Schritte
|
||||
|
||||
1. **Datei** wählen (Klick in der Liste).
|
||||
2. **Drucker** wählen — Kacheln zeigen Live-Zustand und eingelegte Rolle. Passt die Maschine nicht, ist die Kachel blass.
|
||||
3. **Wann:**
|
||||
- **Jetzt** — sofort senden und starten (Zwei-Klick)
|
||||
- **Wenn frei** — in die Warteschlange für diesen Drucker
|
||||
- **Nächster freier** — erster passender freier Drucker
|
||||
|
||||
Auswahl merkt sich der Browser (`localStorage`).
|
||||
|
||||
### Optionen
|
||||
|
||||
Unter Optionen u. a.:
|
||||
|
||||
- **Bettnetz / Auto-Level** beim MQTT-Start (Anycubic)
|
||||
- Maschinen-Override, Projekt zuweisen
|
||||
- **ACE-Zuordnung** — siehe [ACE und Spulen](Anleitung-ACE)
|
||||
|
||||
## Warum es dauert
|
||||
|
||||
Große Dateien brauchen oft **eine Minute**. In der Sidebar unter **Senden** siehst du den MB-Fortschritt. Danach startet der Druck.
|
||||
|
||||
## Nach dem Start
|
||||
|
||||
- Fortschritt auf der [Wand](Anleitung-Wand) und in der Sidebar **Läuft jetzt**
|
||||
- Bei „Wenn frei“: Eintrag unter [Warteschlange](Anleitung-Warteschlange)
|
||||
- Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map)
|
||||
|
||||
## Weiter
|
||||
|
||||
- [ACE und Spulen](Anleitung-ACE)
|
||||
- [Warteschlange](Anleitung-Warteschlange)
|
||||
@@ -0,0 +1,66 @@
|
||||
# Einrichtung
|
||||
|
||||
Stand **15.10.2026**. Grotti auf dem Haus-PC installieren und den Browser öffnen. Die Drucker brauchen vorher Moonraker (Rinkhals auf Kobra 3 und S1).
|
||||
|
||||
## Was du brauchst
|
||||
|
||||
- Python 3.11
|
||||
- PostgreSQL (leere Datenbank `grotti` reicht; Schema legt Grotti an)
|
||||
- Drucker im selben Netz, Moonraker auf Port 80
|
||||
- Dieses Repo und eine `config.json`
|
||||
|
||||
## Einmalig einrichten
|
||||
|
||||
Im Projektordner:
|
||||
|
||||
```powershell
|
||||
cd D:\Projekte\Grotti
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\pip install -r requirements.txt
|
||||
copy config.example.json config.json
|
||||
```
|
||||
|
||||
`config.json` anpassen. Die wichtigsten Schlüssel:
|
||||
|
||||
| Schlüssel | Bedeutung |
|
||||
| --- | --- |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `energy`, `telegram`, `alerts` | Kosten im Protokoll und Telegram — später auch unter Einstellungen. |
|
||||
| `secret_key` | Für Login-Sessions. Beim ersten Start wird ein Wert erzeugt, wenn leer. |
|
||||
|
||||
Drucker beim ersten Start über `drucker.json` (Seed). Danach pflegt man sie unter [Einstellungen](Anleitung-Einstellungen).
|
||||
|
||||
```json
|
||||
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
|
||||
```
|
||||
|
||||
Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen.
|
||||
|
||||
## Starten
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe server.py
|
||||
```
|
||||
|
||||
Dann <http://127.0.0.1:8770/> öffnen. Beim ersten Start legst du den Admin-Benutzer an. Nach Code-Änderungen an Python oft neu starten.
|
||||
|
||||

|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Druckstart nur mit Bestätigung (Zwei-Klick in der UI bzw. `confirm: true` in der API).
|
||||
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
|
||||
- Benutzer und API-Tokens unter Einstellungen. Für Zugriff aus dem Internet: Reverse Proxy mit TLS, nicht den Entwickler-Server öffentlich freigeben.
|
||||
- `config.json` und `data/` bleiben lokal, nicht im Git.
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Oberfläche](Anleitung-Oberflaeche) — Navigation und Sidebar
|
||||
- [Dateien senden](Anleitung-Dateien) — erster Druck
|
||||
- Ausführlich im `README.md` im Projektordner
|
||||
@@ -0,0 +1,44 @@
|
||||
# Einstellungen
|
||||
|
||||
Stand **15.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie.
|
||||
|
||||

|
||||
|
||||
## Dieser Browser
|
||||
|
||||
- **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche).
|
||||
|
||||
## Automatik
|
||||
|
||||
Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen „wenn frei“-Starts. Laufende Abläufe und manuelle „Jetzt“-Starts bleiben möglich.
|
||||
|
||||
## Drucker
|
||||
|
||||
Tabelle: Name, Host, Familie, **gesperrt**.
|
||||
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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_…`).
|
||||
|
||||
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.
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Einrichtung](Anleitung-Einrichtung) (Config-Datei)
|
||||
- [Störungen](Anleitung-Stoerungen)
|
||||
@@ -0,0 +1,42 @@
|
||||
# Material und mehr
|
||||
|
||||
Stand **15.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Kameras, Aktivität und Ideen.
|
||||
|
||||

|
||||
|
||||
## Material
|
||||
|
||||
Zwei Reiter: **Spulen** und **Sorten**.
|
||||
|
||||
**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).
|
||||
|
||||
**Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
||||
|
||||
### Etikett und QR
|
||||
|
||||
Pro Spule ein Etikett mit Chargennummer und QR. Der Code öffnet Material mit der Spule vorausgewählt. Dafür in den Einstellungen ggf. **FQDN** setzen (öffentliche URL), sonst gilt der Host aus dem Request.
|
||||
|
||||
## Projekte
|
||||
|
||||
Unter **Projekte** Aufträge mit Notiz und Kalkulation. Dateien lassen sich einem Projekt zuweisen (Bibliothek / Senden).
|
||||
|
||||
## Protokoll
|
||||
|
||||
Abgeschlossene Läufe: Dauer, Gramm, Kosten, Urteil. **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
|
||||
|
||||
## Kameras
|
||||
|
||||
`/kameras` — alle Snapshots nebeneinander mit Live-Zustand. Praktisch auf dem Regal-Tablet.
|
||||
|
||||
## Aktivität
|
||||
|
||||
Zeitstrahl: Starts, Schritte, Fertig, Fehler, Abbrüche, offline. Filter nach Drucker, Art, Zeitraum. Ungelesene Alerts sind markiert; in der Sidebar kannst du sie als gelesen setzen.
|
||||
|
||||
## Ideen
|
||||
|
||||
`/ideen` — Notizen und Ideen für später. Kein Druckfluss, nur Merkliste.
|
||||
|
||||
## Weiter
|
||||
|
||||
- [ACE und Spulen](Anleitung-ACE)
|
||||
- [Einstellungen](Anleitung-Einstellungen)
|
||||
@@ -0,0 +1,37 @@
|
||||
# Oberfläche
|
||||
|
||||
Stand **15.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop und Regal.
|
||||
|
||||
## Navigation
|
||||
|
||||
Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Aktivität**, **Warteschlange**, **Protokoll**, **Material**, **Projekte**. Darunter **Einstellungen**, **Ideen**, **Doku**. Fuß: Version (öffnet die Doku).
|
||||
|
||||
Unter ca. 760 px: untere Leiste mit den wichtigsten Seiten. Das Seitenmenü lässt sich zur Icon-Rail einklappen.
|
||||
|
||||

|
||||
|
||||
## Live-Leiste
|
||||
|
||||
Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Browser):
|
||||
|
||||
| Box | 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. |
|
||||
|
||||
## Desktop und Regal
|
||||
|
||||
Unter **Einstellungen → Dieser Browser**:
|
||||
|
||||
- **Desktop** — normale Dichte
|
||||
- **Regal (Touch)** — größere Bedienelemente, kompaktere Nav, Sidebar oft eingeklappt
|
||||
|
||||
Shortcut in der URL: `?shelf=1` bzw. `?shelf=0`. Nur dieser Browser (`localStorage`).
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Druckerwand](Anleitung-Wand)
|
||||
- [Dateien senden](Anleitung-Dateien)
|
||||
@@ -0,0 +1,56 @@
|
||||
# Störungen
|
||||
|
||||
Stand **15.10.2026**. Was tun, wenn etwas hängt. Kurzfassung auch auf der [Doku-Startseite](/doku#haengt).
|
||||
|
||||
## Auftrag startet nicht
|
||||
|
||||
| Anzeige | Maßnahme |
|
||||
| --- | --- |
|
||||
| **Platte belegt** | Teil runter, dann **Platte geräumt** (Karte oder Sidebar → Aufgaben). |
|
||||
| Drucker **gesperrt** | Unter Einstellungen entsperren. |
|
||||
| Automatik aus | Einstellungen → Automatik-Warteschlange an. |
|
||||
| Falsche Maschine | Andere Datei oder anderen Drucker wählen. |
|
||||
|
||||
## Fehlerzustand auf dem Drucker
|
||||
|
||||
Auf der Karte **Fehler löschen** (Zwei-Klick). Grotti räumt den hängenden Fehler und schaltet die Heizung aus. Ein Neustart der Firmware über Grotti ist absichtlich nicht möglich — bei Bedarf Display-Neustart oder Strom aus/an.
|
||||
|
||||
Hängende Fehler vor einem Start räumt Grotti oft selbst, wenn kein Druck aktiv ist.
|
||||
|
||||
## Ablauf gestoppt
|
||||
|
||||
1. Grund in Sidebar / Karte / Aktivität lesen (Schieben, Tasten, Timeout, …).
|
||||
2. Mechanisch klären (Kiste, Teil, Platte).
|
||||
3. **Weiter** ab dem fehlgeschlagenen Schritt — oder Verwerfen und neu einreihen.
|
||||
|
||||
## Start scheitert nach Abbruch
|
||||
|
||||
Manchmal bleibt nach einem Abbruch ein alter Auftrag auf dem Drucker hängen. Der nächste Start scheitert dann oft mit einer Fehlermeldung (z. B. Code **10101**).
|
||||
|
||||
Was tun: Abbruch über Grotti (nicht nur am Display), auf der Wand den Task-Hinweis prüfen, erneut starten. Bleibt es hängen: Strom aus und wieder an.
|
||||
|
||||
## ACE / „cannot parse“
|
||||
|
||||
ACE-Zuordnung unter Dateien → Optionen prüfen. Spulen am richtigen Platz. Siehe [ACE und Spulen](Anleitung-ACE).
|
||||
|
||||
## Offline
|
||||
|
||||
Strom, WLAN und Erreichbarkeit des Druckers prüfen. Grotti meldet „wieder erreichbar“ unter Aktivität und ggf. Telegram.
|
||||
|
||||
## E-Stop
|
||||
|
||||
Im Cockpit (nur bei aktivem Druck/Heizen). Danach oft **Strom aus und wieder an**. Firmware-Neustart über Grotti ist absichtlich gesperrt.
|
||||
|
||||
## Upload hängt bei großen Dateien
|
||||
|
||||
Oft noch Verarbeitung auf dem Drucker. In der Sidebar unter **Senden** den Fortschritt beobachten. Nach einem Grotti-Update den Dienst einmal neu starten.
|
||||
|
||||
## Wo nachschauen
|
||||
|
||||
- Sidebar: Alerts, Läuft, Wartet, Senden
|
||||
- **Aktivität** — Zeitstrahl mit Filtern
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Druckerwand](Anleitung-Wand)
|
||||
- [Warteschlange](Anleitung-Warteschlange)
|
||||
@@ -0,0 +1,54 @@
|
||||
# Druckerwand
|
||||
|
||||
Stand **15.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
|
||||
|
||||

|
||||
|
||||
## Karte
|
||||
|
||||
Pro Drucker siehst du:
|
||||
|
||||
- Zustand (Chip), Fertig-Uhrzeit, Fortschrittsbalken
|
||||
- Restzeit, Schicht, Laufzeit
|
||||
- Temperaturen Düse/Bett (mit Ziel)
|
||||
- Dateiname, bei ACE oft Farb-Swatches
|
||||
- Bei Ablauf: Schrittleiste und Abbrechen
|
||||
|
||||
Aktionen auf der Karte brauchen oft **zwei Klicks** (erster Klick „scharf“, zweiter bestätigt):
|
||||
|
||||
| Aktion | Wann |
|
||||
| --- | --- |
|
||||
| **Pause** / **Weiter** | Während des Drucks |
|
||||
| **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 |
|
||||
| **Weiter** (Ablauf) | Gestoppter Ablauf — ab dem fehlgeschlagenen Schritt, wenn die Kiste steht |
|
||||
|
||||
Während eines Abräum-Ablaufs blendet Grotti „Platte geräumt“ aus — der Ablauf räumt selbst.
|
||||
|
||||
## Cockpit
|
||||
|
||||
URL mit Hash: `/#<host>` (z. B. nach Klick auf die Karte).
|
||||
|
||||

|
||||
|
||||
Inhalt:
|
||||
|
||||
- **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg
|
||||
- Status, Temps inkl. Bauraum wo verfügbar, Lüfter
|
||||
- **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole
|
||||
- **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an
|
||||
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
|
||||
- **Bettnetz**-Heatmap, wenn vorhanden
|
||||
|
||||
## Tipps
|
||||
|
||||
- Gesperrte Drucker (Einstellungen) starten keine neuen „wenn frei“-Jobs.
|
||||
- Live-Daten kommen mit wenigen Sekunden Verzögerung.
|
||||
- Ein Task-Hinweis auf der Karte zeigt, wenn auf dem Drucker noch ein alter Auftrag hängt — siehe [Störungen](Anleitung-Stoerungen).
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Dateien senden](Anleitung-Dateien)
|
||||
- [Warteschlange und Ablauf](Anleitung-Warteschlange)
|
||||
- [Störungen](Anleitung-Stoerungen)
|
||||
@@ -0,0 +1,52 @@
|
||||
# Warteschlange und Ablauf
|
||||
|
||||
Stand **15.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
|
||||
|
||||

|
||||
|
||||
## Seite Warteschlange
|
||||
|
||||
Filter: **Läuft** / **Wartet** / **Fehler** / **Historie** (und „Alle aktiv“).
|
||||
|
||||
Pro Auftrag:
|
||||
|
||||
- Priorität hoch/runter
|
||||
- **Abbrechen**
|
||||
- Bei gestopptem Ablauf: **Fortsetzen** (ab dem fehlgeschlagenen Schritt)
|
||||
|
||||
Unter **Läuft** siehst du auch Direktstarts ohne Queue-Zeile (Link zur Wand).
|
||||
|
||||
Dieselbe Information steht kompakt in der Sidebar (**Läuft jetzt** / **Wartet**).
|
||||
|
||||
## 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.
|
||||
|
||||
## Ablauf (nur Kobra 3)
|
||||
|
||||
Unter **Dateien → Ablauf** eine Kette bauen und einreihen:
|
||||
|
||||
```text
|
||||
Drucken → Abkühlen → Schieben → Tasten → (Wiederholung)
|
||||
```
|
||||
|
||||
| Schritt | Bedeutung |
|
||||
| --- | --- |
|
||||
| **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. |
|
||||
|
||||
Aktueller Schritt: auf der Druckerkarte und in der Sidebar. Abbrechen an beiden Stellen. Jeder Schritt landet unter **Aktivität**.
|
||||
|
||||
**Schieben testen** und „Bett ist leer“: im Ablauf-Reiter, bevor du unbeaufsichtigt läuft.
|
||||
|
||||
## Gestoppter Ablauf
|
||||
|
||||
1. Ursache beheben (Kiste voll, Teil hängt, Tasten zu hoch, …).
|
||||
2. Auf der Karte oder in der Sidebar **Weiter** — nicht nur neu einreihen.
|
||||
3. Oder verwerfen / abbrechen und später neu starten.
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Druckerwand](Anleitung-Wand)
|
||||
- [Störungen](Anleitung-Stoerungen)
|
||||
@@ -8,7 +8,7 @@ Jede Spule in Grotti bekommt automatisch eine `chargen_nr` der Form `S-YYYYMMDD-
|
||||
|
||||
Scannen: auf Material den Code tippen oder per USB-Handscanner (Tastatur + Enter). Lookup `GET /api/v1/spulen/by-code/<code>`. QR auf dem Etikett öffnet `/material?spool=<id>`.
|
||||
|
||||
Etikett: `/etikett/<id>`, Druck über den Browser. QR per qrcode.js (CDN). Basis-URL für den QR kommt aus Einstellungen → **FQDN** (`config.json` Key `fqdn`); leer = Host der aktuellen Anfrage. Beispiel: `https://grotti.example.de/material?spool=12`.
|
||||
Etikett: `/etikett/<id>`, Druck über den Browser. QR per lokalem `qrcode.min.js` (`web/static/`). Basis-URL für den QR kommt aus Einstellungen → **FQDN** (`config.json` Key `fqdn`); leer = Host der aktuellen Anfrage. Beispiel: `https://grotti.example.de/material?spool=12`.
|
||||
|
||||
## 3D-Pfad
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# MCP-Server
|
||||
|
||||
Stand **15.10.2026**. Agenten steuern Grotti nur über die HTTP-API mit einem API-Token.
|
||||
|
||||
## Start
|
||||
|
||||
```powershell
|
||||
$env:GROTTI_URL = "http://127.0.0.1:8770"
|
||||
$env:GROTTI_TOKEN = "grt_…" # unter Einstellungen → API-Tokens
|
||||
$env:GROTTI_MCP_READ_ONLY = "1" # optional: nur lesen
|
||||
.\.venv\Scripts\pip install mcp
|
||||
.\.venv\Scripts\python.exe -m grotti.mcp_server
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Schreibend | Confirm |
|
||||
| --- | --- | --- |
|
||||
| `list_printers`, `get_printer`, `list_running`, `list_queue`, `list_events`, `list_files`, `list_spools`, `get_version` | nein | — |
|
||||
| `pause_printer`, `resume_printer` | ja | — |
|
||||
| `cancel_printer`, `clear_bed`, `enqueue_*` | ja | `confirm=true` |
|
||||
| `steer_temp`, `steer_speed`, `steer_fan` | ja | — |
|
||||
|
||||
Nicht exponiert: freie G-code, Firmware-Restart, E-Stop, User/Token/Settings.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Token mit Rolle Operator reicht für den Alltag; Admin-Token vermeiden.
|
||||
- Read-only-Flag für vorsichtige Agents.
|
||||
- Server ideal nur lokal; Grotti selbst hinter Login/TLS.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Interne Technik-Notizen
|
||||
|
||||
Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App.
|
||||
|
||||
Nutzer-Handbuch: `docs/Anleitung-*.md`.
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Benutzer, Sessions, API-Tokens und CSRF."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from grotti.errors import GrottiError
|
||||
|
||||
ROLES = ("admin", "operator")
|
||||
ROLE_RANK = {"operator": 1, "admin": 2}
|
||||
TOKEN_PREFIX = "grt_"
|
||||
LOGIN_WINDOW_S = 300
|
||||
LOGIN_MAX_ATTEMPTS = 12
|
||||
|
||||
_login_hits: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return generate_password_hash(password, method="scrypt")
|
||||
|
||||
|
||||
def verify_password(password_hash: str, password: str) -> bool:
|
||||
if not password_hash or not password:
|
||||
return False
|
||||
return check_password_hash(password_hash, password)
|
||||
|
||||
|
||||
def validate_username(username: str) -> str:
|
||||
name = (username or "").strip()
|
||||
if len(name) < 2 or len(name) > 64:
|
||||
raise GrottiError("Benutzername: 2–64 Zeichen.", "user")
|
||||
if not all(ch.isalnum() or ch in "._-" for ch in name):
|
||||
raise GrottiError("Benutzername: nur Buchstaben, Ziffern, . _ -", "user")
|
||||
return name
|
||||
|
||||
|
||||
def validate_password(password: str) -> str:
|
||||
if not password or len(password) < 8:
|
||||
raise GrottiError("Passwort mindestens 8 Zeichen.", "password")
|
||||
if len(password) > 200:
|
||||
raise GrottiError("Passwort zu lang.", "password")
|
||||
return password
|
||||
|
||||
|
||||
def normalize_role(role: str, *, default: str = "operator") -> str:
|
||||
role = (role or default).strip().lower()
|
||||
if role not in ROLES:
|
||||
raise GrottiError("Rolle muss admin oder operator sein.", "role")
|
||||
return role
|
||||
|
||||
|
||||
def role_at_least(have: str, need: str) -> bool:
|
||||
return ROLE_RANK.get(have or "", 0) >= ROLE_RANK.get(need or "", 99)
|
||||
|
||||
|
||||
def clamp_token_role(user_role: str, token_role: str | None) -> str:
|
||||
wanted = normalize_role(token_role or user_role, default=user_role)
|
||||
if not role_at_least(user_role, wanted):
|
||||
raise GrottiError("Token-Rolle darf nicht höher als die des Benutzers sein.", "role")
|
||||
return wanted
|
||||
|
||||
|
||||
def new_api_token() -> str:
|
||||
return TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_token(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def token_prefix(raw: str) -> str:
|
||||
return (raw or "")[:12]
|
||||
|
||||
|
||||
def new_csrf() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
def login_rate_ok(key: str) -> bool:
|
||||
now = time.time()
|
||||
hits = _login_hits[key]
|
||||
_login_hits[key] = [t for t in hits if now - t < LOGIN_WINDOW_S]
|
||||
return len(_login_hits[key]) < LOGIN_MAX_ATTEMPTS
|
||||
|
||||
|
||||
def login_rate_hit(key: str) -> None:
|
||||
_login_hits[key].append(time.time())
|
||||
|
||||
|
||||
def public_user(row) -> dict:
|
||||
if not row:
|
||||
return {}
|
||||
created = row.get("created_at")
|
||||
last = row.get("last_login")
|
||||
return {
|
||||
"id": row.get("id"),
|
||||
"username": row.get("username"),
|
||||
"role": row.get("role"),
|
||||
"active": bool(row["active"]) if "active" in row else True,
|
||||
"created_at": created.isoformat() if created and hasattr(created, "isoformat") else created,
|
||||
"last_login": last.isoformat() if last and hasattr(last, "isoformat") else last,
|
||||
}
|
||||
|
||||
|
||||
def public_token(row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"user_id": row["user_id"],
|
||||
"name": row["name"],
|
||||
"prefix": row["token_prefix"],
|
||||
"role": row["role"],
|
||||
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
||||
"last_used": row["last_used"].isoformat() if row.get("last_used") else None,
|
||||
"expires_at": row["expires_at"].isoformat() if row.get("expires_at") else None,
|
||||
"revoked": bool(row.get("revoked_at")),
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -59,6 +60,27 @@ def public_base(cfg, request_root=None):
|
||||
return str(request_root or "").rstrip("/")
|
||||
|
||||
|
||||
def _oauth(raw):
|
||||
oauth = raw.get("oauth") or {}
|
||||
return {
|
||||
"enabled": bool(oauth.get("enabled")),
|
||||
"issuer": str(oauth.get("issuer") or ""),
|
||||
"client_id": str(oauth.get("client_id") or ""),
|
||||
"client_secret": str(oauth.get("client_secret") or ""),
|
||||
"redirect_uri": str(oauth.get("redirect_uri") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _ensure_secret_key(path, raw):
|
||||
key = str(raw.get("secret_key") or "").strip()
|
||||
if key:
|
||||
return key
|
||||
key = secrets.token_hex(32)
|
||||
raw["secret_key"] = key
|
||||
path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return key
|
||||
|
||||
|
||||
def load():
|
||||
path = CONFIG_PATH
|
||||
if not path.exists():
|
||||
@@ -66,8 +88,14 @@ def load():
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
host = (raw.get("host") or "127.0.0.1").strip()
|
||||
token = raw.get("token") or ""
|
||||
if host not in LOOPBACK and not token:
|
||||
raise SystemExit("Ohne Token startet der Dienst nicht, wenn er nicht nur lokal lauscht.")
|
||||
secret_key = _ensure_secret_key(path, raw)
|
||||
# Nicht-Loopback ohne Shared-Token ist ok — Login/API-Tokens schützen die App.
|
||||
# Neu laden falls secret_key geschrieben wurde
|
||||
if raw.get("secret_key") == secret_key:
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
data_dir = Path(raw.get("data_dir") or "data")
|
||||
if not data_dir.is_absolute():
|
||||
data_dir = ROOT / data_dir
|
||||
@@ -82,6 +110,8 @@ def load():
|
||||
"watch_dir": watch_dir,
|
||||
"database_url": raw.get("database_url") or "postgresql://[email protected]:5432/grotti",
|
||||
"token": token,
|
||||
"secret_key": secret_key,
|
||||
"oauth": _oauth(raw),
|
||||
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
|
||||
"printers_file": ROOT / "drucker.json",
|
||||
"energy": _energy(raw),
|
||||
@@ -102,10 +132,12 @@ def mask_token(token):
|
||||
|
||||
def settings_public(cfg):
|
||||
tg = cfg.get("telegram") or {}
|
||||
oauth = cfg.get("oauth") or {}
|
||||
return {
|
||||
"watch_dir": str(cfg.get("watch_dir") or ""),
|
||||
"auto_queue": bool(cfg.get("auto_queue", True)),
|
||||
"fqdn": str(cfg.get("fqdn") or ""),
|
||||
"legacy_token_set": bool(cfg.get("token")),
|
||||
"energy": dict(cfg.get("energy") or {}),
|
||||
"telegram": {
|
||||
"enabled": bool(tg.get("enabled")),
|
||||
@@ -114,6 +146,12 @@ def settings_public(cfg):
|
||||
"chat_id": str(tg.get("chat_id") or ""),
|
||||
},
|
||||
"alerts": dict(cfg.get("alerts") or {}),
|
||||
"oauth": {
|
||||
"enabled": bool(oauth.get("enabled")),
|
||||
"issuer": str(oauth.get("issuer") or ""),
|
||||
"client_id": str(oauth.get("client_id") or ""),
|
||||
"redirect_uri": str(oauth.get("redirect_uri") or ""),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""MCP-Server für Grotti — spricht nur die HTTP-API mit einem API-Token.
|
||||
|
||||
Umgebung:
|
||||
GROTTI_URL Basis-URL (Standard http://127.0.0.1:8770)
|
||||
GROTTI_TOKEN Bearer-Token (grt_…)
|
||||
GROTTI_MCP_READ_ONLY=1 nur Lese-Tools
|
||||
|
||||
Start:
|
||||
python -m grotti.mcp_server
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
READ_ONLY = os.environ.get("GROTTI_MCP_READ_ONLY", "").strip() in ("1", "true", "yes")
|
||||
BASE = (os.environ.get("GROTTI_URL") or "http://127.0.0.1:8770").rstrip("/")
|
||||
TOKEN = (os.environ.get("GROTTI_TOKEN") or "").strip()
|
||||
|
||||
|
||||
def _api(method: str, path: str, body: dict | None = None):
|
||||
if not TOKEN:
|
||||
raise RuntimeError("GROTTI_TOKEN fehlt.")
|
||||
url = BASE + "/api/v1" + path
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": "Bearer " + TOKEN,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
payload = json.loads(detail)
|
||||
message = payload.get("error") or detail
|
||||
except json.JSONDecodeError:
|
||||
message = detail or str(exc)
|
||||
raise RuntimeError(message) from exc
|
||||
|
||||
|
||||
def _write_guard():
|
||||
if READ_ONLY:
|
||||
raise RuntimeError("MCP läuft im Read-only-Modus (GROTTI_MCP_READ_ONLY).")
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
except ImportError:
|
||||
print(
|
||||
"Paket „mcp“ fehlt. Installieren: pip install mcp",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
server = FastMCP(
|
||||
"grotti",
|
||||
instructions=(
|
||||
"Grotti steuert die 3D-Drucker-Farm. Destruktive Aktionen brauchen "
|
||||
"confirm=true. Kein freier G-code, kein Firmware-Restart."
|
||||
),
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def list_printers(live: bool = True) -> str:
|
||||
"""Druckerliste mit optionalem Live-Zustand."""
|
||||
q = "?live=1" if live else "?live=0"
|
||||
return json.dumps(_api("GET", "/drucker" + q), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def get_printer(host: str) -> str:
|
||||
"""Einen Drucker anhand Host/IP aus der Live-Liste."""
|
||||
rows = _api("GET", "/drucker?live=1") or []
|
||||
for row in rows:
|
||||
if row.get("host") == host:
|
||||
return json.dumps(row, ensure_ascii=False, indent=2)
|
||||
raise RuntimeError("Drucker nicht gefunden: %s" % host)
|
||||
|
||||
@server.tool()
|
||||
def list_running() -> str:
|
||||
"""Laufende Drucke und Abläufe."""
|
||||
return json.dumps(_api("GET", "/laeuft"), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def list_queue(limit: int = 40) -> str:
|
||||
"""Druckwarteschlange."""
|
||||
return json.dumps(
|
||||
_api("GET", "/druck-queue?limit=%d" % max(1, min(limit, 200))),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def list_events(limit: int = 30) -> str:
|
||||
"""Letzte Ereignisse / Aktivität."""
|
||||
return json.dumps(
|
||||
_api("GET", "/ereignisse?limit=%d" % max(1, min(limit, 200))),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def list_files(limit: int = 50) -> str:
|
||||
"""Dateien in der Bibliothek."""
|
||||
rows = _api("GET", "/dateien") or []
|
||||
return json.dumps(rows[: max(1, min(limit, 200))], ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def list_spools() -> str:
|
||||
"""Spulen / Materialbestand."""
|
||||
return json.dumps(_api("GET", "/spulen"), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def get_version() -> str:
|
||||
"""Grotti-Version."""
|
||||
return json.dumps(_api("GET", "/version"), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def pause_printer(host: str) -> str:
|
||||
"""Druck pausieren."""
|
||||
_write_guard()
|
||||
return json.dumps(
|
||||
_api("POST", "/drucker/%s/pause" % urllib.parse.quote(host)),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def resume_printer(host: str) -> str:
|
||||
"""Druck fortsetzen."""
|
||||
_write_guard()
|
||||
return json.dumps(
|
||||
_api("POST", "/drucker/%s/fortsetzen" % urllib.parse.quote(host)),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def cancel_printer(host: str, confirm: bool = False) -> str:
|
||||
"""Druck abbrechen. confirm muss true sein."""
|
||||
_write_guard()
|
||||
if not confirm:
|
||||
raise RuntimeError("Abbruch braucht confirm=true.")
|
||||
return json.dumps(
|
||||
_api("POST", "/drucker/%s/abbrechen" % urllib.parse.quote(host)),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def clear_bed(host: str, confirm: bool = False) -> str:
|
||||
"""Platte als geräumt quittieren (kein physisches Schieben)."""
|
||||
_write_guard()
|
||||
if not confirm:
|
||||
raise RuntimeError("Quittierung braucht confirm=true.")
|
||||
return json.dumps(
|
||||
_api(
|
||||
"POST",
|
||||
"/drucker/%s/abraeumen" % urllib.parse.quote(host),
|
||||
{"confirm": True},
|
||||
),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def enqueue_when_free(
|
||||
file_id: int,
|
||||
host: str = "",
|
||||
confirm: bool = False,
|
||||
bed_leveling: bool = False,
|
||||
) -> str:
|
||||
"""Datei in die Warteschlange („wenn frei“). confirm muss true sein."""
|
||||
_write_guard()
|
||||
if not confirm:
|
||||
raise RuntimeError("Einreihen braucht confirm=true.")
|
||||
payload = {
|
||||
"file_id": file_id,
|
||||
"host": host,
|
||||
"confirm": True,
|
||||
"bed_leveling": bool(bed_leveling),
|
||||
}
|
||||
return json.dumps(_api("POST", "/druck-queue", payload), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def enqueue_print(
|
||||
file_id: int,
|
||||
host: str,
|
||||
confirm: bool = False,
|
||||
bed_leveling: bool = False,
|
||||
) -> str:
|
||||
"""Sofort senden und starten. confirm muss true sein."""
|
||||
_write_guard()
|
||||
if not confirm:
|
||||
raise RuntimeError("Start braucht confirm=true.")
|
||||
if not host:
|
||||
raise RuntimeError("host fehlt.")
|
||||
payload = {
|
||||
"file_id": file_id,
|
||||
"host": host,
|
||||
"confirm": True,
|
||||
"bed_leveling": bool(bed_leveling),
|
||||
}
|
||||
return json.dumps(_api("POST", "/senden", payload), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def steer_temp(host: str, nozzle: float | None = None, bed: float | None = None) -> str:
|
||||
"""Zieltemperaturen setzen (Whitelist)."""
|
||||
_write_guard()
|
||||
out = []
|
||||
if nozzle is not None:
|
||||
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||
"action": "nozzle", "value": nozzle,
|
||||
}))
|
||||
if bed is not None:
|
||||
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||
"action": "bed", "value": bed,
|
||||
}))
|
||||
if not out:
|
||||
raise RuntimeError("nozzle und/oder bed angeben.")
|
||||
return json.dumps(out, ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def steer_speed(host: str, speed: float) -> str:
|
||||
"""Geschwindigkeitsfaktor in Prozent."""
|
||||
_write_guard()
|
||||
return json.dumps(
|
||||
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||
"action": "speed", "value": speed,
|
||||
}),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def steer_fan(host: str, fan: float) -> str:
|
||||
"""Teilekühlung 0–100."""
|
||||
_write_guard()
|
||||
return json.dumps(
|
||||
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
|
||||
"action": "fan", "value": fan,
|
||||
}),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@server.resource("grotti://version")
|
||||
def resource_version() -> str:
|
||||
return json.dumps(_api("GET", "/version"), ensure_ascii=False, indent=2)
|
||||
|
||||
server.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -907,7 +907,7 @@ def query(host):
|
||||
"/api/v1/vorschau?host=%s&embedded=%s" % (quoted_host, urllib.parse.quote(raw_name))
|
||||
if raw_name and EMBEDDED.get((host, raw_name)) else ""
|
||||
),
|
||||
"camera": "/api/v1/kamera?host=%s" % quoted_host,
|
||||
**_camera_fields(host, quoted_host),
|
||||
**mesh_fields(host, shown, mesh_profile),
|
||||
"fan": fan_speed,
|
||||
"fan_rpm": fan_rpm,
|
||||
@@ -946,6 +946,8 @@ def snapshot(printers):
|
||||
"ok": False,
|
||||
"state": "offline",
|
||||
"message": str(error),
|
||||
"camera": "",
|
||||
"has_camera": False,
|
||||
"fan": None,
|
||||
"fan_rpm": None,
|
||||
"fans": {"part": None, "chamber": None, "aux": None},
|
||||
@@ -986,5 +988,21 @@ def camera_image(host):
|
||||
return CAMERA_CACHE[host][2]
|
||||
|
||||
|
||||
def _camera_fields(host, quoted_host):
|
||||
"""Kamera-URL nur, wenn nicht als tot bekannt (Cache-Miss = erst versuchen)."""
|
||||
cached = CAMERA_CACHE.get(host)
|
||||
if cached is not None and cached[2] is None:
|
||||
return {"camera": "", "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,
|
||||
}
|
||||
|
||||
|
||||
def embedded_bytes(host, name):
|
||||
return EMBEDDED.get((host, name))
|
||||
@@ -180,3 +180,38 @@ CREATE TABLE IF NOT EXISTS bed_status (
|
||||
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)
|
||||
);
|
||||
@@ -1280,3 +1280,130 @@ def set_bed_needs_clear(conn, host, needs, message=""):
|
||||
(host, needs, needs, message or ""),
|
||||
)
|
||||
|
||||
|
||||
def user_count(conn):
|
||||
return int(conn.execute("SELECT count(*) AS n FROM users").fetchone()["n"])
|
||||
|
||||
|
||||
def list_users(conn):
|
||||
return conn.execute(
|
||||
"SELECT id, username, role, active, created_at, last_login FROM users ORDER BY username"
|
||||
).fetchall()
|
||||
|
||||
|
||||
def get_user(conn, user_id):
|
||||
return conn.execute(
|
||||
"SELECT * FROM users WHERE id = %s",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def get_user_by_name(conn, username):
|
||||
return conn.execute(
|
||||
"SELECT * FROM users WHERE lower(username) = lower(%s)",
|
||||
(username,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def create_user(conn, username, password_hash, role="operator"):
|
||||
return conn.execute(
|
||||
"""
|
||||
INSERT INTO users (username, password_hash, role)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING id, username, role, active, created_at, last_login
|
||||
""",
|
||||
(username, password_hash, role),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def update_user(conn, user_id, *, password_hash=None, role=None, active=None):
|
||||
row = get_user(conn, user_id)
|
||||
if not row:
|
||||
return None
|
||||
new_hash = password_hash if password_hash is not None else row["password_hash"]
|
||||
new_role = role if role is not None else row["role"]
|
||||
new_active = row["active"] if active is None else bool(active)
|
||||
return conn.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET password_hash = %s, role = %s, active = %s
|
||||
WHERE id = %s
|
||||
RETURNING id, username, role, active, created_at, last_login
|
||||
""",
|
||||
(new_hash, new_role, new_active, user_id),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def delete_user(conn, user_id):
|
||||
conn.execute("DELETE FROM users WHERE id = %s", (user_id,))
|
||||
|
||||
|
||||
def touch_user_login(conn, user_id):
|
||||
conn.execute("UPDATE users SET last_login = now() WHERE id = %s", (user_id,))
|
||||
|
||||
|
||||
def create_api_token(conn, user_id, name, token_hash, token_prefix, role, expires_at=None):
|
||||
return conn.execute(
|
||||
"""
|
||||
INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, role, expires_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, user_id, name, token_prefix, role, created_at, last_used, expires_at, revoked_at
|
||||
""",
|
||||
(user_id, name or "", token_hash, token_prefix, role, expires_at),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def list_api_tokens(conn, user_id=None, include_revoked=False):
|
||||
sql = """
|
||||
SELECT t.id, t.user_id, t.name, t.token_prefix, t.role,
|
||||
t.created_at, t.last_used, t.expires_at, t.revoked_at,
|
||||
u.username
|
||||
FROM api_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE 1=1
|
||||
"""
|
||||
args = []
|
||||
if user_id is not None:
|
||||
sql += " AND t.user_id = %s"
|
||||
args.append(user_id)
|
||||
if not include_revoked:
|
||||
sql += " AND t.revoked_at IS NULL"
|
||||
sql += " ORDER BY t.created_at DESC"
|
||||
return conn.execute(sql, args).fetchall()
|
||||
|
||||
|
||||
def get_api_token_by_hash(conn, token_hash):
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT t.*, u.username, u.active AS user_active, u.role AS user_role
|
||||
FROM api_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = %s
|
||||
""",
|
||||
(token_hash,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def touch_api_token(conn, token_id):
|
||||
conn.execute("UPDATE api_tokens SET last_used = now() WHERE id = %s", (token_id,))
|
||||
|
||||
|
||||
def revoke_api_token(conn, token_id, user_id=None):
|
||||
if user_id is None:
|
||||
return conn.execute(
|
||||
"""
|
||||
UPDATE api_tokens SET revoked_at = now()
|
||||
WHERE id = %s AND revoked_at IS NULL
|
||||
RETURNING id, user_id, name, token_prefix, role, created_at, last_used, expires_at, revoked_at
|
||||
""",
|
||||
(token_id,),
|
||||
).fetchone()
|
||||
return conn.execute(
|
||||
"""
|
||||
UPDATE api_tokens SET revoked_at = now()
|
||||
WHERE id = %s AND user_id = %s AND revoked_at IS NULL
|
||||
RETURNING id, user_id, name, token_prefix, role, created_at, last_used, expires_at, revoked_at
|
||||
""",
|
||||
(token_id, user_id),
|
||||
).fetchone()
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||
|
||||
__version__ = "2.1.0"
|
||||
__version__ = "2.2.0"
|
||||
@@ -3,3 +3,4 @@ psycopg[binary]>=3.2
|
||||
paho-mqtt>=2.1
|
||||
cryptography>=42
|
||||
markdown>=3.6
|
||||
mcp>=1.2
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Tests für Benutzer-/Token-Helfer und Kamera-Felder."""
|
||||
|
||||
import unittest
|
||||
|
||||
from grotti import auth
|
||||
from grotti import moonraker
|
||||
|
||||
|
||||
class AuthHelpersTest(unittest.TestCase):
|
||||
def test_password_roundtrip(self):
|
||||
hashed = auth.hash_password("geheim123")
|
||||
self.assertTrue(auth.verify_password(hashed, "geheim123"))
|
||||
self.assertFalse(auth.verify_password(hashed, "falsch"))
|
||||
|
||||
def test_username_rules(self):
|
||||
self.assertEqual(auth.validate_username("admin"), "admin")
|
||||
with self.assertRaises(Exception):
|
||||
auth.validate_username("a")
|
||||
with self.assertRaises(Exception):
|
||||
auth.validate_username("bad name")
|
||||
|
||||
def test_role_rank(self):
|
||||
self.assertTrue(auth.role_at_least("admin", "operator"))
|
||||
self.assertTrue(auth.role_at_least("admin", "admin"))
|
||||
self.assertFalse(auth.role_at_least("operator", "admin"))
|
||||
|
||||
def test_token_hash_stable(self):
|
||||
raw = auth.new_api_token()
|
||||
self.assertTrue(raw.startswith("grt_"))
|
||||
self.assertEqual(auth.hash_token(raw), auth.hash_token(raw))
|
||||
self.assertNotEqual(auth.hash_token(raw), auth.hash_token(raw + "x"))
|
||||
|
||||
def test_clamp_token_role(self):
|
||||
self.assertEqual(auth.clamp_token_role("admin", "operator"), "operator")
|
||||
with self.assertRaises(Exception):
|
||||
auth.clamp_token_role("operator", "admin")
|
||||
|
||||
def test_login_rate(self):
|
||||
key = "test-rate-%s" % id(self)
|
||||
for _ in range(auth.LOGIN_MAX_ATTEMPTS):
|
||||
self.assertTrue(auth.login_rate_ok(key))
|
||||
auth.login_rate_hit(key)
|
||||
self.assertFalse(auth.login_rate_ok(key))
|
||||
|
||||
|
||||
class CameraFieldsTest(unittest.TestCase):
|
||||
def test_unknown_offers_url(self):
|
||||
moonraker.CAMERA_CACHE.clear()
|
||||
fields = moonraker._camera_fields("10.0.0.1", "10.0.0.1")
|
||||
self.assertTrue(fields["camera"])
|
||||
self.assertIsNone(fields["has_camera"])
|
||||
|
||||
def test_known_dead_hides_url(self):
|
||||
import time
|
||||
|
||||
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.assertFalse(fields["has_camera"])
|
||||
moonraker.CAMERA_CACHE.pop("10.0.0.2", None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -10,10 +11,11 @@ from urllib.parse import quote
|
||||
|
||||
import markdown
|
||||
import psycopg
|
||||
from flask import Flask, abort, g, jsonify, render_template, request
|
||||
from flask import Flask, abort, g, jsonify, redirect, render_template, request, session, url_for
|
||||
from markupsafe import Markup
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from grotti import auth as grotti_auth
|
||||
from grotti import config as grotti_config
|
||||
from grotti import eject, jobs, library, moonraker, notify, store
|
||||
from grotti.catalog import color_catalog
|
||||
@@ -29,7 +31,6 @@ from grotti.watcher import StateWatcher
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
TOOL_ROOT = ROOT.parent
|
||||
DOCS_DIR = TOOL_ROOT / "docs"
|
||||
CHANGELOG = TOOL_ROOT / "CHANGELOG.md"
|
||||
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
|
||||
VERDICTS = {"", "gut", "ausschuss"}
|
||||
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
|
||||
@@ -47,8 +48,13 @@ def create_app(cfg):
|
||||
static_folder=str(ROOT / "static"),
|
||||
)
|
||||
app.config["GROTTI"] = cfg
|
||||
app.config["SECRET_KEY"] = cfg.get("secret_key") or secrets.token_hex(32)
|
||||
app.config["TEMPLATES_AUTO_RELOAD"] = True
|
||||
app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
if cfg.get("fqdn", "").startswith("https://"):
|
||||
app.config["SESSION_COOKIE_SECURE"] = True
|
||||
app.json.ensure_ascii = False
|
||||
app.extensions["send_queue"] = SendQueue(cfg["database_url"], cfg)
|
||||
watcher = StateWatcher(cfg["database_url"], cfg)
|
||||
@@ -58,19 +64,167 @@ def create_app(cfg):
|
||||
watcher.start()
|
||||
dispatcher.start()
|
||||
|
||||
@app.before_request
|
||||
def check_token():
|
||||
token = cfg["token"]
|
||||
if not token or not request.path.startswith("/api/"):
|
||||
PUBLIC_PATHS = {
|
||||
"/login",
|
||||
"/setup",
|
||||
"/api/v1/auth/status",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/setup",
|
||||
"/api/v1/version",
|
||||
}
|
||||
ADMIN_API_PREFIXES = (
|
||||
"/api/v1/users",
|
||||
"/api/v1/einstellungen",
|
||||
)
|
||||
ADMIN_API_EXACT = {
|
||||
"/api/v1/telegram/test",
|
||||
}
|
||||
ADMIN_METHODS_ON = {
|
||||
# Drucker anlegen/ändern/löschen nur Admin
|
||||
("POST", "/api/v1/drucker"),
|
||||
}
|
||||
|
||||
def conn():
|
||||
if "db" not in g:
|
||||
g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row)
|
||||
return g.db
|
||||
|
||||
def ensure_csrf():
|
||||
if not session.get("csrf"):
|
||||
session["csrf"] = grotti_auth.new_csrf()
|
||||
return session["csrf"]
|
||||
|
||||
def auth_user():
|
||||
return getattr(g, "user", None)
|
||||
|
||||
def require_role(need):
|
||||
user = auth_user()
|
||||
if not user or not grotti_auth.role_at_least(user.get("role"), need):
|
||||
raise GrottiError("Keine Berechtigung.", "forbidden", 403)
|
||||
return user
|
||||
|
||||
def resolve_bearer(presented):
|
||||
legacy = cfg.get("token") or ""
|
||||
if legacy and secrets.compare_digest(presented, legacy):
|
||||
return {
|
||||
"id": None,
|
||||
"username": "legacy-token",
|
||||
"role": "admin",
|
||||
"auth": "legacy",
|
||||
}
|
||||
if not presented.startswith(grotti_auth.TOKEN_PREFIX):
|
||||
return None
|
||||
row = store.get_api_token_by_hash(conn(), grotti_auth.hash_token(presented))
|
||||
if not row or row.get("revoked_at") or not row.get("user_active"):
|
||||
return None
|
||||
if row.get("expires_at") and row["expires_at"].timestamp() < time.time():
|
||||
return None
|
||||
store.touch_api_token(conn(), row["id"])
|
||||
return {
|
||||
"id": row["user_id"],
|
||||
"username": row["username"],
|
||||
"role": row["role"],
|
||||
"auth": "token",
|
||||
"token_id": row["id"],
|
||||
}
|
||||
|
||||
def resolve_session_user():
|
||||
uid = session.get("user_id")
|
||||
if not uid:
|
||||
return None
|
||||
row = store.get_user(conn(), uid)
|
||||
if not row or not row.get("active"):
|
||||
session.clear()
|
||||
return None
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"role": row["role"],
|
||||
"auth": "session",
|
||||
}
|
||||
|
||||
def wants_json():
|
||||
if request.path.startswith("/api/"):
|
||||
return True
|
||||
accept = request.accept_mimetypes
|
||||
return accept.best == "application/json"
|
||||
|
||||
@app.before_request
|
||||
def check_auth():
|
||||
g.user = None
|
||||
path = request.path
|
||||
if path.startswith("/static/"):
|
||||
return None
|
||||
if path in PUBLIC_PATHS:
|
||||
return None
|
||||
|
||||
users_exist = store.user_count(conn()) > 0
|
||||
if not users_exist:
|
||||
if path.startswith("/api/") and path != "/api/v1/auth/setup":
|
||||
if path == "/api/v1/auth/status":
|
||||
return None
|
||||
return jsonify({
|
||||
"error": "Zuerst Admin anlegen.",
|
||||
"code": "setup",
|
||||
"setup": True,
|
||||
}), 503
|
||||
if path != "/setup":
|
||||
return redirect(url_for("setup_page"))
|
||||
return None
|
||||
|
||||
presented = ""
|
||||
header = request.headers.get("Authorization", "")
|
||||
if header.startswith("Bearer "):
|
||||
presented = header[7:]
|
||||
elif request.method == "GET":
|
||||
presented = request.args.get("token", "")
|
||||
if not presented or not secrets.compare_digest(presented, token):
|
||||
presented = header[7:].strip()
|
||||
elif request.method == "GET" and request.path.startswith("/api/"):
|
||||
presented = (request.args.get("token") or "").strip()
|
||||
|
||||
user = None
|
||||
if presented:
|
||||
user = resolve_bearer(presented)
|
||||
if not user:
|
||||
return jsonify({"error": "Token fehlt oder stimmt nicht.", "code": "token"}), 401
|
||||
else:
|
||||
user = resolve_session_user()
|
||||
|
||||
if not user:
|
||||
if wants_json() or path.startswith("/api/"):
|
||||
return jsonify({"error": "Anmeldung nötig.", "code": "auth"}), 401
|
||||
return redirect(url_for("login_page", next=path))
|
||||
|
||||
g.user = user
|
||||
ensure_csrf()
|
||||
|
||||
# CSRF für Cookie-Sessions bei mutierenden API-Calls
|
||||
if (
|
||||
user.get("auth") == "session"
|
||||
and path.startswith("/api/")
|
||||
and request.method in ("POST", "PATCH", "PUT", "DELETE")
|
||||
):
|
||||
sent = request.headers.get("X-CSRF-Token") or ""
|
||||
expected = session.get("csrf") or ""
|
||||
if not expected or not secrets.compare_digest(sent, expected):
|
||||
return jsonify({"error": "CSRF-Token fehlt oder ungültig.", "code": "csrf"}), 403
|
||||
|
||||
need_admin = False
|
||||
if path.startswith(ADMIN_API_PREFIXES) or path in ADMIN_API_EXACT:
|
||||
if path == "/api/v1/einstellungen" and request.method == "GET":
|
||||
need_admin = False
|
||||
elif path == "/api/v1/einstellungen" and request.method == "PATCH":
|
||||
# Operator darf nur auto_queue schalten
|
||||
payload = request.get_json(silent=True) or {}
|
||||
keys = {k for k, v in payload.items() if v is not None}
|
||||
need_admin = keys - {"auto_queue"} != set()
|
||||
elif path.startswith("/api/v1/users"):
|
||||
need_admin = True
|
||||
elif request.method != "GET":
|
||||
need_admin = True
|
||||
if (request.method, path) in ADMIN_METHODS_ON:
|
||||
need_admin = True
|
||||
if path.startswith("/api/v1/drucker/id/") and request.method in ("PATCH", "DELETE"):
|
||||
need_admin = True
|
||||
if need_admin and not grotti_auth.role_at_least(user.get("role"), "admin"):
|
||||
return jsonify({"error": "Nur für Admins.", "code": "forbidden"}), 403
|
||||
return None
|
||||
|
||||
@app.teardown_appcontext
|
||||
@@ -88,16 +242,11 @@ def create_app(cfg):
|
||||
|
||||
@app.errorhandler(GrottiError)
|
||||
def grotti_error(err):
|
||||
conn = g.get("db")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
db = g.get("db")
|
||||
if db:
|
||||
db.rollback()
|
||||
return jsonify({"error": err.message, "code": err.code}), err.status
|
||||
|
||||
def conn():
|
||||
if "db" not in g:
|
||||
g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row)
|
||||
return g.db
|
||||
|
||||
def body():
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
@@ -124,16 +273,250 @@ def create_app(cfg):
|
||||
return host
|
||||
|
||||
@app.context_processor
|
||||
def inject_version():
|
||||
return {"version": __version__}
|
||||
def inject_globals():
|
||||
user = auth_user()
|
||||
return {
|
||||
"version": __version__,
|
||||
"csrf": session.get("csrf") or "",
|
||||
"current_user": user,
|
||||
}
|
||||
|
||||
def page(name, title):
|
||||
return render_template(name, page=name.split(".")[0], title=title, token=cfg["token"])
|
||||
return render_template(
|
||||
name,
|
||||
page=name.split(".")[0],
|
||||
title=title,
|
||||
token="",
|
||||
)
|
||||
|
||||
def audit(kind, message, host=""):
|
||||
user = auth_user()
|
||||
who = (user or {}).get("username") or "?"
|
||||
store.add_event(conn(), host, "", kind, "%s · %s" % (who, message))
|
||||
|
||||
@app.get("/login")
|
||||
def login_page():
|
||||
if store.user_count(conn()) == 0:
|
||||
return redirect(url_for("setup_page"))
|
||||
if resolve_session_user():
|
||||
return redirect("/")
|
||||
ensure_csrf()
|
||||
return render_template("login.html", title="Anmelden", csrf=session.get("csrf") or "")
|
||||
|
||||
@app.get("/setup")
|
||||
def setup_page():
|
||||
if store.user_count(conn()) > 0:
|
||||
return redirect(url_for("login_page"))
|
||||
ensure_csrf()
|
||||
return render_template("setup.html", title="Ersten Admin anlegen", csrf=session.get("csrf") or "")
|
||||
|
||||
@app.get("/api/v1/auth/status")
|
||||
def auth_status():
|
||||
count = store.user_count(conn())
|
||||
user = resolve_session_user()
|
||||
return jsonify({
|
||||
"setup_required": count == 0,
|
||||
"authenticated": bool(user or auth_user()),
|
||||
"user": grotti_auth.public_user(user) if user else None,
|
||||
"oauth_enabled": bool((cfg.get("oauth") or {}).get("enabled")),
|
||||
"legacy_token_set": bool(cfg.get("token")),
|
||||
})
|
||||
|
||||
@app.post("/api/v1/auth/setup")
|
||||
def auth_setup():
|
||||
if store.user_count(conn()) > 0:
|
||||
raise GrottiError("Setup ist schon erledigt.", "setup", 409)
|
||||
data = body()
|
||||
username = grotti_auth.validate_username(data.get("username") or "")
|
||||
password = grotti_auth.validate_password(data.get("password") or "")
|
||||
row = store.create_user(conn(), username, grotti_auth.hash_password(password), "admin")
|
||||
store.touch_user_login(conn(), row["id"])
|
||||
session.clear()
|
||||
session["user_id"] = row["id"]
|
||||
session["csrf"] = grotti_auth.new_csrf()
|
||||
store.add_event(conn(), "", "", "auth", "Admin %s angelegt" % username)
|
||||
return jsonify({"user": grotti_auth.public_user(row), "csrf": session["csrf"]}), 201
|
||||
|
||||
@app.post("/api/v1/auth/login")
|
||||
def auth_login():
|
||||
if store.user_count(conn()) == 0:
|
||||
raise GrottiError("Zuerst Admin anlegen.", "setup", 503)
|
||||
key = request.remote_addr or "unknown"
|
||||
if not grotti_auth.login_rate_ok(key):
|
||||
raise GrottiError("Zu viele Versuche. Bitte warten.", "rate", 429)
|
||||
data = body()
|
||||
username = (data.get("username") or "").strip()
|
||||
password = data.get("password") or ""
|
||||
row = store.get_user_by_name(conn(), username)
|
||||
if not row or not row.get("active") or not grotti_auth.verify_password(row["password_hash"], password):
|
||||
grotti_auth.login_rate_hit(key)
|
||||
raise GrottiError("Benutzer oder Passwort falsch.", "auth", 401)
|
||||
store.touch_user_login(conn(), row["id"])
|
||||
session.clear()
|
||||
session["user_id"] = row["id"]
|
||||
session["csrf"] = grotti_auth.new_csrf()
|
||||
store.add_event(conn(), "", "", "auth", "Login %s" % row["username"])
|
||||
return jsonify({"user": grotti_auth.public_user(row), "csrf": session["csrf"]})
|
||||
|
||||
@app.post("/api/v1/auth/logout")
|
||||
def auth_logout():
|
||||
session.clear()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@app.get("/api/v1/me")
|
||||
def me():
|
||||
user = require_role("operator")
|
||||
return jsonify({"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"role": user["role"],
|
||||
"auth": user.get("auth"),
|
||||
}, "csrf": ensure_csrf()})
|
||||
|
||||
@app.get("/api/v1/users")
|
||||
def users_list():
|
||||
require_role("admin")
|
||||
return jsonify([grotti_auth.public_user(r) for r in store.list_users(conn())])
|
||||
|
||||
@app.post("/api/v1/users")
|
||||
def users_create():
|
||||
require_role("admin")
|
||||
data = body()
|
||||
username = grotti_auth.validate_username(data.get("username") or "")
|
||||
password = grotti_auth.validate_password(data.get("password") or "")
|
||||
role = grotti_auth.normalize_role(data.get("role") or "operator")
|
||||
if store.get_user_by_name(conn(), username):
|
||||
raise GrottiError("Benutzer gibt es schon.", "user", 409)
|
||||
row = store.create_user(conn(), username, grotti_auth.hash_password(password), role)
|
||||
audit("auth", "Benutzer %s (%s) angelegt" % (username, role))
|
||||
return jsonify(grotti_auth.public_user(row)), 201
|
||||
|
||||
@app.patch("/api/v1/users/<int:user_id>")
|
||||
def users_patch(user_id):
|
||||
actor = require_role("admin")
|
||||
data = body()
|
||||
row = store.get_user(conn(), user_id)
|
||||
if not row:
|
||||
raise GrottiError("Benutzer fehlt.", "user", 404)
|
||||
password_hash = None
|
||||
if data.get("password"):
|
||||
password_hash = grotti_auth.hash_password(grotti_auth.validate_password(data["password"]))
|
||||
role = grotti_auth.normalize_role(data["role"]) if "role" in data else None
|
||||
active = data.get("active") if "active" in data else None
|
||||
if active is False and row["id"] == actor.get("id"):
|
||||
raise GrottiError("Eigenes Konto nicht deaktivieren.", "user")
|
||||
if role == "operator" and row["role"] == "admin" and row["id"] == actor.get("id"):
|
||||
admins = [u for u in store.list_users(conn()) if u["role"] == "admin" and u["active"]]
|
||||
if len(admins) <= 1:
|
||||
raise GrottiError("Letzter Admin bleibt Admin.", "user")
|
||||
updated = store.update_user(conn(), user_id, password_hash=password_hash, role=role, active=active)
|
||||
audit("auth", "Benutzer %s geändert" % row["username"])
|
||||
return jsonify(grotti_auth.public_user(updated))
|
||||
|
||||
@app.delete("/api/v1/users/<int:user_id>")
|
||||
def users_delete(user_id):
|
||||
actor = require_role("admin")
|
||||
row = store.get_user(conn(), user_id)
|
||||
if not row:
|
||||
raise GrottiError("Benutzer fehlt.", "user", 404)
|
||||
if row["id"] == actor.get("id"):
|
||||
raise GrottiError("Eigenes Konto nicht löschen.", "user")
|
||||
if row["role"] == "admin":
|
||||
admins = [u for u in store.list_users(conn()) if u["role"] == "admin" and u["active"]]
|
||||
if len(admins) <= 1:
|
||||
raise GrottiError("Letzter Admin bleibt.", "user")
|
||||
store.delete_user(conn(), user_id)
|
||||
audit("auth", "Benutzer %s gelöscht" % row["username"])
|
||||
return "", 204
|
||||
|
||||
@app.get("/api/v1/tokens")
|
||||
def tokens_list():
|
||||
user = require_role("operator")
|
||||
if user.get("role") == "admin" and request.args.get("all") == "1":
|
||||
rows = store.list_api_tokens(conn(), user_id=None)
|
||||
else:
|
||||
if not user.get("id"):
|
||||
return jsonify([])
|
||||
rows = store.list_api_tokens(conn(), user_id=user["id"])
|
||||
out = []
|
||||
for row in rows:
|
||||
item = grotti_auth.public_token(row)
|
||||
item["username"] = row.get("username")
|
||||
out.append(item)
|
||||
return jsonify(out)
|
||||
|
||||
@app.post("/api/v1/tokens")
|
||||
def tokens_create():
|
||||
user = require_role("operator")
|
||||
if not user.get("id"):
|
||||
raise GrottiError("Legacy-Token: bitte Benutzer-Token anlegen.", "token")
|
||||
data = body()
|
||||
name = (data.get("name") or "Token").strip()[:80]
|
||||
role = grotti_auth.clamp_token_role(user["role"], data.get("role"))
|
||||
raw = grotti_auth.new_api_token()
|
||||
row = store.create_api_token(
|
||||
conn(),
|
||||
user["id"],
|
||||
name,
|
||||
grotti_auth.hash_token(raw),
|
||||
grotti_auth.token_prefix(raw),
|
||||
role,
|
||||
)
|
||||
audit("auth", "API-Token „%s“ erzeugt" % name)
|
||||
item = grotti_auth.public_token(row)
|
||||
item["token"] = raw
|
||||
return jsonify(item), 201
|
||||
|
||||
@app.delete("/api/v1/tokens/<int:token_id>")
|
||||
def tokens_revoke(token_id):
|
||||
user = require_role("operator")
|
||||
if user.get("role") == "admin":
|
||||
row = store.revoke_api_token(conn(), token_id, user_id=None)
|
||||
else:
|
||||
row = store.revoke_api_token(conn(), token_id, user_id=user.get("id"))
|
||||
if not row:
|
||||
raise GrottiError("Token fehlt.", "token", 404)
|
||||
audit("auth", "API-Token #%s widerrufen" % token_id)
|
||||
return jsonify(grotti_auth.public_token(row))
|
||||
|
||||
def doc_names():
|
||||
"""Nur Anleitung-*.md — Technik liegt unter docs/internal/ (nicht in der App)."""
|
||||
if not DOCS_DIR.is_dir():
|
||||
return []
|
||||
return sorted(p.stem for p in DOCS_DIR.glob("*.md"))
|
||||
return sorted(p.stem for p in DOCS_DIR.glob("Anleitung-*.md"))
|
||||
|
||||
ANLEITUNG_ORDER = (
|
||||
"Anleitung-Einrichtung",
|
||||
"Anleitung-Oberflaeche",
|
||||
"Anleitung-Wand",
|
||||
"Anleitung-Dateien",
|
||||
"Anleitung-ACE",
|
||||
"Anleitung-Warteschlange",
|
||||
"Anleitung-Material",
|
||||
"Anleitung-Einstellungen",
|
||||
"Anleitung-Stoerungen",
|
||||
)
|
||||
|
||||
def anleitung_list(names=None):
|
||||
names = names if names is not None else doc_names()
|
||||
anleitung_set = set(names)
|
||||
ordered = [n for n in ANLEITUNG_ORDER if n in anleitung_set]
|
||||
ordered += sorted(anleitung_set - set(ordered))
|
||||
return ordered
|
||||
|
||||
def anleitung_title(name):
|
||||
labels = {
|
||||
"Anleitung-Einrichtung": "Einrichtung",
|
||||
"Anleitung-Oberflaeche": "Oberfläche",
|
||||
"Anleitung-Wand": "Druckerwand",
|
||||
"Anleitung-Dateien": "Dateien senden",
|
||||
"Anleitung-ACE": "ACE und Spulen",
|
||||
"Anleitung-Warteschlange": "Warteschlange",
|
||||
"Anleitung-Material": "Material und mehr",
|
||||
"Anleitung-Einstellungen": "Einstellungen",
|
||||
"Anleitung-Stoerungen": "Störungen",
|
||||
}
|
||||
return labels.get(name, name.replace("Anleitung-", "").replace("-", " "))
|
||||
|
||||
def render_markdown(path):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
@@ -180,7 +563,7 @@ def create_app(cfg):
|
||||
"etikett.html",
|
||||
page="etikett",
|
||||
title="Etikett %s" % spool["chargen_nr"],
|
||||
token=cfg["token"],
|
||||
token="",
|
||||
spool=spool,
|
||||
qr_url=qr_url,
|
||||
)
|
||||
@@ -203,37 +586,42 @@ def create_app(cfg):
|
||||
|
||||
@app.get("/doku")
|
||||
def doku():
|
||||
names = doc_names()
|
||||
anleitung = anleitung_list(names)
|
||||
return render_template(
|
||||
"doku.html",
|
||||
page="doku",
|
||||
title="Doku",
|
||||
token=cfg["token"],
|
||||
docs=doc_names(),
|
||||
token="",
|
||||
docs=names,
|
||||
anleitung=anleitung,
|
||||
anleitung_title=anleitung_title,
|
||||
)
|
||||
|
||||
@app.get("/doku/<name>")
|
||||
def doku_page(name):
|
||||
if name == "changelog":
|
||||
path, title = CHANGELOG, "Changelog"
|
||||
elif name in doc_names():
|
||||
path, title = DOCS_DIR / ("%s.md" % name), name.replace("-", " ")
|
||||
else:
|
||||
names = doc_names()
|
||||
if name not in names:
|
||||
abort(404)
|
||||
path = DOCS_DIR / ("%s.md" % name)
|
||||
if not path.is_file():
|
||||
abort(404)
|
||||
anleitung = anleitung_list(names)
|
||||
return render_template(
|
||||
"doku_page.html",
|
||||
page="doku",
|
||||
title=title,
|
||||
token=cfg["token"],
|
||||
title=anleitung_title(name),
|
||||
token="",
|
||||
content=render_markdown(path),
|
||||
docs=doc_names(),
|
||||
docs=names,
|
||||
anleitung=anleitung,
|
||||
anleitung_title=anleitung_title,
|
||||
current=name,
|
||||
)
|
||||
|
||||
@app.get("/api/v1/version")
|
||||
def version_info():
|
||||
return jsonify({"version": __version__, "changelog": "/doku/changelog"})
|
||||
return jsonify({"version": __version__})
|
||||
|
||||
@app.get("/api/v1/einstellungen")
|
||||
def settings():
|
||||
@@ -262,7 +650,7 @@ def create_app(cfg):
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@app.get("/api/v1/drucker")
|
||||
@app.get("/api/drucker")
|
||||
@app.get("/api/drucker") # veraltet — Alias zu /api/v1/drucker
|
||||
def printers():
|
||||
rows = store.list_printers(conn())
|
||||
clears = store.bed_clear_map(conn())
|
||||
@@ -767,6 +1155,8 @@ def create_app(cfg):
|
||||
data = body()
|
||||
if isinstance(data.get("steps"), list) and data.get("steps"):
|
||||
return jsonify(enqueue_ablauf_payload(data)), 202
|
||||
if not bool(data.get("confirm")):
|
||||
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
|
||||
try:
|
||||
file_id = int(data.get("file_id"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const TOKEN = document.querySelector('meta[name="token"]')?.content || "";
|
||||
const TOKEN = ""; // Legacy: Auth über Session-Cookie oder Bearer von außen
|
||||
const CSRF = document.querySelector('meta[name="csrf"]')?.content || "";
|
||||
|
||||
const STATE_LABELS = {
|
||||
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
|
||||
@@ -17,17 +18,29 @@ function escapeHtml(value) {
|
||||
async function api(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (TOKEN) headers.set("Authorization", "Bearer " + TOKEN);
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
if (CSRF && method !== "GET" && method !== "HEAD") {
|
||||
headers.set("X-CSRF-Token", CSRF);
|
||||
}
|
||||
let body = options.body;
|
||||
if (options.json !== undefined) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
body = JSON.stringify(options.json);
|
||||
}
|
||||
const response = await fetch("/api/v1" + path, {
|
||||
method: options.method || "GET",
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (response.status === 401) {
|
||||
const here = location.pathname + location.search;
|
||||
if (!here.startsWith("/login") && !here.startsWith("/setup")) {
|
||||
location.href = "/login?next=" + encodeURIComponent(here);
|
||||
}
|
||||
throw new Error("Anmeldung nötig.");
|
||||
}
|
||||
if (!response.ok) {
|
||||
let message = response.statusText;
|
||||
try {
|
||||
@@ -45,8 +58,8 @@ async function api(path, options = {}) {
|
||||
}
|
||||
|
||||
function withToken(url) {
|
||||
if (!url || !TOKEN) return url;
|
||||
return url + (url.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN);
|
||||
// Session-Cookie reicht für same-origin Kameras; kein Token in der URL.
|
||||
return url;
|
||||
}
|
||||
|
||||
function euro(value) {
|
||||
@@ -103,6 +116,13 @@ function chipHtml(state, label, small) {
|
||||
return `<span class="chip ${escapeHtml(state || "")}${small ? " sm" : ""}">${escapeHtml(text)}</span>`;
|
||||
}
|
||||
|
||||
function shownState(printer) {
|
||||
const state = (printer && printer.state) || "offline";
|
||||
const hot = ((printer && printer.nozzle_target) || 0) > 40 || ((printer && printer.bed_target) || 0) > 30;
|
||||
if ((state === "standby" || state === "complete") && hot) return "heating";
|
||||
return state;
|
||||
}
|
||||
|
||||
function stepsHtml(labels, index, compact) {
|
||||
if (!labels || !labels.length) return "";
|
||||
const parts = labels.map((label, i) => {
|
||||
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 670 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -111,20 +111,21 @@ summary:focus:not(:focus-visible) { outline: none; }
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.brand-mark {
|
||||
flex: none;
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
border-radius: 99px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.brand-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nav-toggle {
|
||||
flex: none;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
.brand::before {
|
||||
content: "";
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
border-radius: 99px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-soft);
|
||||
}
|
||||
.nav-main, .nav-secondary { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
.nav-main a, .nav-secondary a {
|
||||
color: var(--muted);
|
||||
@@ -183,10 +184,14 @@ html.nav-collapsed .brand,
|
||||
body.nav-collapsed .brand {
|
||||
padding: 0.2rem;
|
||||
justify-content: center;
|
||||
font-size: 0;
|
||||
}
|
||||
html.nav-collapsed .brand::before,
|
||||
body.nav-collapsed .brand::before { margin: 0; }
|
||||
html.nav-collapsed .brand-name,
|
||||
body.nav-collapsed .brand-name { display: none; }
|
||||
html.nav-collapsed .brand-mark,
|
||||
body.nav-collapsed .brand-mark {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
html.nav-collapsed .brand-row,
|
||||
body.nav-collapsed .brand-row { flex-direction: column; padding-bottom: 0.35rem; }
|
||||
html.nav-collapsed .nav-toggle,
|
||||
@@ -803,6 +808,11 @@ article.printer {
|
||||
font-weight: 650;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.camera-tile .cam-stage {
|
||||
position: relative;
|
||||
min-height: 140px;
|
||||
background: var(--card-2);
|
||||
}
|
||||
.camera-tile img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -810,16 +820,22 @@ article.printer {
|
||||
object-fit: contain;
|
||||
background: var(--card-2);
|
||||
}
|
||||
.camera-tile img.broken { min-height: 120px; opacity: 0.35; }
|
||||
.cam-url {
|
||||
display: block;
|
||||
padding: 0.4rem 0.75rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--faint);
|
||||
word-break: break-all;
|
||||
text-decoration: none;
|
||||
.camera-tile .cam-empty {
|
||||
margin: 0;
|
||||
min-height: 140px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.camera-tile .cam-empty .ico { width: 1.6rem; height: 1.6rem; opacity: 0.55; }
|
||||
.camera-tile .cam-open {
|
||||
display: inline-block;
|
||||
margin: 0.45rem 0.75rem 0.65rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.cam-url:hover { color: var(--accent); }
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
@@ -937,6 +953,60 @@ tr.pick:hover td { background: rgba(255, 255, 255, 0.04); }
|
||||
tr.on td { background: var(--accent-soft); }
|
||||
td select { width: auto; min-width: 8.5rem; padding: 0.3rem 0.45rem; min-height: 1.9rem; border-radius: 7px; }
|
||||
.thumb { width: 64px; height: 64px; object-fit: contain; background: var(--card-2); border-radius: 12px; border: 1px solid var(--line); }
|
||||
.auth-body {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 0%, rgba(61, 206, 124, 0.12), transparent 45%),
|
||||
var(--bg);
|
||||
}
|
||||
.auth-card {
|
||||
width: min(22rem, 100%);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.4rem 1.35rem 1.5rem;
|
||||
}
|
||||
.auth-card h1 { margin: 0 0 0.35rem; font-size: 1.45rem; }
|
||||
.auth-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.auth-brand img {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 99px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.auth-quote {
|
||||
margin: 0.55rem 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.auth-quote blockquote {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.auth-quote figcaption {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--faint);
|
||||
font-style: normal;
|
||||
}
|
||||
.auth-card .stack { display: grid; gap: 0.75rem; margin-top: 1rem; }
|
||||
.side-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.empty { color: var(--muted); padding: 1rem 0.3rem; }
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
@@ -2092,6 +2162,17 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
|
||||
.prose table { margin: 0.8rem 0; font-size: var(--t-sm); }
|
||||
.prose blockquote { margin: 0.8rem 0; padding: 0.4rem 0.9rem; border-left: 3px solid var(--accent); background: var(--accent-soft); border-radius: 0 10px 10px 0; }
|
||||
.prose .lede { font-size: 1.05rem; color: var(--ink); }
|
||||
.prose img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--card-2);
|
||||
}
|
||||
.prose .doc-chapters { columns: 2; column-gap: 1.5rem; }
|
||||
.prose .doc-chapters li { break-inside: avoid; }
|
||||
.flow-diagram { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; margin: 0.8rem 0 1rem; }
|
||||
.flow-diagram .step { font-size: var(--t-xs); padding: 0.3rem 0.65rem; }
|
||||
.terms { display: grid; grid-template-columns: max-content 1fr; gap: 0.45rem 1rem; margin: 0.6rem 0; }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/qrcode@1.5.1/build/qrcode.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
var QRCode=function(t){"use strict";var r,e=function(){return"function"==typeof Promise&&Promise.prototype&&Promise.prototype.then},n=[0,26,44,70,100,134,172,196,242,292,346,404,466,532,581,655,733,815,901,991,1085,1156,1258,1364,1474,1588,1706,1828,1921,2051,2185,2323,2465,2611,2761,2876,3034,3196,3362,3532,3706],o=function(t){if(!t)throw new Error('"version" cannot be null or undefined');if(t<1||t>40)throw new Error('"version" should be in range from 1 to 40');return 4*t+17},a=function(t){return n[t]},i=function(t){for(var r=0;0!==t;)r++,t>>>=1;return r},u=function(t){if("function"!=typeof t)throw new Error('"toSJISFunc" is not a valid function.');r=t},s=function(){return void 0!==r},f=function(t){return r(t)};function h(t,r){return t(r={exports:{}},r.exports),r.exports}var c=h((function(t,r){r.L={bit:1},r.M={bit:0},r.Q={bit:3},r.H={bit:2},r.isValid=function(t){return t&&void 0!==t.bit&&t.bit>=0&&t.bit<4},r.from=function(t,e){if(r.isValid(t))return t;try{return function(t){if("string"!=typeof t)throw new Error("Param is not a string");switch(t.toLowerCase()){case"l":case"low":return r.L;case"m":case"medium":return r.M;case"q":case"quartile":return r.Q;case"h":case"high":return r.H;default:throw new Error("Unknown EC Level: "+t)}}(t)}catch(t){return e}}}));function g(){this.buffer=[],this.length=0}c.L,c.M,c.Q,c.H,c.isValid,g.prototype={get:function(t){var r=Math.floor(t/8);return 1==(this.buffer[r]>>>7-t%8&1)},put:function(t,r){for(var e=0;e<r;e++)this.putBit(1==(t>>>r-e-1&1))},getLengthInBits:function(){return this.length},putBit:function(t){var r=Math.floor(this.length/8);this.buffer.length<=r&&this.buffer.push(0),t&&(this.buffer[r]|=128>>>this.length%8),this.length++}};var d=g;function l(t){if(!t||t<1)throw new Error("BitMatrix size must be defined and greater than 0");this.size=t,this.data=new Uint8Array(t*t),this.reservedBit=new Uint8Array(t*t)}l.prototype.set=function(t,r,e,n){var o=t*this.size+r;this.data[o]=e,n&&(this.reservedBit[o]=!0)},l.prototype.get=function(t,r){return this.data[t*this.size+r]},l.prototype.xor=function(t,r,e){this.data[t*this.size+r]^=e},l.prototype.isReserved=function(t,r){return this.reservedBit[t*this.size+r]};var v=l,p=h((function(t,r){var e=o;r.getRowColCoords=function(t){if(1===t)return[];for(var r=Math.floor(t/7)+2,n=e(t),o=145===n?26:2*Math.ceil((n-13)/(2*r-2)),a=[n-7],i=1;i<r-1;i++)a[i]=a[i-1]-o;return a.push(6),a.reverse()},r.getPositions=function(t){for(var e=[],n=r.getRowColCoords(t),o=n.length,a=0;a<o;a++)for(var i=0;i<o;i++)0===a&&0===i||0===a&&i===o-1||a===o-1&&0===i||e.push([n[a],n[i]]);return e}}));p.getRowColCoords,p.getPositions;var w=o,m=function(t){var r=w(t);return[[0,0],[r-7,0],[0,r-7]]},E=h((function(t,r){r.Patterns={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7};var e=3,n=3,o=40,a=10;function i(t,e,n){switch(t){case r.Patterns.PATTERN000:return(e+n)%2==0;case r.Patterns.PATTERN001:return e%2==0;case r.Patterns.PATTERN010:return n%3==0;case r.Patterns.PATTERN011:return(e+n)%3==0;case r.Patterns.PATTERN100:return(Math.floor(e/2)+Math.floor(n/3))%2==0;case r.Patterns.PATTERN101:return e*n%2+e*n%3==0;case r.Patterns.PATTERN110:return(e*n%2+e*n%3)%2==0;case r.Patterns.PATTERN111:return(e*n%3+(e+n)%2)%2==0;default:throw new Error("bad maskPattern:"+t)}}r.isValid=function(t){return null!=t&&""!==t&&!isNaN(t)&&t>=0&&t<=7},r.from=function(t){return r.isValid(t)?parseInt(t,10):void 0},r.getPenaltyN1=function(t){for(var r=t.size,n=0,o=0,a=0,i=null,u=null,s=0;s<r;s++){o=a=0,i=u=null;for(var f=0;f<r;f++){var h=t.get(s,f);h===i?o++:(o>=5&&(n+=e+(o-5)),i=h,o=1),(h=t.get(f,s))===u?a++:(a>=5&&(n+=e+(a-5)),u=h,a=1)}o>=5&&(n+=e+(o-5)),a>=5&&(n+=e+(a-5))}return n},r.getPenaltyN2=function(t){for(var r=t.size,e=0,o=0;o<r-1;o++)for(var a=0;a<r-1;a++){var i=t.get(o,a)+t.get(o,a+1)+t.get(o+1,a)+t.get(o+1,a+1);4!==i&&0!==i||e++}return e*n},r.getPenaltyN3=function(t){for(var r=t.size,e=0,n=0,a=0,i=0;i<r;i++){n=a=0;for(var u=0;u<r;u++)n=n<<1&2047|t.get(i,u),u>=10&&(1488===n||93===n)&&e++,a=a<<1&2047|t.get(u,i),u>=10&&(1488===a||93===a)&&e++}return e*o},r.getPenaltyN4=function(t){for(var r=0,e=t.data.length,n=0;n<e;n++)r+=t.data[n];return Math.abs(Math.ceil(100*r/e/5)-10)*a},r.applyMask=function(t,r){for(var e=r.size,n=0;n<e;n++)for(var o=0;o<e;o++)r.isReserved(o,n)||r.xor(o,n,i(t,o,n))},r.getBestMask=function(t,e){for(var n=Object.keys(r.Patterns).length,o=0,a=1/0,i=0;i<n;i++){e(i),r.applyMask(i,t);var u=r.getPenaltyN1(t)+r.getPenaltyN2(t)+r.getPenaltyN3(t)+r.getPenaltyN4(t);r.applyMask(i,t),u<a&&(a=u,o=i)}return o}}));E.Patterns,E.isValid,E.getPenaltyN1,E.getPenaltyN2,E.getPenaltyN3,E.getPenaltyN4,E.applyMask,E.getBestMask;var y=[1,1,1,1,1,1,1,1,1,1,2,2,1,2,2,4,1,2,4,4,2,4,4,4,2,4,6,5,2,4,6,6,2,5,8,8,4,5,8,8,4,5,8,11,4,8,10,11,4,9,12,16,4,9,16,16,6,10,12,18,6,10,17,16,6,11,16,19,6,13,18,21,7,14,21,25,8,16,20,25,8,17,23,25,9,17,23,34,9,18,25,30,10,20,27,32,12,21,29,35,12,23,34,37,12,25,34,40,13,26,35,42,14,28,38,45,15,29,Line truncated
|
||||
@@ -3,15 +3,12 @@
|
||||
<p>Handbuch</p>
|
||||
<a href="/doku#grotti" data-sec="grotti" class="{{ 'on' if current == 'start' else '' }}">Was Grotti ist</a>
|
||||
<a href="/doku#druck" data-sec="druck">Ein Druck von A bis Z</a>
|
||||
<a href="/doku#ablauf" data-sec="ablauf">Ablauf (Kobra 3)</a>
|
||||
<a href="/doku#seiten" data-sec="seiten">Seiten</a>
|
||||
<a href="/doku#kapitel" data-sec="kapitel">Anleitung</a>
|
||||
<a href="/doku#haengt" data-sec="haengt">Wenn etwas hängt</a>
|
||||
<a href="/doku#begriffe" data-sec="begriffe">Begriffe</a>
|
||||
<p>Versionen</p>
|
||||
<a href="/doku/changelog" class="{{ 'on' if current == 'changelog' else '' }}">Changelog · v{{ version }}</a>
|
||||
<p>Technik</p>
|
||||
{% for name in docs %}
|
||||
<a href="/doku/{{ name }}" class="{{ 'on' if current == name else '' }}">{{ name | replace('-', ' ') }}</a>
|
||||
<p>Anleitung</p>
|
||||
{% for name in anleitung %}
|
||||
<a href="/doku/{{ name }}" class="{{ 'on' if current == name else '' }}">{{ anleitung_title(name) }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
@@ -6,8 +6,8 @@
|
||||
<article class="prose">
|
||||
<h1 id="grotti">Grotti <span class="badge">v{{ version }}</span></h1>
|
||||
<p class="lede">Grotti steuert die Drucker im Haus von einer Seite aus: sehen, was läuft, Dateien schicken, Aufträge einreihen und nachlesen, was passiert ist.</p>
|
||||
<p>Sie läuft auf diesem Rechner und spricht jeden Drucker über Moonraker an (Rinkhals auf Kobra 3 und Kobra S1). Daten liegen in PostgreSQL. Alles, was die Seiten zeigen, gibt es auch unter <code>/api/v1</code>.</p>
|
||||
<blockquote>Sicherheit: Ein Druck startet nur nach ausdrücklicher Bestätigung. Es gibt keine freie G-code-Konsole und kein <code>FIRMWARE_RESTART</code>.</blockquote>
|
||||
<p>Sie läuft auf diesem Rechner und spricht jeden Drucker an. Alles, was die Seiten zeigen, gibt es auch unter <code>/api/v1</code> — mit Login oder API-Token.</p>
|
||||
<blockquote>Sicherheit: Ein Druck startet nur nach ausdrücklicher Bestätigung. Es gibt keine freie G-code-Konsole.</blockquote>
|
||||
|
||||
<h2 id="druck">Ein Druck von A bis Z</h2>
|
||||
<div class="flow-diagram">
|
||||
@@ -22,46 +22,28 @@
|
||||
<li><strong>Drucker wählen.</strong> Die Kacheln zeigen live, was jeder Drucker gerade tut. Passt die Maschine nicht zur Datei, ist die Kachel blass.</li>
|
||||
<li><strong>Wann.</strong> <em>Jetzt</em> startet sofort. <em>Wenn frei</em> reiht für diesen Drucker ein. <em>Nächster freier</em> lässt Grotti den ersten freien passenden Drucker nehmen.</li>
|
||||
<li><strong>Starten.</strong> „Jetzt“ will zwei Klicks. Der zweite ist die Bestätigung.</li>
|
||||
<li><strong>Während des Drucks</strong> zeigt die <a href="/">Wand</a> Fortschritt, Fertig-Uhrzeit und Temperaturen. Ein Klick auf die Karte öffnet das Cockpit mit Kamera, Steuerung, Bettnetz und 3D-Pfad. Die Seitenleiste zeigt immer, was läuft und was wartet.</li>
|
||||
<li><strong>Während des Drucks</strong> zeigt die <a href="/">Wand</a> Fortschritt, Fertig-Uhrzeit und Temperaturen. Ein Klick auf die Karte öffnet das Cockpit mit Kamera (falls vorhanden), Steuerung, Bettnetz und 3D-Pfad. Die Seitenleiste zeigt immer, was läuft und was wartet.</li>
|
||||
<li><strong>Danach</strong> ist die Platte belegt. Ein wartender Auftrag startet erst, wenn auf der Karte <em>Platte geräumt</em> gedrückt wurde, oder ein Ablauf sie selbst freischiebt.</li>
|
||||
</ol>
|
||||
<p>Ausführlich mit Screenshots: <a href="/doku/Anleitung-Dateien">Dateien senden</a>, <a href="/doku/Anleitung-Wand">Druckerwand</a>.</p>
|
||||
|
||||
<h2 id="ablauf">Ablauf (Kobra 3)</h2>
|
||||
<p>Ein Ablauf ist eine Kette, die der Kobra 3 alleine abarbeitet und beliebig oft wiederholt:</p>
|
||||
<div class="flow-diagram">
|
||||
<span class="step">Drucken</span><span class="step-sep"></span>
|
||||
<span class="step">Abkühlen</span><span class="step-sep"></span>
|
||||
<span class="step">Schieben</span><span class="step-sep"></span>
|
||||
<span class="step">Tasten</span><span class="step-sep"></span>
|
||||
<span class="step">nochmal</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li><strong>Abkühlen</strong> wartet, bis Bett und Düse unter der Grenze sind. Danach löst sich PLA meist von selbst.</li>
|
||||
<li><strong>Schieben</strong> fährt mit dem Extruder knapp über dem Bett von hinten nach vorn und schiebt das Teil in die Kiste.</li>
|
||||
<li><strong>Tasten</strong> tippt mit aktivem LeviQ-Netz (ohne <code>BED_MESH_CLEAR</code>, ohne <code>G28 Z</code>). Tastpunkt einstellbar. Maß: <code>Result is z=</code> über der lokalen Platte. Über 1 mm stoppt den Ablauf.</li>
|
||||
<h2 id="kapitel">Anleitung</h2>
|
||||
<p>Schritt für Schritt mit Screenshots. Kurzfassung oben, Details in den Kapiteln.</p>
|
||||
<ul class="doc-chapters">
|
||||
{% for name in anleitung %}
|
||||
<li><a href="/doku/{{ name }}">{{ anleitung_title(name) }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p>Einreihen unter <a href="/dateien?tab=ablauf">Dateien → Ablauf</a>. Der aktuelle Schritt steht auf der Druckerkarte und in der Seitenleiste. Abbrechen geht an beiden Stellen. Jeder Schritt landet unter <a href="/aktivitaet">Aktivität</a>.</p>
|
||||
|
||||
<h2 id="seiten">Seiten</h2>
|
||||
<dl class="terms">
|
||||
<dt><a href="/">Drucker</a></dt><dd>Die Wand. Eine Karte je Drucker mit Zustand, Fertig-Uhrzeit, Temperaturen und Ablauf-Schritten. Klick öffnet das Cockpit mit Kamera, Steuerung, Bettnetz und 3D-Pfad.</dd>
|
||||
<dt><a href="/dateien">Dateien</a></dt><dd>Bibliothek und Senden. Spule und ACE-Plätze unter Optionen. Warnt, wenn das Filament nicht reicht.</dd>
|
||||
<dt><a href="/kameras">Kameras</a></dt><dd>Alle Kamerabilder nebeneinander.</dd>
|
||||
<dt><a href="/aktivitaet">Aktivität</a></dt><dd>Zeitstrahl: Starts, Schritte, Fertig, Fehler, Abbrüche, offline. Filter nach Drucker, Art und Zeitraum.</dd>
|
||||
<dt><a href="/protokoll">Protokoll</a></dt><dd>Abgeschlossene Läufe mit Dauer, Gramm, Kosten und Urteil.</dd>
|
||||
<dt><a href="/material">Material</a></dt><dd>Sorten, Spulen, Farben, ACE-Plätze, Etiketten.</dd>
|
||||
<dt><a href="/projekte">Projekte</a></dt><dd>Aufträge mit Notiz und Kalkulation.</dd>
|
||||
<dt><a href="/einstellungen">Einstellungen</a></dt><dd>Drucker, Telegram-Alerts, Energie.</dd>
|
||||
</dl>
|
||||
|
||||
<h2 id="haengt">Wenn etwas hängt</h2>
|
||||
<dl class="terms">
|
||||
<dt>Auftrag wartet und startet nicht</dt><dd>Auf der Karte steht <em>Platte belegt</em>. Platte räumen, dann <em>Platte geräumt</em> drücken.</dd>
|
||||
<dt>Drucker im Fehlerzustand</dt><dd>Auf der Karte <em>Fehler löschen</em>. Grotti räumt hängende Fehler vor einem Start selbst, wenn kein Druck aktiv ist.</dd>
|
||||
<dt>Ablauf gestoppt</dt><dd>Die Seitenleiste zeigt den Grund. Ursache beheben, Ablauf neu einreihen. Unter <a href="/aktivitaet">Aktivität</a> steht, welcher Schritt zuletzt lief.</dd>
|
||||
<dt>Ablauf gestoppt</dt><dd>Auf der Karte oder in der Seitenleiste Ursache beheben, dann <em>Weiter</em> (ab dem fehlgeschlagenen Schritt) oder Verwerfen. Unter <a href="/aktivitaet">Aktivität</a> steht, welcher Schritt zuletzt lief.</dd>
|
||||
<dt>Drucker offline</dt><dd>Strom und WLAN prüfen. Grotti meldet, wenn er wieder da ist.</dd>
|
||||
<dt>ACE startet nicht</dt><dd>Unter Optionen die ACE-Zuordnung prüfen. Ohne Zuordnung kommt oft „cannot parse“.</dd>
|
||||
</dl>
|
||||
<p>Mehr Fälle: <a href="/doku/Anleitung-Stoerungen">Störungen</a>.</p>
|
||||
|
||||
<h2 id="begriffe">Begriffe</h2>
|
||||
<dl class="terms">
|
||||
@@ -71,14 +53,8 @@
|
||||
<dt>Platte belegt</dt><dd>Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.</dd>
|
||||
<dt>Alert</dt><dd>Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.</dd>
|
||||
<dt>Ereignis</dt><dd>Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.</dd>
|
||||
<dt>API-Token</dt><dd>Persönlicher Schlüssel für App, Home-Automation oder MCP — unter Einstellungen erzeugen.</dd>
|
||||
</dl>
|
||||
|
||||
<h2>Technik</h2>
|
||||
<p>Die Notizen zu Aufbau und Schnittstellen:</p>
|
||||
<ul>
|
||||
{% for name in docs %}<li><a href="/doku/{{ name }}">{{ name | replace('-', ' ') }}</a></li>{% endfor %}
|
||||
</ul>
|
||||
<p>Was sich je Version geändert hat, steht im <a href="/doku/changelog">Changelog</a>.</p>
|
||||
</article>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<p id="auto-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<section class="card admin-only">
|
||||
<h2>Öffentliche Adresse</h2>
|
||||
<p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z. B. localhost).</p>
|
||||
<div class="field" style="max-width:28rem">
|
||||
@@ -41,7 +41,7 @@
|
||||
<p id="fqdn-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<section class="card admin-only">
|
||||
<h2>Drucker</h2>
|
||||
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.</p>
|
||||
<div class="table-wrap">
|
||||
@@ -72,7 +72,7 @@
|
||||
<p id="printer-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<section class="card admin-only">
|
||||
<h2>Telegram</h2>
|
||||
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
|
||||
<div class="grid-2" style="margin-top:0.75rem">
|
||||
@@ -101,7 +101,7 @@
|
||||
<p id="tg-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<section class="card admin-only">
|
||||
<h2>Energie</h2>
|
||||
<p class="hint">Für Kosten im Protokoll. Filament zählt erst mit zugeordneter Spule.</p>
|
||||
<div class="grid-2">
|
||||
@@ -115,10 +115,61 @@
|
||||
<div class="actions"><button type="button" id="en-save">Speichern</button></div>
|
||||
<p id="en-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only">
|
||||
<h2>Benutzer</h2>
|
||||
<p class="hint">Rollen: <strong>Admin</strong> (alles) und <strong>Operator</strong> (Drucker, Queue, Material — keine Benutzerverwaltung).</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Rolle</th><th>Aktiv</th><th></th></tr></thead>
|
||||
<tbody id="user-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form id="user-add" class="grid-2" style="margin-top:1rem">
|
||||
<div class="field"><label for="user-name">Benutzer</label><input id="user-name" required minlength="2" autocomplete="off"></div>
|
||||
<div class="field"><label for="user-pass">Passwort</label><input id="user-pass" type="password" required minlength="8" autocomplete="new-password"></div>
|
||||
<div class="field">
|
||||
<label for="user-role">Rolle</label>
|
||||
<select id="user-role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
|
||||
</div>
|
||||
<div class="actions" style="align-self:end"><button type="submit">Anlegen</button></div>
|
||||
</form>
|
||||
<p id="user-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>API-Tokens</h2>
|
||||
<p class="hint">Für App, Home-Automation oder MCP. Klartext erscheint nur einmal bei der Erstellung. Authorization: <code>Bearer grt_…</code></p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Prefix</th><th>Rolle</th><th></th></tr></thead>
|
||||
<tbody id="token-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form id="token-add" class="grid-2" style="margin-top:1rem">
|
||||
<div class="field"><label for="token-name">Bezeichnung</label><input id="token-name" placeholder="Home Assistant" required></div>
|
||||
<div class="field">
|
||||
<label for="token-role">Rolle</label>
|
||||
<select id="token-role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
|
||||
</div>
|
||||
<div class="actions" style="align-self:end"><button type="submit">Token erzeugen</button></div>
|
||||
</form>
|
||||
<p id="token-note"></p>
|
||||
<p class="ok" id="token-once" hidden></p>
|
||||
<p class="hint" id="legacy-token-hint" hidden>In der Config steht noch ein Shared-Token — der gilt weiter als Admin. Besser auf Benutzer-Tokens umstellen und <code>token</code> in der Config leeren.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
|
||||
const isAdmin = document.querySelector('meta[name="user-role"]')?.content === "admin";
|
||||
document.querySelectorAll(".admin-only").forEach((el) => { el.hidden = !isAdmin; });
|
||||
if (!isAdmin) {
|
||||
const roleSel = document.getElementById("token-role");
|
||||
if (roleSel) {
|
||||
[...roleSel.options].forEach((opt) => { if (opt.value === "admin") opt.remove(); });
|
||||
}
|
||||
}
|
||||
|
||||
/* showNote aus api.js: Inline-Hinweis + Toast */
|
||||
|
||||
@@ -181,15 +232,109 @@ function renderPrinters(rows) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderUsers(rows) {
|
||||
const body = document.getElementById("user-rows");
|
||||
if (!body) return;
|
||||
body.innerHTML = "";
|
||||
rows.forEach((row) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${escapeHtml(row.username)}</td>
|
||||
<td><select data-f="role"><option value="operator">Operator</option><option value="admin">Admin</option></select></td>
|
||||
<td><label class="check"><input type="checkbox" data-f="active"></label></td><td></td>`;
|
||||
tr.querySelector('[data-f="role"]').value = row.role;
|
||||
tr.querySelector('[data-f="active"]').checked = !!row.active;
|
||||
const save = document.createElement("button");
|
||||
save.type = "button";
|
||||
save.textContent = "Speichern";
|
||||
save.addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/users/" + row.id, {
|
||||
method: "PATCH",
|
||||
json: {
|
||||
role: tr.querySelector('[data-f="role"]').value,
|
||||
active: tr.querySelector('[data-f="active"]').checked,
|
||||
},
|
||||
});
|
||||
showNote("user-note", null, "Benutzer gespeichert.");
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
showNote("user-note", error);
|
||||
}
|
||||
});
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "ghost warn";
|
||||
del.textContent = "Löschen";
|
||||
del.addEventListener("click", () => {
|
||||
armButton(del, "Wirklich löschen?", async () => {
|
||||
try {
|
||||
await api("/users/" + row.id, { method: "DELETE" });
|
||||
showNote("user-note", null, "Benutzer gelöscht.");
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
showNote("user-note", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
tr.children[3].appendChild(save);
|
||||
tr.children[3].appendChild(del);
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTokens(rows) {
|
||||
const body = document.getElementById("token-rows");
|
||||
body.innerHTML = "";
|
||||
if (!rows.length) {
|
||||
body.innerHTML = '<tr><td class="empty" colspan="4">Keine Tokens.</td></tr>';
|
||||
return;
|
||||
}
|
||||
rows.forEach((row) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${escapeHtml(row.name)}${row.username ? ' <span class="tiny">(' + escapeHtml(row.username) + ")</span>" : ""}</td>
|
||||
<td><code>${escapeHtml(row.prefix)}…</code></td>
|
||||
<td>${escapeHtml(row.role)}</td><td></td>`;
|
||||
const rev = document.createElement("button");
|
||||
rev.type = "button";
|
||||
rev.className = "ghost warn";
|
||||
rev.textContent = "Widerrufen";
|
||||
rev.addEventListener("click", () => {
|
||||
armButton(rev, "Widerrufen?", async () => {
|
||||
try {
|
||||
await api("/tokens/" + row.id, { method: "DELETE" });
|
||||
showNote("token-note", null, "Token widerrufen.");
|
||||
loadTokens();
|
||||
} catch (error) {
|
||||
showNote("token-note", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
tr.children[3].appendChild(rev);
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
if (!isAdmin) return;
|
||||
renderUsers(await api("/users"));
|
||||
}
|
||||
|
||||
async function loadTokens() {
|
||||
renderTokens(await api("/tokens" + (isAdmin ? "?all=1" : "")));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [settings, printers] = await Promise.all([
|
||||
api("/einstellungen"),
|
||||
api("/drucker?live=0"),
|
||||
]);
|
||||
renderPrinters(printers);
|
||||
if (isAdmin) renderPrinters(printers);
|
||||
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
|
||||
document.getElementById("fqdn").value = settings.fqdn || "";
|
||||
document.getElementById("tg-enabled").checked = !!(settings.telegram && settings.telegram.enabled);
|
||||
const fqdn = document.getElementById("fqdn");
|
||||
if (fqdn) fqdn.value = settings.fqdn || "";
|
||||
const tgEnabled = document.getElementById("tg-enabled");
|
||||
if (tgEnabled) {
|
||||
tgEnabled.checked = !!(settings.telegram && settings.telegram.enabled);
|
||||
document.getElementById("tg-token").value = "";
|
||||
document.getElementById("tg-token").placeholder = settings.telegram && settings.telegram.bot_token_set
|
||||
? ("gesetzt: " + (settings.telegram.bot_token || "••••"))
|
||||
@@ -209,8 +354,53 @@ async function load() {
|
||||
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
|
||||
document.getElementById("en-waste").value = en.waste_percent ?? 0;
|
||||
document.getElementById("en-util").value = en.utilization ?? 50;
|
||||
}
|
||||
const legacy = document.getElementById("legacy-token-hint");
|
||||
if (legacy) legacy.hidden = !settings.legacy_token_set;
|
||||
await Promise.all([loadUsers(), loadTokens()]);
|
||||
}
|
||||
|
||||
document.getElementById("user-add")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await api("/users", {
|
||||
method: "POST",
|
||||
json: {
|
||||
username: document.getElementById("user-name").value,
|
||||
password: document.getElementById("user-pass").value,
|
||||
role: document.getElementById("user-role").value,
|
||||
},
|
||||
});
|
||||
document.getElementById("user-add").reset();
|
||||
showNote("user-note", null, "Benutzer angelegt.");
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
showNote("user-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("token-add").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const once = document.getElementById("token-once");
|
||||
once.hidden = true;
|
||||
try {
|
||||
const out = await api("/tokens", {
|
||||
method: "POST",
|
||||
json: {
|
||||
name: document.getElementById("token-name").value,
|
||||
role: document.getElementById("token-role").value,
|
||||
},
|
||||
});
|
||||
document.getElementById("token-add").reset();
|
||||
once.hidden = false;
|
||||
once.textContent = "Token (nur jetzt sichtbar): " + out.token;
|
||||
showNote("token-note", null, "Token erzeugt — jetzt kopieren.");
|
||||
loadTokens();
|
||||
} catch (error) {
|
||||
showNote("token-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("auto-save").addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/einstellungen", {
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
</div>
|
||||
<canvas id="qr" width="120" height="120"></canvas>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/qrcode.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
||||
<script>
|
||||
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
|
||||
width: 120,
|
||||
|
||||
@@ -11,23 +11,12 @@
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const labels = {
|
||||
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
|
||||
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline"
|
||||
};
|
||||
const tiles = new Map();
|
||||
|
||||
function shownState(printer) {
|
||||
const state = printer.state || "offline";
|
||||
const hot = (printer.nozzle_target || 0) > 40 || (printer.bed_target || 0) > 30;
|
||||
if ((state === "standby" || state === "complete") && hot) return "heating";
|
||||
return state;
|
||||
}
|
||||
|
||||
function metaLine(printer) {
|
||||
const state = shownState(printer);
|
||||
const busy = state === "printing" || state === "paused";
|
||||
const bits = [labels[state] || state];
|
||||
const bits = [STATE_LABELS[state] || state];
|
||||
if (busy) {
|
||||
const pct = Math.round((printer.progress || 0) * 100);
|
||||
bits.push(pct + " %");
|
||||
@@ -52,8 +41,11 @@ function ensureTile(printer) {
|
||||
<span class="host"></span>
|
||||
</figcaption>
|
||||
<p class="cam-meta tiny"></p>
|
||||
<img alt="" data-role="cam">
|
||||
<a class="cam-url" href="#"></a>`;
|
||||
<div class="cam-stage">
|
||||
<img alt="" data-role="cam" hidden>
|
||||
<p class="cam-empty" data-role="cam-empty">${icon("camera")}<span>Keine Kamera</span></p>
|
||||
</div>
|
||||
<a class="ghost cam-open" href="/#${encodeURIComponent(printer.host)}">Cockpit</a>`;
|
||||
tiles.set(printer.host, tile);
|
||||
document.getElementById("grid").appendChild(tile);
|
||||
return tile;
|
||||
@@ -65,10 +57,10 @@ async function refresh() {
|
||||
const grid = document.getElementById("grid");
|
||||
if (!printers.length) {
|
||||
tiles.clear();
|
||||
grid.innerHTML = '<p class="empty">Keine Drucker eingetragen.</p>';
|
||||
grid.innerHTML = `<div class="empty-state">${icon("camera", "lg")}<p>Noch keine Drucker.</p><a href="/einstellungen">Zu den Einstellungen</a></div>`;
|
||||
return;
|
||||
}
|
||||
if (grid.querySelector(".empty")) grid.innerHTML = "";
|
||||
if (grid.querySelector(".empty-state")) grid.innerHTML = "";
|
||||
const seen = new Set();
|
||||
for (const printer of printers) {
|
||||
seen.add(printer.host);
|
||||
@@ -78,16 +70,28 @@ async function refresh() {
|
||||
tile.querySelector(".cam-name").textContent = printer.name;
|
||||
tile.querySelector(".host").textContent = printer.host;
|
||||
tile.querySelector(".cam-meta").textContent = metaLine(printer);
|
||||
const path = "/api/v1/kamera?host=" + encodeURIComponent(printer.host);
|
||||
const shown = "/api/v1/kamera?host=" + printer.host;
|
||||
tile.querySelector(".cam-open").href = "/#" + encodeURIComponent(printer.host);
|
||||
const img = tile.querySelector('[data-role="cam"]');
|
||||
const empty = tile.querySelector('[data-role="cam-empty"]');
|
||||
if (!printer.camera || printer.has_camera === false) {
|
||||
img.removeAttribute("src");
|
||||
img.hidden = true;
|
||||
empty.hidden = false;
|
||||
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
|
||||
continue;
|
||||
}
|
||||
img.alt = "Kamera " + printer.name;
|
||||
img.src = withToken(path) + "&t=" + Math.floor(Date.now() / 2000);
|
||||
img.classList.remove("broken");
|
||||
img.onerror = () => img.classList.add("broken");
|
||||
const link = tile.querySelector(".cam-url");
|
||||
link.href = withToken(path);
|
||||
link.textContent = shown;
|
||||
const next = withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 2000);
|
||||
img.onerror = () => {
|
||||
img.hidden = true;
|
||||
empty.hidden = false;
|
||||
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
|
||||
};
|
||||
img.onload = () => {
|
||||
img.hidden = false;
|
||||
empty.hidden = true;
|
||||
};
|
||||
img.src = next;
|
||||
}
|
||||
for (const [host, tile] of [...tiles.entries()]) {
|
||||
if (!seen.has(host)) {
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="token" content="{{ token }}">
|
||||
<meta name="csrf" content="{{ csrf }}">
|
||||
{% if current_user %}<meta name="user-role" content="{{ current_user.role }}">{% endif %}
|
||||
<title>{{ title }} · Grotti</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=36">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=39">
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
@@ -84,7 +88,10 @@
|
||||
</svg>
|
||||
<aside class="sidebar">
|
||||
<div class="brand-row">
|
||||
<a class="brand" href="/">Grotti</a>
|
||||
<a class="brand" href="/" title="Grotti">
|
||||
<img class="brand-mark" src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="28" height="28" decoding="async">
|
||||
<span class="brand-name">Grotti</span>
|
||||
</a>
|
||||
<button type="button" class="icon-btn nav-toggle" id="nav-toggle" title="Menü einklappen" aria-label="Menü einklappen" aria-pressed="false"><svg class="ico"><use href="#i-panel"/></svg></button>
|
||||
</div>
|
||||
<nav class="nav-main" aria-label="Hauptmenü">
|
||||
@@ -127,8 +134,14 @@
|
||||
<a href="/doku" class="{{ 'on' if page == 'doku' else '' }}" title="Doku"><svg class="ico"><use href="#i-book"/></svg><span>Doku</span></a>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
{% if current_user %}
|
||||
<div class="side-user">
|
||||
<span class="tiny" title="{{ current_user.role }}">{{ current_user.username }}</span>
|
||||
<button type="button" class="ghost tiny-btn" id="logout-btn">Abmelden</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<span class="side-copy">© by <a href="mailto:[email protected]">[email protected]</a></span>
|
||||
<a href="/doku/changelog" title="Changelog">v{{ version }}</a>
|
||||
<a href="/doku" title="Doku">v{{ version }}</a>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="app-main">
|
||||
@@ -143,8 +156,12 @@
|
||||
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }} nav-shelf"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
|
||||
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
|
||||
</nav>
|
||||
<script src="{{ url_for('static', filename='api.js') }}?v=23"></script>
|
||||
<script src="{{ url_for('static', filename='api.js') }}?v=24"></script>
|
||||
<script>
|
||||
document.getElementById("logout-btn")?.addEventListener("click", async () => {
|
||||
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
|
||||
location.href = "/login";
|
||||
});
|
||||
(function applyShellClasses() {
|
||||
const shelf = document.documentElement.classList.contains("shelf");
|
||||
const collapsed = document.documentElement.classList.contains("nav-collapsed");
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf" content="{{ csrf }}">
|
||||
<title>Anmelden · Grotti</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=40">
|
||||
</head>
|
||||
<body class="auth-body">
|
||||
<main class="auth-card">
|
||||
<h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="40" height="40" decoding="async"> Grotti</h1>
|
||||
<figure class="auth-quote" id="auth-quote" hidden>
|
||||
<blockquote id="auth-quote-text"></blockquote>
|
||||
<figcaption id="auth-quote-by"></figcaption>
|
||||
</figure>
|
||||
<form id="login-form" class="stack">
|
||||
<div class="field">
|
||||
<label for="username">Benutzer</label>
|
||||
<input id="username" name="username" autocomplete="username" required autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Passwort</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit">Anmelden</button>
|
||||
<p class="msg" id="note" hidden></p>
|
||||
</form>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
const quotes = [
|
||||
{ t: "Wissenschaftler entdecken die Welt, die ist; Ingenieure schaffen die Welt, die noch nie war.", a: "Theodore von Kármán" },
|
||||
{ t: "Perfektion ist nicht dann erreicht, wenn man nichts mehr hinzufügen, sondern wenn man nichts mehr weglassen kann.", a: "Antoine de Saint-Exupéry" },
|
||||
{ t: "Wenn man es nicht messen kann, kann man es nicht verbessern.", a: "Lord Kelvin" },
|
||||
{ t: "Einfachheit ist die höchste Form der Raffinesse.", a: "Leonardo da Vinci" },
|
||||
{ t: "Es gibt nichts Praktischeres als eine gute Theorie.", a: "Kurt Lewin" },
|
||||
{ t: "Gott steckt im Detail.", a: "Ludwig Mies van der Rohe" },
|
||||
{ t: "Erst das Problem lösen. Dann den Code schreiben.", a: "John Johnson" },
|
||||
{ t: "Qualität heißt, es von Anfang an richtig zu machen.", a: "W. Edwards Deming" },
|
||||
{ t: "Wer misst, misst Mist — wer nicht misst, steuert blind.", a: "Werkstattweisheit" },
|
||||
{ t: "Simplicity is a great virtue, but it requires hard work to achieve it.", a: "Edsger W. Dijkstra" },
|
||||
];
|
||||
const pick = quotes[Math.floor(Date.now() / 86400000) % quotes.length];
|
||||
document.getElementById("auth-quote-text").textContent = pick.t;
|
||||
document.getElementById("auth-quote-by").textContent = "— " + pick.a;
|
||||
document.getElementById("auth-quote").hidden = false;
|
||||
})();
|
||||
document.getElementById("login-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const note = document.getElementById("note");
|
||||
note.hidden = true;
|
||||
try {
|
||||
const response = await fetch("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById("username").value,
|
||||
password: document.getElementById("password").value,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || response.statusText);
|
||||
const next = new URLSearchParams(location.search).get("next") || "/";
|
||||
location.href = next.startsWith("/") ? next : "/";
|
||||
} catch (error) {
|
||||
note.hidden = false;
|
||||
note.textContent = error.message || String(error);
|
||||
note.className = "msg";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf" content="{{ csrf }}">
|
||||
<title>Setup · Grotti</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=40">
|
||||
</head>
|
||||
<body class="auth-body">
|
||||
<main class="auth-card">
|
||||
<h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="40" height="40" decoding="async"> Grotti einrichten</h1>
|
||||
<p class="tiny">Lege den ersten Administrator an. Danach ist Login Pflicht.</p>
|
||||
<form id="setup-form" class="stack">
|
||||
<div class="field">
|
||||
<label for="username">Admin-Benutzer</label>
|
||||
<input id="username" name="username" autocomplete="username" required autofocus minlength="2">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Passwort</label>
|
||||
<input id="password" name="password" type="password" autocomplete="new-password" required minlength="8">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password2">Passwort wiederholen</label>
|
||||
<input id="password2" name="password2" type="password" autocomplete="new-password" required minlength="8">
|
||||
</div>
|
||||
<button type="submit">Admin anlegen</button>
|
||||
<p class="msg" id="note" hidden></p>
|
||||
</form>
|
||||
</main>
|
||||
<script>
|
||||
document.getElementById("setup-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const note = document.getElementById("note");
|
||||
note.hidden = true;
|
||||
const password = document.getElementById("password").value;
|
||||
if (password !== document.getElementById("password2").value) {
|
||||
note.hidden = false;
|
||||
note.textContent = "Passwörter stimmen nicht überein.";
|
||||
note.className = "msg";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch("/api/v1/auth/setup", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById("username").value,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || response.statusText);
|
||||
location.href = "/";
|
||||
} catch (error) {
|
||||
note.hidden = false;
|
||||
note.textContent = error.message || String(error);
|
||||
note.className = "msg";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -49,7 +49,7 @@
|
||||
<div class="desk-facts" data-role="facts"></div>
|
||||
<div data-role="flow"></div>
|
||||
<div data-role="strips"></div>
|
||||
<details class="desk-fold desk-steer-fold" data-fold="steer" open>
|
||||
<details class="desk-fold desk-steer-fold" data-fold="steer">
|
||||
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Steuerung</span></summary>
|
||||
<div data-role="steer"></div>
|
||||
</details>
|
||||
@@ -61,7 +61,7 @@
|
||||
</aside>
|
||||
</div>
|
||||
<div class="desk-tools">
|
||||
<details class="desk-fold desk-tool" data-fold="path" open>
|
||||
<details class="desk-fold desk-tool" data-fold="path">
|
||||
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">3D-Pfad</span><span class="tiny" data-role="path-caption"></span></summary>
|
||||
<div class="desk-fold-body">
|
||||
<p class="tiny">Ziehen dreht · Rad zoomt · Rechtsklick/Shift schwenkt · Doppelklick setzt zurück</p>
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="desk-fold desk-tool desk-tool-mesh" data-fold="mesh" open>
|
||||
<details class="desk-fold desk-tool desk-tool-mesh" data-fold="mesh">
|
||||
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Bettnetz</span></summary>
|
||||
<div class="desk-fold-body" data-role="mesh">
|
||||
<p class="desk-empty"><svg class="ico"><use href="#i-grid"/></svg><span>Noch kein Bettnetz</span></p>
|
||||
@@ -88,16 +88,15 @@
|
||||
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script>
|
||||
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=8"></script>
|
||||
<script>
|
||||
const labels = {
|
||||
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
|
||||
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline"
|
||||
};
|
||||
const labels = STATE_LABELS;
|
||||
const rank = { printing: 0, paused: 1, error: 2, heating: 3, complete: 4, cancelled: 5, standby: 6, offline: 7 };
|
||||
const DESK_STATES = ["printing", "paused", "complete", "standby", "heating", "error", "cancelled", "offline"];
|
||||
let deskHost = "";
|
||||
let hideOffline = false;
|
||||
let libraryFiles = [];
|
||||
let wallSpools = [];
|
||||
let deskViewer = null;
|
||||
let deskCamFails = 0;
|
||||
let deskViewerFileId = null;
|
||||
let deskExcludeSelected = "";
|
||||
const wallPullTried = new Set();
|
||||
@@ -123,9 +122,9 @@ function syncDeskCamCollapsed() {
|
||||
}
|
||||
|
||||
function restoreDeskFolds() {
|
||||
let closed = [];
|
||||
try { closed = JSON.parse(localStorage.getItem(DESK_FOLD_KEY) || "[]"); } catch (error) { closed = []; }
|
||||
if (!Array.isArray(closed)) closed = [];
|
||||
let closed;
|
||||
try { closed = JSON.parse(localStorage.getItem(DESK_FOLD_KEY) || "null"); } catch (error) { closed = null; }
|
||||
if (!Array.isArray(closed)) closed = ["steer", "path", "mesh"];
|
||||
deskEl.querySelectorAll("details[data-fold]").forEach((box) => {
|
||||
box.open = !closed.includes(box.dataset.fold);
|
||||
box.addEventListener("toggle", () => {
|
||||
@@ -193,13 +192,6 @@ function heatMapHtml(printer) {
|
||||
return `<div class="mesh desk-mesh"><p>Aktuelles Netz, oben ist hinten. ${low.toFixed(2)} bis ${high.toFixed(2)} mm</p><div class="gridmesh" style="grid-template-columns:repeat(${rows[0].length},1fr)">${cells}</div><p class="tiny">${bits.join(" · ")}</p></div>`;
|
||||
}
|
||||
|
||||
function shownState(printer) {
|
||||
const state = printer.state || "offline";
|
||||
const hot = (printer.nozzle_target || 0) > 40 || (printer.bed_target || 0) > 30;
|
||||
if ((state === "standby" || state === "complete") && hot) return "heating";
|
||||
return state;
|
||||
}
|
||||
|
||||
function jobName(filename) {
|
||||
return (filename || "").replace(/\.gcode$/i, "").replace(/^(\d+-)+/, "");
|
||||
}
|
||||
@@ -575,13 +567,14 @@ function refreshDeskCamera(printer) {
|
||||
const img = deskEl.querySelector('[data-role="camera"]');
|
||||
const empty = deskEl.querySelector('[data-role="cam-empty"]');
|
||||
if (!img || !empty) return;
|
||||
if (!printer || !printer.camera) {
|
||||
if (!printer || !printer.camera || printer.has_camera === false) {
|
||||
img.removeAttribute("src");
|
||||
img.hidden = true;
|
||||
empty.hidden = false;
|
||||
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
|
||||
setDeskCameraMode(false, "keine");
|
||||
img.dataset.srcKey = "";
|
||||
deskCamFails = 0;
|
||||
return;
|
||||
}
|
||||
// Layout (no-cam) nicht bei jedem Frame-Fehler umwerfen — sonst springt die
|
||||
@@ -593,6 +586,7 @@ function refreshDeskCamera(printer) {
|
||||
const probe = new Image();
|
||||
probe.onload = () => {
|
||||
if (img.dataset.srcKey !== next) return;
|
||||
deskCamFails = 0;
|
||||
img.src = next;
|
||||
img.hidden = false;
|
||||
empty.hidden = true;
|
||||
@@ -600,7 +594,16 @@ function refreshDeskCamera(printer) {
|
||||
};
|
||||
probe.onerror = () => {
|
||||
if (img.dataset.srcKey !== next) return;
|
||||
// Altes Bild behalten; nur wenn noch nie was kam, Platzhalter zeigen
|
||||
deskCamFails += 1;
|
||||
if (deskCamFails >= 2) {
|
||||
img.removeAttribute("src");
|
||||
img.hidden = true;
|
||||
empty.hidden = false;
|
||||
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
|
||||
setDeskCameraMode(false, "offline");
|
||||
img.dataset.srcKey = "";
|
||||
return;
|
||||
}
|
||||
if (!img.src || img.hidden) {
|
||||
empty.hidden = false;
|
||||
empty.innerHTML = icon("camera") + "<span>Kamera nicht erreichbar</span>";
|
||||
@@ -652,7 +655,9 @@ function patchDesk(printer) {
|
||||
deskEl.dataset.host = printer.host;
|
||||
deskEl.dataset.filename = printer.filename || "";
|
||||
deskEl.dataset.progress = String(printer.progress || 0);
|
||||
deskEl.className = "desk " + state;
|
||||
deskEl.classList.remove(...DESK_STATES);
|
||||
deskEl.classList.add(state);
|
||||
syncDeskCamCollapsed();
|
||||
|
||||
const role = (name) => deskEl.querySelector(`[data-role=${name}]`);
|
||||
const nameEl = role("name");
|
||||
@@ -1006,6 +1011,14 @@ async function refresh() {
|
||||
document.getElementById("summary").innerHTML = summaryText(all) || "keine Drucker";
|
||||
document.getElementById("when").textContent = new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
if (!all.length) {
|
||||
lastFleetKey = "";
|
||||
cardNodes.clear();
|
||||
cardsEl.innerHTML = `<div class="empty-state">${icon("printer", "lg")}<p>Noch keine Drucker.</p><p class="tiny">Unter Einstellungen anlegen.</p><a href="/einstellungen">Zu den Einstellungen</a></div>`;
|
||||
if (deskHost) closeDesk();
|
||||
return;
|
||||
}
|
||||
|
||||
const sections = ensureFleetShell(printers);
|
||||
const seen = new Set();
|
||||
for (const printer of printers) {
|
||||
|
||||