diff --git a/.cursor/rules/grotti.mdc b/.cursor/rules/grotti.mdc index a15e4d8..eb8d7e4 100644 --- a/.cursor/rules/grotti.mdc +++ b/.cursor/rules/grotti.mdc @@ -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/` 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\`). diff --git a/AGENTS.md b/AGENTS.md index 35a84b1..c7ba2e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/` 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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1196f7a..b31ecb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c18917a..dea7030 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen. .\.venv\Scripts\python.exe server.py ``` -Dann öffnen. Handbuch in der Oberfläche unter **Doku**. +Dann ö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 ö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/` | +| `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//` | Pause, Fortsetzen, Abbrechen, Fehler löschen | | `POST /drucker//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/` | Warteschlange, Abbrechen (`status: cancelled`), Ablauf fortsetzen (`resume`) | +| `GET /druck-queue`, `POST /druck-queue`, `PATCH /druck-queue/` | Warteschlange (`confirm` beim Einreihen) | | `POST /spulen//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 diff --git a/config.example.json b/config.example.json index bb22a94..8bb5c8b 100644 --- a/config.example.json +++ b/config.example.json @@ -5,8 +5,16 @@ "watch_dir": "", "database_url": "postgresql://postgres@127.0.0.1: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, diff --git a/docs/Anleitung-ACE.md b/docs/Anleitung-ACE.md new file mode 100644 index 0000000..e8754a0 --- /dev/null +++ b/docs/Anleitung-ACE.md @@ -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. + +![ACE-Zuordnung beim Senden](/static/doku/ace-zuordnung.png) + +## 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) diff --git a/docs/Anleitung-Dateien.md b/docs/Anleitung-Dateien.md new file mode 100644 index 0000000..eb95f16 --- /dev/null +++ b/docs/Anleitung-Dateien.md @@ -0,0 +1,49 @@ +# Dateien senden + +Stand **15.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts. + +![Dateien: Bibliothek und Senden](/static/doku/dateien-flow.png) + +## 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) diff --git a/docs/Anleitung-Einrichtung.md b/docs/Anleitung-Einrichtung.md new file mode 100644 index 0000000..4cf106c --- /dev/null +++ b/docs/Anleitung-Einrichtung.md @@ -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 öffnen. Beim ersten Start legst du den Admin-Benutzer an. Nach Code-Änderungen an Python oft neu starten. + +![Druckerwand nach dem Start](/static/doku/wand-uebersicht.png) + +## 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 diff --git a/docs/Anleitung-Einstellungen.md b/docs/Anleitung-Einstellungen.md new file mode 100644 index 0000000..3ff2214 --- /dev/null +++ b/docs/Anleitung-Einstellungen.md @@ -0,0 +1,44 @@ +# Einstellungen + +Stand **15.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie. + +![Einstellungen: Drucker](/static/doku/einstellungen-drucker.png) + +## 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) diff --git a/docs/Anleitung-Material.md b/docs/Anleitung-Material.md new file mode 100644 index 0000000..41c8cdb --- /dev/null +++ b/docs/Anleitung-Material.md @@ -0,0 +1,42 @@ +# Material und mehr + +Stand **15.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Kameras, Aktivität und Ideen. + +![Material: Spulen](/static/doku/material-spule.png) + +## 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) diff --git a/docs/Anleitung-Oberflaeche.md b/docs/Anleitung-Oberflaeche.md new file mode 100644 index 0000000..df3830e --- /dev/null +++ b/docs/Anleitung-Oberflaeche.md @@ -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. + +![Seitenleiste mit Live-Boxen](/static/doku/sidebar-live.png) + +## 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) diff --git a/docs/Anleitung-Stoerungen.md b/docs/Anleitung-Stoerungen.md new file mode 100644 index 0000000..5a647ba --- /dev/null +++ b/docs/Anleitung-Stoerungen.md @@ -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) diff --git a/docs/Anleitung-Wand.md b/docs/Anleitung-Wand.md new file mode 100644 index 0000000..d824b5d --- /dev/null +++ b/docs/Anleitung-Wand.md @@ -0,0 +1,54 @@ +# Druckerwand + +Stand **15.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit. + +![Druckerwand](/static/doku/wand-uebersicht.png) + +## 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: `/#` (z. B. nach Klick auf die Karte). + +![Cockpit eines Druckers](/static/doku/cockpit.png) + +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) diff --git a/docs/Anleitung-Warteschlange.md b/docs/Anleitung-Warteschlange.md new file mode 100644 index 0000000..c033826 --- /dev/null +++ b/docs/Anleitung-Warteschlange.md @@ -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). + +![Warteschlange](/static/doku/warteschlange.png) + +## 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) diff --git a/docs/ACE-Live.md b/docs/internal/ACE-Live.md similarity index 100% rename from docs/ACE-Live.md rename to docs/internal/ACE-Live.md diff --git a/docs/Abraeumen.md b/docs/internal/Abraeumen.md similarity index 100% rename from docs/Abraeumen.md rename to docs/internal/Abraeumen.md diff --git a/docs/Anycubic-Stack.md b/docs/internal/Anycubic-Stack.md similarity index 100% rename from docs/Anycubic-Stack.md rename to docs/internal/Anycubic-Stack.md diff --git a/docs/Chargen-Etiketten-Viewer.md b/docs/internal/Chargen-Etiketten-Viewer.md similarity index 84% rename from docs/Chargen-Etiketten-Viewer.md rename to docs/internal/Chargen-Etiketten-Viewer.md index b3056cf..1b5d9bd 100644 --- a/docs/Chargen-Etiketten-Viewer.md +++ b/docs/internal/Chargen-Etiketten-Viewer.md @@ -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/`. QR auf dem Etikett öffnet `/material?spool=`. -Etikett: `/etikett/`, 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/`, 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 diff --git a/docs/Dateien-Metadaten.md b/docs/internal/Dateien-Metadaten.md similarity index 100% rename from docs/Dateien-Metadaten.md rename to docs/internal/Dateien-Metadaten.md diff --git a/docs/Features-UI.md b/docs/internal/Features-UI.md similarity index 100% rename from docs/Features-UI.md rename to docs/internal/Features-UI.md diff --git a/docs/Glossar.md b/docs/internal/Glossar.md similarity index 100% rename from docs/Glossar.md rename to docs/internal/Glossar.md diff --git a/docs/Haftung.md b/docs/internal/Haftung.md similarity index 100% rename from docs/Haftung.md rename to docs/internal/Haftung.md diff --git a/docs/internal/MCP.md b/docs/internal/MCP.md new file mode 100644 index 0000000..27fb06f --- /dev/null +++ b/docs/internal/MCP.md @@ -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. diff --git a/docs/Ops-Queue-Alerts.md b/docs/internal/Ops-Queue-Alerts.md similarity index 100% rename from docs/Ops-Queue-Alerts.md rename to docs/internal/Ops-Queue-Alerts.md diff --git a/docs/internal/README.md b/docs/internal/README.md new file mode 100644 index 0000000..b28b413 --- /dev/null +++ b/docs/internal/README.md @@ -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`. diff --git a/docs/Senden-Queue.md b/docs/internal/Senden-Queue.md similarity index 100% rename from docs/Senden-Queue.md rename to docs/internal/Senden-Queue.md diff --git a/docs/Voraussetzungen.md b/docs/internal/Voraussetzungen.md similarity index 100% rename from docs/Voraussetzungen.md rename to docs/internal/Voraussetzungen.md diff --git a/docs/Wand-Bettnetz.md b/docs/internal/Wand-Bettnetz.md similarity index 100% rename from docs/Wand-Bettnetz.md rename to docs/internal/Wand-Bettnetz.md diff --git a/grotti/auth.py b/grotti/auth.py new file mode 100644 index 0000000..ed4e1b0 --- /dev/null +++ b/grotti/auth.py @@ -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")), + } diff --git a/grotti/config.py b/grotti/config.py index bcf5bbe..33cfd1b 100644 --- a/grotti/config.py +++ b/grotti/config.py @@ -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://postgres@127.0.0.1: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 ""), + }, } diff --git a/grotti/mcp_server.py b/grotti/mcp_server.py new file mode 100644 index 0000000..1cdeb48 --- /dev/null +++ b/grotti/mcp_server.py @@ -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() diff --git a/grotti/moonraker.py b/grotti/moonraker.py index e672b5a..657c053 100644 --- a/grotti/moonraker.py +++ b/grotti/moonraker.py @@ -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)) diff --git a/grotti/schema.sql b/grotti/schema.sql index 7952eb2..2c9d68f 100644 --- a/grotti/schema.sql +++ b/grotti/schema.sql @@ -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) +); diff --git a/grotti/store.py b/grotti/store.py index 01682c1..959f561 100644 --- a/grotti/store.py +++ b/grotti/store.py @@ -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() + diff --git a/grotti/version.py b/grotti/version.py index 3d6fa4e..60ec20d 100644 --- a/grotti/version.py +++ b/grotti/version.py @@ -1,3 +1,3 @@ """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" -__version__ = "2.1.0" +__version__ = "2.2.0" diff --git a/requirements.txt b/requirements.txt index 7bf5d5f..82fc757 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ psycopg[binary]>=3.2 paho-mqtt>=2.1 cryptography>=42 markdown>=3.6 +mcp>=1.2 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..b637609 --- /dev/null +++ b/tests/test_auth.py @@ -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() diff --git a/web/app.py b/web/app.py index b1516ff..2b18696 100644 --- a/web/app.py +++ b/web/app.py @@ -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): - return jsonify({"error": "Token fehlt oder stimmt nicht.", "code": "token"}), 401 + 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/") + 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/") + 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/") + 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/") 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: diff --git a/web/static/api.js b/web/static/api.js index 949624e..2cf70c2 100644 --- a/web/static/api.js +++ b/web/static/api.js @@ -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 `${escapeHtml(text)}`; } +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) => { diff --git a/web/static/brand/apple-touch-icon.png b/web/static/brand/apple-touch-icon.png new file mode 100644 index 0000000..4fd5722 Binary files /dev/null and b/web/static/brand/apple-touch-icon.png differ diff --git a/web/static/brand/favicon-32.png b/web/static/brand/favicon-32.png new file mode 100644 index 0000000..6c0b331 Binary files /dev/null and b/web/static/brand/favicon-32.png differ diff --git a/web/static/brand/favicon.png b/web/static/brand/favicon.png new file mode 100644 index 0000000..21b5bc3 Binary files /dev/null and b/web/static/brand/favicon.png differ diff --git a/web/static/brand/logo-icon.png b/web/static/brand/logo-icon.png new file mode 100644 index 0000000..c209624 Binary files /dev/null and b/web/static/brand/logo-icon.png differ diff --git a/web/static/brand/logo-lockup-dark.png b/web/static/brand/logo-lockup-dark.png new file mode 100644 index 0000000..f0f2c3c Binary files /dev/null and b/web/static/brand/logo-lockup-dark.png differ diff --git a/web/static/brand/logo-lockup.png b/web/static/brand/logo-lockup.png new file mode 100644 index 0000000..ffc97cd Binary files /dev/null and b/web/static/brand/logo-lockup.png differ diff --git a/web/static/brand/logo.png b/web/static/brand/logo.png new file mode 100644 index 0000000..2655d16 Binary files /dev/null and b/web/static/brand/logo.png differ diff --git a/web/static/doku/ace-zuordnung.png b/web/static/doku/ace-zuordnung.png new file mode 100644 index 0000000..42586e2 Binary files /dev/null and b/web/static/doku/ace-zuordnung.png differ diff --git a/web/static/doku/cockpit.png b/web/static/doku/cockpit.png new file mode 100644 index 0000000..6d3679c Binary files /dev/null and b/web/static/doku/cockpit.png differ diff --git a/web/static/doku/dateien-flow.png b/web/static/doku/dateien-flow.png new file mode 100644 index 0000000..0c1c90c Binary files /dev/null and b/web/static/doku/dateien-flow.png differ diff --git a/web/static/doku/einstellungen-drucker.png b/web/static/doku/einstellungen-drucker.png new file mode 100644 index 0000000..461ecb7 Binary files /dev/null and b/web/static/doku/einstellungen-drucker.png differ diff --git a/web/static/doku/material-spule.png b/web/static/doku/material-spule.png new file mode 100644 index 0000000..9230519 Binary files /dev/null and b/web/static/doku/material-spule.png differ diff --git a/web/static/doku/sidebar-live.png b/web/static/doku/sidebar-live.png new file mode 100644 index 0000000..8f22386 Binary files /dev/null and b/web/static/doku/sidebar-live.png differ diff --git a/web/static/doku/wand-uebersicht.png b/web/static/doku/wand-uebersicht.png new file mode 100644 index 0000000..b5c72f5 Binary files /dev/null and b/web/static/doku/wand-uebersicht.png differ diff --git a/web/static/doku/warteschlange.png b/web/static/doku/warteschlange.png new file mode 100644 index 0000000..ba938a7 Binary files /dev/null and b/web/static/doku/warteschlange.png differ diff --git a/web/static/grotti.css b/web/static/grotti.css index 5305713..d853f83 100644 --- a/web/static/grotti.css +++ b/web/static/grotti.css @@ -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; } diff --git a/web/static/qrcode.min.js b/web/static/qrcode.min.js new file mode 100644 index 0000000..974e062 --- /dev/null +++ b/web/static/qrcode.min.js @@ -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-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=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=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=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=0;){for(var n=e[0],o=0;o0){var o=new Uint8Array(this.degree);return o.set(e,n),o}return e};var L=T,b=function(t){return!isNaN(t)&&t>=1&&t<=40},U="(?:[u3000-u303F]|[u3040-u309F]|[u30A0-u30FF]|[uFF00-uFFEF]|[u4E00-u9FAF]|[u2605-u2606]|[u2190-u2195]|u203B|[u2010u2015u2018u2019u2025u2026u201Cu201Du2225u2260]|[u0391-u0451]|[u00A7u00A8u00B1u00B4u00D7u00F7])+",x="(?:(?![A-Z0-9 $%*+\\-./:]|"+(U=U.replace(/u/g,"\\u"))+")(?:.|[\r\n]))+",k=new RegExp(U,"g"),F=new RegExp("[^A-Z0-9 $%*+\\-./:]+","g"),S=new RegExp(x,"g"),D=new RegExp("[0-9]+","g"),Y=new RegExp("[A-Z $%*+\\-./:]+","g"),_=new RegExp("^"+U+"$"),z=new RegExp("^[0-9]+$"),H=new RegExp("^[A-Z0-9 $%*+\\-./:]+$"),J={KANJI:k,BYTE_KANJI:F,BYTE:S,NUMERIC:D,ALPHANUMERIC:Y,testKanji:function(t){return _.test(t)},testNumeric:function(t){return z.test(t)},testAlphanumeric:function(t){return H.test(t)}},K=h((function(t,r){r.NUMERIC={id:"Numeric",bit:1,ccBits:[10,12,14]},r.ALPHANUMERIC={id:"Alphanumeric",bit:2,ccBits:[9,11,13]},r.BYTE={id:"Byte",bit:4,ccBits:[8,16,16]},r.KANJI={id:"Kanji",bit:8,ccBits:[8,10,12]},r.MIXED={bit:-1},r.getCharCountIndicator=function(t,r){if(!t.ccBits)throw new Error("Invalid mode: "+t);if(!b(r))throw new Error("Invalid version: "+r);return r>=1&&r<10?t.ccBits[0]:r<27?t.ccBits[1]:t.ccBits[2]},r.getBestModeForData=function(t){return J.testNumeric(t)?r.NUMERIC:J.testAlphanumeric(t)?r.ALPHANUMERIC:J.testKanji(t)?r.KANJI:r.BYTE},r.toString=function(t){if(t&&t.id)return t.id;throw new Error("Invalid mode")},r.isValid=function(t){return t&&t.bit&&t.ccBits},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"numeric":return r.NUMERIC;case"alphanumeric":return r.ALPHANUMERIC;case"kanji":return r.KANJI;case"byte":return r.BYTE;default:throw new Error("Unknown mode: "+t)}}(t)}catch(t){return e}}}));K.NUMERIC,K.ALPHANUMERIC,K.BYTE,K.KANJI,K.MIXED,K.getCharCountIndicator,K.getBestModeForData,K.isValid;var O=h((function(t,r){var e=i(7973);function n(t,r){return K.getCharCountIndicator(t,r)+4}function o(t,r){var e=0;return t.forEach((function(t){var o=n(t.mode,r);e+=o+t.getBitsLength()})),e}r.from=function(t,r){return b(t)?parseInt(t,10):r},r.getCapacity=function(t,r,e){if(!b(t))throw new Error("Invalid QR Code version");void 0===e&&(e=K.BYTE);var o=8*(a(t)-M(t,r));if(e===K.MIXED)return o;var i=o-n(e,t);switch(e){case K.NUMERIC:return Math.floor(i/10*3);case K.ALPHANUMERIC:return Math.floor(i/11*2);case K.KANJI:return Math.floor(i/13);case K.BYTE:default:return Math.floor(i/8)}},r.getBestVersionForData=function(t,e){var n,a=c.from(e,c.M);if(Array.isArray(t)){if(t.length>1)return function(t,e){for(var n=1;n<=40;n++){if(o(t,n)<=r.getCapacity(n,e,K.MIXED))return n}}(t,a);if(0===t.length)return 1;n=t[0]}else n=t;return function(t,e,n){for(var o=1;o<=40;o++)if(e<=r.getCapacity(o,n,t))return o}(n.mode,n.getLength(),a)},r.getEncodedBits=function(t){if(!b(t)||t<7)throw new Error("Invalid QR Code version");for(var r=t<<12;i(r)-e>=0;)r^=7973<=0;)n^=1335<0&&(e=this.data.substr(r),n=parseInt(e,10),t.put(n,3*o+1))};var j=q,$=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"," ","$","%","*","+","-",".","/",":"];function X(t){this.mode=K.ALPHANUMERIC,this.data=t}X.getBitsLength=function(t){return 11*Math.floor(t/2)+t%2*6},X.prototype.getLength=function(){return this.data.length},X.prototype.getBitsLength=function(){return X.getBitsLength(this.data.length)},X.prototype.write=function(t){var r;for(r=0;r+2<=this.data.length;r+=2){var e=45*$.indexOf(this.data[r]);e+=$.indexOf(this.data[r+1]),t.put(e,11)}this.data.length%2&&t.put($.indexOf(this.data[r]),6)};var Z=X;function W(t){this.mode=K.BYTE,"string"==typeof t&&(t=function(t){for(var r=[],e=t.length,n=0;n=55296&&o<=56319&&e>n+1){var a=t.charCodeAt(n+1);a>=56320&&a<=57343&&(o=1024*(o-55296)+a-56320+65536,n+=1)}o<128?r.push(o):o<2048?(r.push(o>>6|192),r.push(63&o|128)):o<55296||o>=57344&&o<65536?(r.push(o>>12|224),r.push(o>>6&63|128),r.push(63&o|128)):o>=65536&&o<=1114111?(r.push(o>>18|240),r.push(o>>12&63|128),r.push(o>>6&63|128),r.push(63&o|128)):r.push(239,191,189)}return new Uint8Array(r).buffer}(t)),this.data=new Uint8Array(t)}W.getBitsLength=function(t){return 8*t},W.prototype.getLength=function(){return this.data.length},W.prototype.getBitsLength=function(){return W.getBitsLength(this.data.length)},W.prototype.write=function(t){for(var r=0,e=this.data.length;r=33088&&e<=40956)e-=33088;else{if(!(e>=57408&&e<=60351))throw new Error("Invalid SJIS character: "+this.data[r]+"\nMake sure your charset is UTF-8");e-=49472}e=192*(e>>>8&255)+(255&e),t.put(e,13)}};var rt=tt,et=h((function(t){var r={single_source_shortest_paths:function(t,e,n){var o={},a={};a[e]=0;var i,u,s,f,h,c,g,d=r.PriorityQueue.make();for(d.push(e,0);!d.empty();)for(s in u=(i=d.pop()).value,f=i.cost,h=t[u]||{})h.hasOwnProperty(s)&&(c=f+h[s],g=a[s],(void 0===a[s]||g>c)&&(a[s]=c,d.push(s,c),o[s]=u));if(void 0!==n&&void 0===a[n]){var l=["Could not find a path from ",e," to ",n,"."].join("");throw new Error(l)}return o},extract_shortest_path_from_predecessor_list:function(t,r){for(var e=[],n=r;n;)e.push(n),n=t[n];return e.reverse(),e},find_path:function(t,e,n){var o=r.single_source_shortest_paths(t,e,n);return r.extract_shortest_path_from_predecessor_list(o,n)},PriorityQueue:{make:function(t){var e,n=r.PriorityQueue,o={};for(e in t=t||{},n)n.hasOwnProperty(e)&&(o[e]=n[e]);return o.queue=[],o.sorter=t.sorter||n.default_sorter,o},default_sorter:function(t,r){return t.cost-r.cost},push:function(t,r){var e={value:t,cost:r};this.queue.push(e),this.queue.sort(this.sorter)},pop:function(){return this.queue.shift()},empty:function(){return 0===this.queue.length}}};t.exports=r})),nt=h((function(t,r){function e(t){return unescape(encodeURIComponent(t)).length}function n(t,r,e){for(var n,o=[];null!==(n=t.exec(e));)o.push({data:n[0],index:n.index,mode:r,length:n[0].length});return o}function o(t){var r,e,o=n(J.NUMERIC,K.NUMERIC,t),a=n(J.ALPHANUMERIC,K.ALPHANUMERIC,t);return s()?(r=n(J.BYTE,K.BYTE,t),e=n(J.KANJI,K.KANJI,t)):(r=n(J.BYTE_KANJI,K.BYTE,t),e=[]),o.concat(a,r,e).sort((function(t,r){return t.index-r.index})).map((function(t){return{data:t.data,mode:t.mode,length:t.length}}))}function a(t,r){switch(r){case K.NUMERIC:return j.getBitsLength(t);case K.ALPHANUMERIC:return Z.getBitsLength(t);case K.KANJI:return rt.getBitsLength(t);case K.BYTE:return G.getBitsLength(t)}}function i(t,r){var e,n=K.getBestModeForData(t);if((e=K.from(r,n))!==K.BYTE&&e.bit=0?t[t.length-1]:null;return e&&e.mode===r.mode?(t[t.length-1].data+=r.data,t):(t.push(r),t)}),[])}(s))},r.rawSplit=function(t){return r.fromArray(o(t))}}));function ot(t,r,e){var n,o,a=t.size,i=V(r,e);for(n=0;n<15;n++)o=1==(i>>n&1),n<6?t.set(n,8,o,!0):n<8?t.set(n+1,8,o,!0):t.set(a-15+n,8,o,!0),n<8?t.set(8,a-n-1,o,!0):n<9?t.set(8,15-n-1+1,o,!0):t.set(8,15-n-1,o,!0);t.set(a-8,8,1,!0)}function at(t,r,e){var n=new d;e.forEach((function(r){n.put(r.mode.bit,4),n.put(r.getLength(),K.getCharCountIndicator(r.mode,t)),r.write(n)}));var o=8*(a(t)-M(t,r));for(n.getLengthInBits()+4<=o&&n.put(0,4);n.getLengthInBits()%8!=0;)n.putBit(0);for(var i=(o-n.getLengthInBits())/8,u=0;u=0&&u<=6&&(0===s||6===s)||s>=0&&s<=6&&(0===u||6===u)||u>=2&&u<=4&&s>=2&&s<=4?t.set(a+u,i+s,!0,!0):t.set(a+u,i+s,!1,!0))}(c,r),function(t){for(var r=t.size,e=8;e=7&&function(t,r){for(var e,n,o,a=t.size,i=O.getEncodedBits(r),u=0;u<18;u++)e=Math.floor(u/3),n=u%3+a-8-3,o=1==(i>>u&1),t.set(e,n,o,!0),t.set(n,e,o,!0)}(c,r),function(t,r){for(var e=t.size,n=-1,o=e-1,a=7,i=0,u=e-1;u>0;u-=2)for(6===u&&u--;;){for(var s=0;s<2;s++)if(!t.isReserved(o,u-s)){var f=!1;i>>a&1)),t.set(o,u-s,f),-1===--a&&(i++,a=7)}if((o+=n)<0||e<=o){o-=n,n=-n;break}}}(c,f),isNaN(n)&&(n=E.getBestMask(c,ot.bind(null,c,e))),E.applyMask(n,c),ot(c,e,n),{modules:c,version:r,errorCorrectionLevel:e,maskPattern:n,segments:a}}nt.fromArray,nt.fromString,nt.rawSplit;var ut=function(t,r){if(void 0===t||""===t)throw new Error("No input text");var e,n,o=c.M;return void 0!==r&&(o=c.from(r.errorCorrectionLevel,c.M),e=O.from(r.version),n=E.from(r.maskPattern),r.toSJISFunc&&u(r.toSJISFunc)),it(t,e,o,n)},st=h((function(t,r){function e(t){if("number"==typeof t&&(t=t.toString()),"string"!=typeof t)throw new Error("Color should be defined as hex string");var r=t.slice().replace("#","").split("");if(r.length<3||5===r.length||r.length>8)throw new Error("Invalid hex color: "+t);3!==r.length&&4!==r.length||(r=Array.prototype.concat.apply([],r.map((function(t){return[t,t]})))),6===r.length&&r.push("F","F");var e=parseInt(r.join(""),16);return{r:e>>24&255,g:e>>16&255,b:e>>8&255,a:255&e,hex:"#"+r.slice(0,6).join("")}}r.getOptions=function(t){t||(t={}),t.color||(t.color={});var r=void 0===t.margin||null===t.margin||t.margin<0?4:t.margin,n=t.width&&t.width>=21?t.width:void 0,o=t.scale||4;return{width:n,scale:n?4:o,margin:r,color:{dark:e(t.color.dark||"#000000ff"),light:e(t.color.light||"#ffffffff")},type:t.type,rendererOpts:t.rendererOpts||{}}},r.getScale=function(t,r){return r.width&&r.width>=t+2*r.margin?r.width/(t+2*r.margin):r.scale},r.getImageWidth=function(t,e){var n=r.getScale(t,e);return Math.floor((t+2*e.margin)*n)},r.qrToImageData=function(t,e,n){for(var o=e.modules.size,a=e.modules.data,i=r.getScale(o,n),u=Math.floor((o+2*n.margin)*i),s=n.margin*i,f=[n.color.light,n.color.dark],h=0;h=s&&c>=s&&h':"",s="0&&s>0&&t[u-1]||(n+=a?ct("M",s+e,.5+f+e):ct("m",o,0),o=0,a=!1),s+1',f='viewBox="0 0 '+i+" "+i+'"',h=''+u+s+"\n";return"function"==typeof e&&e(null,h),h};function dt(t,r,n,o,a){var i=[].slice.call(arguments,1),u=i.length,s="function"==typeof i[u-1];if(!s&&!e())throw new Error("Callback required as last argument");if(!s){if(u<1)throw new Error("Too few arguments provided");return 1===u?(n=r,r=o=void 0):2!==u||r.getContext||(o=n,n=r,r=void 0),new Promise((function(e,a){try{var i=ut(n,o);e(t(i,r,o))}catch(t){a(t)}}))}if(u<2)throw new Error("Too few arguments provided");2===u?(a=n,n=r,r=o=void 0):3===u&&(r.getContext&&void 0===a?(a=o,o=void 0):(a=o,o=n,n=r,r=void 0));try{var f=ut(n,o);a(null,t(f,r,o))}catch(t){a(t)}}var lt=ut,vt=dt.bind(null,ft.render),pt=dt.bind(null,ft.renderToDataURL),wt=dt.bind(null,(function(t,r,e){return gt(t,e)})),mt={create:lt,toCanvas:vt,toDataURL:pt,toString:wt};return t.create=lt,t.default=mt,t.toCanvas=vt,t.toDataURL=pt,t.toString=wt,Object.defineProperty(t,"__esModule",{value:!0}),t}({}); diff --git a/web/templates/_doc_toc.html b/web/templates/_doc_toc.html index e5082d7..600c614 100644 --- a/web/templates/_doc_toc.html +++ b/web/templates/_doc_toc.html @@ -3,15 +3,12 @@

Handbuch

Was Grotti ist Ein Druck von A bis Z - Ablauf (Kobra 3) - Seiten + Anleitung Wenn etwas hängt Begriffe -

Versionen

- Changelog · v{{ version }} -

Technik

- {% for name in docs %} - {{ name | replace('-', ' ') }} +

Anleitung

+ {% for name in anleitung %} + {{ anleitung_title(name) }} {% endfor %} {% endmacro %} diff --git a/web/templates/doku.html b/web/templates/doku.html index c3ce583..568d2cc 100644 --- a/web/templates/doku.html +++ b/web/templates/doku.html @@ -6,8 +6,8 @@

Grotti v{{ version }}

Grotti steuert die Drucker im Haus von einer Seite aus: sehen, was läuft, Dateien schicken, Aufträge einreihen und nachlesen, was passiert ist.

-

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 /api/v1.

-
Sicherheit: Ein Druck startet nur nach ausdrücklicher Bestätigung. Es gibt keine freie G-code-Konsole und kein FIRMWARE_RESTART.
+

Sie läuft auf diesem Rechner und spricht jeden Drucker an. Alles, was die Seiten zeigen, gibt es auch unter /api/v1 — mit Login oder API-Token.

+
Sicherheit: Ein Druck startet nur nach ausdrücklicher Bestätigung. Es gibt keine freie G-code-Konsole.

Ein Druck von A bis Z

@@ -22,46 +22,28 @@
  • Drucker wählen. Die Kacheln zeigen live, was jeder Drucker gerade tut. Passt die Maschine nicht zur Datei, ist die Kachel blass.
  • Wann. Jetzt startet sofort. Wenn frei reiht für diesen Drucker ein. Nächster freier lässt Grotti den ersten freien passenden Drucker nehmen.
  • Starten. „Jetzt“ will zwei Klicks. Der zweite ist die Bestätigung.
  • -
  • Während des Drucks zeigt die Wand 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.
  • +
  • Während des Drucks zeigt die Wand 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.
  • Danach ist die Platte belegt. Ein wartender Auftrag startet erst, wenn auf der Karte Platte geräumt gedrückt wurde, oder ein Ablauf sie selbst freischiebt.
  • +

    Ausführlich mit Screenshots: Dateien senden, Druckerwand.

    -

    Ablauf (Kobra 3)

    -

    Ein Ablauf ist eine Kette, die der Kobra 3 alleine abarbeitet und beliebig oft wiederholt:

    -
    - Drucken - Abkühlen - Schieben - Tasten - nochmal -
    -
      -
    • Abkühlen wartet, bis Bett und Düse unter der Grenze sind. Danach löst sich PLA meist von selbst.
    • -
    • Schieben fährt mit dem Extruder knapp über dem Bett von hinten nach vorn und schiebt das Teil in die Kiste.
    • -
    • Tasten tippt mit aktivem LeviQ-Netz (ohne BED_MESH_CLEAR, ohne G28 Z). Tastpunkt einstellbar. Maß: Result is z= über der lokalen Platte. Über 1 mm stoppt den Ablauf.
    • +

      Anleitung

      +

      Schritt für Schritt mit Screenshots. Kurzfassung oben, Details in den Kapiteln.

      + -

      Einreihen unter Dateien → Ablauf. Der aktuelle Schritt steht auf der Druckerkarte und in der Seitenleiste. Abbrechen geht an beiden Stellen. Jeder Schritt landet unter Aktivität.

      - -

      Seiten

      -
      -
      Drucker
      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.
      -
      Dateien
      Bibliothek und Senden. Spule und ACE-Plätze unter Optionen. Warnt, wenn das Filament nicht reicht.
      -
      Kameras
      Alle Kamerabilder nebeneinander.
      -
      Aktivität
      Zeitstrahl: Starts, Schritte, Fertig, Fehler, Abbrüche, offline. Filter nach Drucker, Art und Zeitraum.
      -
      Protokoll
      Abgeschlossene Läufe mit Dauer, Gramm, Kosten und Urteil.
      -
      Material
      Sorten, Spulen, Farben, ACE-Plätze, Etiketten.
      -
      Projekte
      Aufträge mit Notiz und Kalkulation.
      -
      Einstellungen
      Drucker, Telegram-Alerts, Energie.
      -

      Wenn etwas hängt

      Auftrag wartet und startet nicht
      Auf der Karte steht Platte belegt. Platte räumen, dann Platte geräumt drücken.
      Drucker im Fehlerzustand
      Auf der Karte Fehler löschen. Grotti räumt hängende Fehler vor einem Start selbst, wenn kein Druck aktiv ist.
      -
      Ablauf gestoppt
      Die Seitenleiste zeigt den Grund. Ursache beheben, Ablauf neu einreihen. Unter Aktivität steht, welcher Schritt zuletzt lief.
      +
      Ablauf gestoppt
      Auf der Karte oder in der Seitenleiste Ursache beheben, dann Weiter (ab dem fehlgeschlagenen Schritt) oder Verwerfen. Unter Aktivität steht, welcher Schritt zuletzt lief.
      Drucker offline
      Strom und WLAN prüfen. Grotti meldet, wenn er wieder da ist.
      ACE startet nicht
      Unter Optionen die ACE-Zuordnung prüfen. Ohne Zuordnung kommt oft „cannot parse“.
      +

      Mehr Fälle: Störungen.

      Begriffe

      @@ -71,14 +53,8 @@
      Platte belegt
      Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.
      Alert
      Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.
      Ereignis
      Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.
      +
      API-Token
      Persönlicher Schlüssel für App, Home-Automation oder MCP — unter Einstellungen erzeugen.
      - -

      Technik

      -

      Die Notizen zu Aufbau und Schnittstellen:

      - -

      Was sich je Version geändert hat, steht im Changelog.

    {% endblock %} diff --git a/web/templates/einstellungen.html b/web/templates/einstellungen.html index 8d6a8c7..d108fd9 100644 --- a/web/templates/einstellungen.html +++ b/web/templates/einstellungen.html @@ -29,7 +29,7 @@

    -
    +

    Öffentliche Adresse

    FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z. B. localhost).

    @@ -41,7 +41,7 @@

    -
    +

    Drucker

    Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.

    @@ -72,7 +72,7 @@

    -
    +

    Telegram

    @@ -101,7 +101,7 @@

    -
    +

    Energie

    Für Kosten im Protokoll. Filament zählt erst mit zugeordneter Spule.

    @@ -115,10 +115,61 @@

    + +
    +

    Benutzer

    +

    Rollen: Admin (alles) und Operator (Drucker, Queue, Material — keine Benutzerverwaltung).

    +
    + + + +
    NameRolleAktiv
    +
    +
    +
    +
    +
    + + +
    +
    +
    +

    +
    + +
    +

    API-Tokens

    +

    Für App, Home-Automation oder MCP. Klartext erscheint nur einmal bei der Erstellung. Authorization: Bearer grt_…

    +
    + + + +
    NamePrefixRolle
    +
    +
    +
    +
    + + +
    +
    +
    +

    + + +
    {% endblock %} {% block script %} + + + + diff --git a/web/templates/setup.html b/web/templates/setup.html new file mode 100644 index 0000000..3c57aaa --- /dev/null +++ b/web/templates/setup.html @@ -0,0 +1,65 @@ + + + + + + +Setup · Grotti + + + + +
    +

    Grotti einrichten

    +

    Lege den ersten Administrator an. Danach ist Login Pflicht.

    +
    +
    + + +
    +
    + + +
    +
    + + +
    + + +
    +
    + + + diff --git a/web/templates/wand.html b/web/templates/wand.html index 049120a..99b0267 100644 --- a/web/templates/wand.html +++ b/web/templates/wand.html @@ -49,7 +49,7 @@
    -
    +
    Steuerung
    @@ -61,7 +61,7 @@
    -
    +
    3D-Pfad

    Ziehen dreht · Rad zoomt · Rechtsklick/Shift schwenkt · Doppelklick setzt zurück

    @@ -75,7 +75,7 @@
    -
    +
    Bettnetz

    Noch kein Bettnetz

    @@ -88,16 +88,15 @@