Compare commits

4 Commits

Author SHA1 Message Date
d040475a62 release: v0.9.10 2026-05-17 22:33:07 +02:00
9f6b6a8518 release: v0.9.9 2026-05-14 17:23:05 +02:00
5eda8a241f docs: README.md englisch + README.de.md deutsch mit Sprach-Cross-Link 2026-05-12 19:41:16 +02:00
2aaaa5bbbe README.de.md aktualisiert 2026-05-12 17:20:38 +02:00
7 changed files with 774 additions and 228 deletions

View File

@@ -1,5 +1,37 @@
# Changelog
## [0.9.10] 2026-05-17
> **Hinweis:** Mit diesem Release wird der Fokus von neuen Features auf
> **Stabilisierung und Bugfixing** verlagert. Die Kern-Workflows
> (Multi-Printer, Drucker hinzufügen/entfernen, Filament-Dialog,
> Skip-Objekte, Standalone-Binaries) sind funktional ausreichend — ab jetzt
> steht Robustheit vor neuen Features. Größere Feature-Wünsche (ACE Pro 2,
> Home-Assistant-Integration vervollständigen, …) bleiben vorerst im Backlog.
### Neu
- **Objekte überspringen (vor und während des Drucks):** Aus dem AnycubicSlicerNext-Workbench-Bundle rekonstruiert — der Kobra X kann das nativ über sein Protokoll, der Anycubic-Slicer bietet es bloß nicht im UI an. Die Bridge schon, in zwei Varianten:
- **Vor dem Druck:** beim Starten eines Multi-Object-Drucks aus dem Browser-Tab hat der Filament-Dialog jetzt einen zusätzlichen Abschnitt „Objekte". Einzelne Objekte abwählen (oder Polygon direkt im Build-Plate-SVG anklicken) — sie werden vor dem Druck herausgenommen.
- **Während des Drucks:** neuer ✂-Button im Dashboard (nur während eines laufenden Drucks sichtbar). Öffnet einen Dialog mit derselben interaktiven SVG-Vorschau — Teil anklicken, bestätigen, der Drucker druckt es nicht weiter. Bereits übersprungene Teile bleiben ausgegraut, der Dialog aktualisiert sich live damit man sieht welche schon weg sind.
- **Filament-Dialog farbige Kanal- und Slot-Marker (Issue #23):** Die GCode-Kanal-Nummer sitzt jetzt in einer farbigen Box links (Hintergrund = Kanal-Farbe, Auto-Kontrast-Text statt des alten kleinen Punktes), der zugewiesene AMS-Slot bekommt rechts neben dem Dropdown denselben Look — aktualisiert sich live wenn man die Auswahl ändert. Funktioniert mit 4 Kanälen; das Layout iteriert sauber für mehr, aber >4 echte Filament-Slots brauchen eine ACE-Pro-2-Box und sind ohne entsprechende Hardware nicht durchgängig testbar (geparkt als Feature-Request, Issues #22 und #23).
### Intern
- Neue Helfer `kobrax_client.skip_objects(names)` / `query_skip_objects()`.
- Neue Endpunkte: `GET /kx/files/{id}/objects`, `POST /kx/skip`, `POST /kx/skip/query`, `GET /kx/skip/state`.
- SQLite-Schema: `gcode_files` bekommt die Spalten `objects_skip_parts` und `svg_image` (Auto-Migration auf bestehenden DBs).
- `_on_file` extrahiert die vom Drucker gelieferte Objektliste + SVG-Vorschau und speichert sie pro Datei.
- `_on_skip`-Callback verfolgt, welche Objekte der Drucker aktuell als übersprungen meldet.
---
## [0.9.9] 2026-05-14
### Fixes
- **„Failed to fetch"-Schleife in der UI (Issue #21):** Wenn die Web-UI über die LAN-IP geöffnet wurde, lieferte `/kx/printers` `bridge_url: http://localhost:7125` zurück. Der Browser machte daraufhin Cross-Origin-Requests von der LAN-IP nach `localhost` — die wurden vom Browser blockiert und produzierten eine Flut aus `TypeError: Failed to fetch`-Poll-Fehlern. Die Bridge liefert jetzt im Einzel-Drucker-Modus eine leere `bridge_url`, sodass das Frontend relative Pfade gegen dieselbe Origin wie die UI nutzt. Im Multi-Printer-Modus werden `localhost`/`127.0.0.1` als Bridge-Hosts herausgefiltert.
- **Windows-EXE crasht beim Start (Issue #21):** Die v0.9.8-`kx-bridge.exe` wurde mit einer veralteten `config_loader.py` aus einem früheren Release gebaut und stürzte mit `AttributeError: module 'config_loader' has no attribute 'list_printers'` ab. `release.sh` synct jetzt `config_loader.py` zusammen mit den anderen Quellen ins Windows-Build-Repository.
---
## [0.9.8] 2026-05-12
### Neu

View File

@@ -1,5 +1,37 @@
# Changelog
## [0.9.10] 2026-05-17
> **Heads-up:** with this release the focus shifts from new features to
> **stabilization and bug-fixing**. The core flows (multi-printer, add/remove,
> filament dialog, skip objects, standalone binaries) are feature-complete
> enough — from now on the priority is making them rock-solid before adding
> more on top. Bigger feature requests (ACE Pro 2, Home Assistant integration
> completeness, …) stay on the backlog for now.
### New
- **Skip objects (pre-print and mid-print):** Reverse-engineered from the AnycubicSlicerNext Workbench bundle — the Kobra X actually supports object skipping over its native protocol, but the Anycubic slicer doesn't expose it. The bridge does now, in both flavors:
- **Pre-print:** when starting a multi-object print from the Browser tab, the filament dialog now has an additional "Objects" section. Uncheck individual objects (or click the polygon directly on the build-plate SVG preview) and they're stripped from the print before it starts.
- **Mid-print:** new ✂ button on the dashboard (only visible during an active print). Opens a dialog with the same interactive SVG preview — click a part to mark it for skipping, hit confirm, and the printer drops it from the rest of the run. Already-skipped parts stay greyed out and the dialog refreshes live so you can see which ones are gone.
- **Filament dialog colored channel and slot markers (Issue #23):** the GCode channel number now sits in a colored box on the left (background = channel color, auto-contrast text instead of the old tiny dot), and the assigned AMS slot gets the same treatment on the right of the dropdown — updates live as you change the selection. Plays well with 4 channels; the layout iterates so more channels render correctly, but >4 actual filament slots still need an ACE Pro 2 hub to be testable end-to-end (parked as a feature request, Issues #22 and #23).
### Internal
- New `kobrax_client.skip_objects(names)` / `query_skip_objects()` helpers.
- New endpoints: `GET /kx/files/{id}/objects`, `POST /kx/skip`, `POST /kx/skip/query`, `GET /kx/skip/state`.
- SQLite schema: `gcode_files` gained `objects_skip_parts` and `svg_image` columns (auto-migrates on existing DBs).
- `_on_file` now extracts the printer-provided object list + SVG preview and persists them per file.
- `_on_skip` callback tracks which objects the printer reports as currently skipped.
---
## [0.9.9] 2026-05-14
### Fixes
- **"Failed to fetch" loop in the UI (Issue #21):** When the web UI was opened via the LAN IP, `/kx/printers` was returning `bridge_url: http://localhost:7125`, which caused the browser to fire cross-origin requests from the LAN IP to `localhost` — these were silently blocked, producing a flood of `TypeError: Failed to fetch` poll errors. The bridge now sends an empty `bridge_url` in single-printer mode so the frontend uses relative paths against the same origin as the UI. In multi-printer mode, `localhost`/`127.0.0.1` are filtered out as bridge hosts.
- **Windows EXE startup crash (Issue #21):** The v0.9.8 `kx-bridge.exe` was built with a stale `config_loader.py` from an earlier release and crashed on startup with `AttributeError: module 'config_loader' has no attribute 'list_printers'`. `release.sh` now syncs `config_loader.py` into the Windows build repository together with the other source files.
---
## [0.9.8] 2026-05-12
### New

View File

@@ -1,160 +1,182 @@
<p align="center"><img src="knlogo.png" alt="KX-Bridge Logo" width="180"/></p>
<div align="center">
# KX-Bridge Anycubic Kobra X
<img src="knlogo.png" alt="KX-Bridge" width="160"/>
**Version:** 0.9.8
# KX-Bridge
Steuere deinen **Anycubic Kobra X** mit OrcaSlicer — ohne Klipper, ohne Raspberry Pi.
KX-Bridge ist eine Moonraker-kompatible Bridge die direkt mit dem Drucker kommuniziert.
**Steuere deinen Anycubic Kobra X mit OrcaSlicer — ohne Klipper, ohne Raspberry Pi.**
Eine Moonraker-kompatible Bridge, die direkt mit dem Drucker spricht.
<sub>🇬🇧 <a href="README.md">English version</a></sub>
<br>
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20this%20project-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/viewitde)
&nbsp;
[![Releases](https://img.shields.io/badge/Download-Releases-2EA043?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
&nbsp;
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
<sub>Gefällt dir KX-Bridge? Ein Kaffee auf <a href="https://ko-fi.com/viewitde">Ko-fi</a> hält das Projekt am Leben. ☕</sub>
</div>
---
## Schnellstart in 3 Schritten
## ✨ Was kann KX-Bridge?
### Schritt 1 Drucker vorbereiten
| | Feature |
|---|---|
| 🖨️ | **Druckersteuerung** — Start, Pause, Resume, Abbruch, Temperaturen, Druckgeschwindigkeit |
| 📊 | **Live-Status** — Temperatur, Fortschritt, Layer, Restzeit, Kamera-Stream |
| 🎨 | **AMS / Multicolor** — Filament-Slots, Per-Kanal-Remapping, MMU-Emulation für OrcaSlicer Filament-Sync |
| 🗂️ | **GCode-Browser** — hochgeladene Dateien mit Thumbnail, Druckhistorie, Suche & Filter |
| 🧩 | **Multi-Printer** — mehrere Drucker in **einer** Bridge-Instanz, Umschalten per Dropdown |
| | **Drucker hinzufügen per Klick** — nur die IP eingeben, Zugangsdaten werden automatisch importiert |
| 🔄 | **Self-Update** — neue Versionen direkt im Browser installieren |
| 🌐 | **OrcaSlicer** — volles Moonraker-Protokoll (HTTP + WebSocket), DE/EN UI |
Den Kobra X in den LAN-Modus versetzen:
**Drucker-Display → Einstellungen → LAN-Modus einschalten**
---
### Schritt 2 Credentials holen
## 🚀 Schnellstart
Die MQTT-Zugangsdaten sind druckerspezifisch und an die Hardware gebunden.
### 1. Drucker vorbereiten
**Option A fetch_credentials (empfohlen):**
LAN-Modus am Kobra X aktivieren:
**Drucker-Display → Einstellungen → LAN-Modus aktivieren**
### 2. Bridge starten
**Docker (empfohlen):**
```bash
docker compose up -d
```
**Linux-Binary (kein Docker):**
```bash
chmod +x kx-bridge && ./kx-bridge
```
**Windows-EXE (kein Docker):**
```
kx-bridge.exe
```
> `config\` und `data\` werden neben der EXE angelegt — portabel.
> Bei Linux- und Windows-Binary liegen `config/` und `data/` (Einstellungen, SQLite,
> GCode-Store) jeweils neben dem Programm. Einfach den ganzen Ordner kopieren = umziehen.
**Python direkt:**
```bash
pip install -r bridge/requirements.txt
python bridge/kobrax_moonraker_bridge.py
```
### 3. Drucker einrichten
Web-UI öffnen: **`http://BRIDGE-IP:7125`**
Beim Erststart erscheint der **Drucker-Tab** mit *„+ Drucker hinzufügen"* — einfach die
IP-Adresse des Druckers eingeben, der Rest (Username, Passwort, Device-ID) wird automatisch
vom Drucker geholt und entschlüsselt. Fertig.
> Mehrere Drucker? Einfach mehrfach *„+ Drucker hinzufügen"* — jeder bekommt seinen eigenen
> Port (7125, 7126, …) und ist im Header-Dropdown auswählbar.
### 4. OrcaSlicer verbinden
Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125`
> ⚠️ Verbindungstyp muss **Moonraker** sein (nicht „Bambu" oder „Klipper").
> Vollständige URL inkl. `http://` und Port `:7125` im Host-Feld eintragen.
---
## 📺 Video-Tutorial
[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
---
## 🔧 Zugangsdaten manuell ermitteln
Normalerweise nicht nötig — *„+ Drucker hinzufügen"* macht das automatisch. Falls doch:
```bash
fetch_credentials --ip 192.168.x.x --write-config
```
Holt die Zugangsdaten per HTTP direkt vom Drucker und schreibt sie in `config/config.ini`.
Nur die Drucker-IP nötig, kein Slicer.
Holt die Credentials direkt per HTTP vom Drucker und schreibt sie automatisch in `config/config.ini`. Benötigt nur die Drucker-IP — kein Slicer nötig.
Alternativ (wenn die IP unbekannt ist): AnycubicSlicerNext öffnen, Drucker verbinden,
dann `extract_credentials` ausführen → gibt Username, Passwort, Device-ID und IP aus.
**Option B extract_credentials (wenn Drucker-IP unbekannt):**
> **Downloads:** [Releases](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) → `fetch_credentials` / `extract_credentials` (Linux & Windows)
1. **AnycubicSlicerNext** öffnen und Drucker verbinden (bis Status angezeigt wird)
2. **`extract_credentials`** ausführen — gibt Username, Password, Device-ID und Drucker-IP aus
3. Werte im Web-UI eintragen (⚙-Menü)
---
> **Download:** [gitea.it-drui.de/viewit/KX-Bridge-Release/releases](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) → `fetch_credentials` / `extract_credentials` (Linux & Windows) im jeweiligen Release-Asset
### Schritt 3 Bridge starten
## ⚙️ Nützliche Befehle
```bash
./start.sh
```
Das Skript baut das Docker-Image automatisch beim ersten Aufruf.
**Web-UI öffnen:** `http://BRIDGE-IP:7125`
→ Das ⚙-Menü öffnet sich beim ersten Start automatisch
→ Bei Option B: Credentials aus Schritt 2 eintragen → **Speichern & Neustart**
**OrcaSlicer verbinden:**
Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125`
> **Wichtig:** Verbindungstyp muss **Moonraker** sein (nicht „Bambu" oder „Klipper").
> Im Host-Feld vollständige URL mit `http://` und Port `:7125` angeben.
---
## 📺 Video Tutorial
[![KX-Bridge Setup & Bedienung](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
---
## ⚠️ Update von 0.9.1 oder älter
Ab **0.9.2** speichert KX-Bridge Einstellungen in `config/config.ini` statt in `.env`.
**Migration erfolgt automatisch** — keine manuelle Aktion nötig:
- Beim ersten Start nach dem Update liest die Bridge die vorhandene `.env` und erstellt `config/config.ini` automatisch
- Einstellungen bleiben ab sofort nach `docker-compose restart` und zukünftigen Updates erhalten
- Die `.env`-Datei bleibt read-only gemountet als Migrationsquelle — kann liegen bleiben
- Zum manuellen Anlegen einer `config.ini`: Vorlage unter `config/config.ini.example` kopieren
---
## Was wird unterstützt?
| Funktion | Details |
|----------|---------|
| Druckerstatus | Temperatur, Fortschritt, Zustand, Restzeit |
| Drucksteuerung | Start, Pause, Fortsetzen, Abbrechen |
| Temperaturregelung | Nozzle und Bett während des Drucks |
| Druckgeschwindigkeit | Leise / Normal / Sport |
| AMS-Farbwechsel | Filament einziehen / ausziehen |
| Licht & Lüfter | Drucklicht und Lüfterdrehzahl |
| Web-UI | Dashboard, Achsensteuerung, Kameraansicht |
| Self-Update | Neue Versionen direkt im Browser installieren |
| OrcaSlicer | Moonraker-Protokoll (HTTP + WebSocket) |
---
## Alternativen zu Docker
**Linux Binary** (kein Docker nötig):
```bash
chmod +x kx-bridge
./kx-bridge
```
**Python direkt:**
```bash
pip install aiohttp
python bridge/kobrax_moonraker_bridge.py
```
Web-UI jeweils unter `http://localhost:7125` — ⚙-Menü führt durch die Erstkonfiguration.
---
## Nützliche Befehle
```bash
# Logs anzeigen
docker-compose logs -f
# Bridge stoppen
docker-compose down
# Bridge neu starten (nach Update)
./start.sh
docker compose logs -f # Logs anzeigen
docker compose down # Bridge stoppen
docker compose up -d --build # Bridge neu bauen & starten (nach Update)
```
---
## Fehlerbehebung
## 🩹 Troubleshooting
**„Falsche MQTT-Zugangsdaten"** beim Start:
- `fetch_credentials --ip <Drucker-IP> --write-config` erneut ausführen und Bridge neu starten
- Wenn IP unbekannt: AnycubicSlicerNext neu starten, Drucker verbinden, `extract_credentials` erneut ausführen
- Nur die IP-Adresse ins Feld eintragen, keinen Port (✗ `192.168.1.102:9883` → ✓ `192.168.1.102`)
<details>
<summary><b>"Falsche MQTT-Zugangsdaten" beim Start</b></summary>
**Drucker nicht gefunden / kein LAN-Modus:**
- Am Drucker-Display: Einstellungen → LAN-Modus einschalten
- Drucker über *„+ Drucker hinzufügen"* erneut hinzufügen, oder
`fetch_credentials --ip <ip> --write-config` ausführen und Bridge neu starten
- Nur die IP-Adresse eingeben, ohne Port (✗ `192.168.1.102:9883` → ✓ `192.168.1.102`)
</details>
<details>
<summary><b>Drucker nicht gefunden / kein LAN-Modus</b></summary>
- Am Drucker-Display: Einstellungen → LAN-Modus aktivieren
- Drucker und Bridge müssen im selben Netzwerk sein
</details>
<details>
<summary><b>Docker: Permission denied</b></summary>
**Docker: Permission denied:**
```bash
sudo usermod -aG docker $USER # dann neu einloggen
sudo usermod -aG docker $USER # danach aus- und wieder einloggen
```
</details>
<details>
<summary><b>Upgrade von 0.9.1 oder älter</b></summary>
Ab 0.9.2 speichert KX-Bridge die Einstellungen in `config/config.ini` statt `.env`.
Die Migration läuft automatisch beim ersten Start nach dem Upgrade — keine Aktion nötig.
</details>
---
## Sicherheitshinweise
## 🔒 Sicherheit
- Die Bridge ist im lokalen Netzwerk erreichbar unter `http://<Host-IP>:7125` nicht ins Internet freigeben
- `config/config.ini` enthält Drucker-Credentials — nicht öffentlich teilen
- Credentials haben keinen Zugang zu Anycubic-Cloud-Diensten
- Die Bridge ist im lokalen Netzwerk unter `http://<host-IP>:7125` erreichbar — **nicht** ins Internet exposen
- `config/config.ini` enthält Drucker-Zugangsdaten — nicht öffentlich teilen
- Die Zugangsdaten geben **keinen** Zugriff auf Anycubic-Cloud-Dienste
---
## Lizenz & Rechtliches
## 📄 Lizenz
Interoperabilitätsforschung gem. §69e UrhG — ausschließlich private, nicht-kommerzielle Nutzung.
Interoperabilitätsforschung gem. §69e UrhG — private, nicht-kommerzielle Nutzung.
<p align="center">
<a href="https://ko-fi.com/viewitde">
<img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Ko-fi Support"/>
</a>
</p>
<div align="center">
<br>
**Wenn dir KX-Bridge hilft, freut sich das Projekt über Unterstützung:**
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/viewitde)
</div>

203
README.md
View File

@@ -1,47 +1,94 @@
<p align="center"><img src="knlogo.png" alt="KX-Bridge Logo" width="180"/></p>
<div align="center">
# KX-Bridge Anycubic Kobra X
<img src="knlogo.png" alt="KX-Bridge" width="160"/>
**Version:** 0.9.8
# KX-Bridge
Control your **Anycubic Kobra X** with OrcaSlicer — no Klipper, no Raspberry Pi.
KX-Bridge is a Moonraker-compatible bridge that communicates directly with the printer.
**Control your Anycubic Kobra X with OrcaSlicer — no Klipper, no Raspberry Pi.**
A Moonraker-compatible bridge that talks directly to the printer.
<sub>🇩🇪 <a href="README.de.md">Deutsche Version</a></sub>
<br>
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20this%20project-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/viewitde)
&nbsp;
[![Releases](https://img.shields.io/badge/Download-Releases-2EA043?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
&nbsp;
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
<sub>Like KX-Bridge? A coffee on <a href="https://ko-fi.com/viewitde">Ko-fi</a> keeps the project alive. ☕</sub>
</div>
---
## Quick Start in 3 Steps
## ✨ Features
### Step 1 Prepare the printer
| | |
|---|---|
| 🖨️ | **Printer control** — start, pause, resume, cancel, temperatures, print speed |
| 📊 | **Live status** — temperature, progress, layers, remaining time, camera stream |
| 🎨 | **AMS / multicolor** — filament slots, per-channel remapping, MMU emulation for OrcaSlicer filament sync |
| 🗂️ | **GCode browser** — uploaded files with thumbnails, print history, search & filter |
| 🧩 | **Multi-printer** — multiple printers in **one** bridge instance, switch via dropdown |
| | **Add a printer with one click** — just enter the IP, credentials are imported automatically |
| 🔄 | **Self-update** — install new versions directly in the browser |
| 🌐 | **OrcaSlicer** — full Moonraker protocol (HTTP + WebSocket), EN/DE UI |
---
## 🚀 Quick Start
### 1. Prepare the printer
Enable LAN mode on the Kobra X:
**Printer display → Settings → Enable LAN mode**
### Step 2 Get credentials
The MQTT credentials are printer-specific. Here's how to get them:
1. Open **AnycubicSlicerNext** and connect the printer (wait until status is shown)
2. Run **`extract_credentials.exe`** (Windows) or **`extract_credentials`** (Linux) — outputs Username, Password, Device ID and printer IP
3. Note / copy the values
> **Download:** [gitea.it-drui.de/viewit/KX-Bridge-Release/releases](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) → `extract_credentials.exe` (Windows) / `extract_credentials` (Linux) in the release assets
### Step 3 Start the bridge
### 2. Start the bridge
**Docker (recommended):**
```bash
./start.sh
docker compose up -d
```
The script builds the Docker image automatically on first run.
**Linux binary (no Docker):**
```bash
chmod +x kx-bridge && ./kx-bridge
```
**Open Web-UI:** `http://BRIDGE-IP:7125`
→ The ⚙ menu opens automatically on first start
→ Enter credentials from Step 2 → **Save & Restart**
**Windows EXE (no Docker):**
```
kx-bridge.exe
```
> `config\` and `data\` are created next to the EXE — portable.
> With the Linux and Windows binaries, `config/` and `data/` (settings, SQLite, GCode store)
> live next to the program. Copy the whole folder = move the installation.
**Python directly:**
```bash
pip install -r bridge/requirements.txt
python bridge/kobrax_moonraker_bridge.py
```
### 3. Set up the printer
Open the Web UI: **`http://BRIDGE-IP:7125`**
On first start the **Printers tab** shows *"+ Add printer"* — just enter the printer's IP
address, the rest (username, password, device ID) is fetched from the printer and decrypted
automatically. Done.
> More than one printer? Just click *"+ Add printer"* again — each gets its own port
> (7125, 7126, …) and is selectable from the header dropdown.
### 4. Connect OrcaSlicer
**Connect OrcaSlicer:**
Printer → Connection type **Moonraker** → Host: `http://BRIDGE-IP:7125`
> **Important:** Connection type must be **Moonraker** (not "Bambu" or "Klipper").
> ⚠️ Connection type must be **Moonraker** (not "Bambu" or "Klipper").
> Enter the full URL including `http://` and port `:7125` in the host field.
---
@@ -52,98 +99,84 @@ Printer → Connection type **Moonraker** → Host: `http://BRIDGE-IP:7125`
---
## ⚠️ Upgrading from 0.9.1 or earlier
## 🔧 Getting credentials manually
Starting with **0.9.2**, KX-Bridge stores settings in `config/config.ini` instead of `.env`.
Normally not needed — *"+ Add printer"* does this automatically. If you do need it:
**Migration is automatic** — no manual action required:
- On first start after upgrade, the bridge reads your existing `.env` and creates `config/config.ini` automatically
- Settings now survive `docker-compose restart` and future updates
- The `.env` file stays mounted read-only as a migration source — you can keep it in place
- If you want to create a `config.ini` manually: copy `config/config.ini.example`
---
## What's supported?
| Feature | Details |
|---------|---------|
| Printer status | Temperature, progress, state, remaining time |
| Print control | Start, pause, resume, cancel |
| Temperature control | Nozzle and bed during print |
| Print speed | Silent / Normal / Sport |
| AMS filament change | Load / unload filament |
| Light & fan | Print light and fan speed |
| Web-UI | Dashboard, motion control, camera view |
| Self-update | Install new versions directly in the browser |
| OrcaSlicer | Moonraker protocol (HTTP + WebSocket) |
---
## Alternatives to Docker
**Linux binary** (no Docker needed):
```bash
chmod +x kx-bridge
./kx-bridge
fetch_credentials --ip 192.168.x.x --write-config
```
Fetches the credentials directly from the printer via HTTP and writes them to `config/config.ini`.
Only the printer IP is required, no slicer.
**Python directly:**
```bash
pip install aiohttp
python bridge/kobrax_moonraker_bridge.py
```
Alternatively (if the IP is unknown): open AnycubicSlicerNext, connect the printer, then run
`extract_credentials` → outputs username, password, device ID and the printer IP.
Web-UI available at `http://localhost:7125` — the ⚙ menu guides through initial setup.
> **Downloads:** [Releases](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) → `fetch_credentials` / `extract_credentials` (Linux & Windows)
---
## Useful commands
## ⚙️ Useful commands
```bash
# Show logs
docker-compose logs -f
# Stop bridge
docker-compose down
# Restart bridge (after update)
./start.sh
docker compose logs -f # show logs
docker compose down # stop the bridge
docker compose up -d --build # rebuild & start (after an update)
```
---
## Troubleshooting
## 🩹 Troubleshooting
**"Wrong MQTT credentials"** on start:
- Restart AnycubicSlicerNext, reconnect the printer, run `extract_credentials` again
<details>
<summary><b>"Wrong MQTT credentials" on start</b></summary>
- Re-add the printer via *"+ Add printer"*, or run
`fetch_credentials --ip <ip> --write-config` and restart the bridge
- Enter only the IP address, no port (✗ `192.168.1.102:9883` → ✓ `192.168.1.102`)
</details>
<details>
<summary><b>Printer not found / no LAN mode</b></summary>
**Printer not found / no LAN mode:**
- On the printer display: Settings → Enable LAN mode
- Printer and bridge must be on the same network
</details>
<details>
<summary><b>Docker: Permission denied</b></summary>
**Docker: Permission denied:**
```bash
sudo usermod -aG docker $USER # then log out and back in
```
</details>
<details>
<summary><b>Upgrading from 0.9.1 or earlier</b></summary>
Starting with 0.9.2, KX-Bridge stores settings in `config/config.ini` instead of `.env`.
Migration runs automatically on first start after the upgrade — no action required.
</details>
---
## Security
## 🔒 Security
- The bridge is accessible on the local network at `http://<host-IP>:7125` — do not expose to the internet
- The bridge is reachable on the local network at `http://<host-IP>:7125`**do not** expose it to the internet
- `config/config.ini` contains printer credentials — do not share publicly
- Credentials do not grant access to Anycubic cloud services
- The credentials do **not** grant access to Anycubic cloud services
---
## License
## 📄 License
Interoperability research under §69e UrhG — private, non-commercial use only.
<p align="center">
<a href="https://ko-fi.com/viewitde">
<img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Ko-fi Support"/>
</a>
</p>
<div align="center">
<br>
**If KX-Bridge helps you, the project appreciates your support:**
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/viewitde)
</div>

View File

@@ -1 +1 @@
0.9.8
0.9.10

View File

@@ -458,6 +458,20 @@ class KobraXClient:
def stop_print(self, taskid: str = "-1") -> dict | None:
return self.publish("print", "stop", {"taskid": taskid})
# -- Part-Skip ("Exclude Object") ---------------------------------------
def query_skip_objects(self) -> dict | None:
"""Fragt den Drucker nach der aktuellen Objekt-/Skip-Liste."""
return self.publish("skip", "query_obj")
def skip_objects(self, names: list[str]) -> dict | None:
"""Überspringt die genannten Objekte auch mid-print möglich.
Namen entsprechen den EXCLUDE_OBJECT_DEFINE NAME=… Einträgen
im GCode-Header bzw. file_details.objects_skip_parts.
"""
return self.publish("skip", "start", {"objects_skip_parts": list(names)})
# -- G-Code Upload -------------------------------------------------------
def upload_gcode(self, filepath: str, remote_filename: str | None = None,

View File

@@ -267,7 +267,9 @@ class GCodeStore:
est_print_time_sec INTEGER,
filament_used_mm REAL,
layer_count INTEGER,
gcode_filaments TEXT
gcode_filaments TEXT,
objects_skip_parts TEXT,
svg_image TEXT
);
CREATE TABLE IF NOT EXISTS print_jobs (
id TEXT PRIMARY KEY,
@@ -287,6 +289,13 @@ class GCodeStore:
self._conn.commit()
except Exception:
pass
# Migration: Spalten objects_skip_parts + svg_image (Part-Skip-Feature, v0.9.10)
for col, typ in (("objects_skip_parts", "TEXT"), ("svg_image", "TEXT")):
try:
self._conn.execute(f"ALTER TABLE gcode_files ADD COLUMN {col} {typ}")
self._conn.commit()
except Exception:
pass
def save_file(self, file_id: str, filename: str, data: bytes,
est_time_sec: int = 0, thumbnail_b64: str = "",
@@ -330,6 +339,18 @@ class GCodeStore:
).fetchone()
return dict(row) if row else None
def update_file_objects(self, filename: str, objects: list, svg: str = "") -> None:
"""Speichert Objekt-Liste + optionales SVG zu einer Datei (matcht via filename)."""
if not filename:
return
with self._lock:
self._conn.execute(
"UPDATE gcode_files SET objects_skip_parts=?, svg_image=? "
"WHERE filename=?",
(json.dumps(objects), svg or "", filename),
)
self._conn.commit()
def delete_file(self, file_id: str) -> bool:
row = self.get_file(file_id)
if not row:
@@ -435,6 +456,9 @@ class KobraXBridge:
self._thumbnail_b64: str = ""
# Part-Skip: zuletzt vom Drucker gemeldete Skip-Liste (v0.9.10)
self._skip_state: dict = {"objects": [], "skipped": [], "ts": 0}
# Register MQTT push callbacks
client.callbacks["tempature/report"] = self._on_temp
client.callbacks["print/report"] = self._on_print
@@ -442,6 +466,7 @@ class KobraXBridge:
client.callbacks["file/report"] = self._on_file
client.callbacks["multiColorBox/report"] = self._on_multicolor_box
client.callbacks["light/report"] = self._on_light
client.callbacks["skip/report"] = self._on_skip
# -------------------------------------------------------------------------
# MQTT callbacks (called from reader thread)
@@ -539,6 +564,23 @@ class KobraXBridge:
self._state["print_speed_mode"] = int(speed_mode)
self._push_status_update()
def _on_skip(self, payload: dict):
"""skip/report-Callback (Part-Skip-Feature, v0.9.10).
Drucker meldet hier IMMER die Liste der bereits geskippten Objekte
zurück (objects_skip_parts), egal ob auf query_obj oder nach skip/start.
Die Gesamt-Objektliste kommt aus file/report.
"""
d = payload.get("data") or {}
skipped = d.get("objects_skip_parts") or d.get("skipped") or d.get("skipped_parts") or []
# Liste immer (auch leer) übernehmen sonst bleibt sie auf alten Stand
self._skip_state = {
"skipped": list(skipped),
"ts": int(time.time()),
}
if payload.get("state") == "done" or payload.get("code") == 200:
log.info(f"Skip-Antwort: state={payload.get('state')} code={payload.get('code')} skipped={skipped}")
def _on_file(self, payload: dict):
d = payload.get("data") or {}
details = d.get("file_details") or {}
@@ -546,6 +588,17 @@ class KobraXBridge:
if thumb:
self._thumbnail_b64 = thumb
log.info(f"Vorschaubild empfangen: {len(thumb)} Zeichen base64")
# Part-Skip: Objekt-Liste + optionales SVG (v0.9.10)
objs = details.get("objects_skip_parts") or []
svg = details.get("svg_image") or ""
if objs:
filename = d.get("filename") or details.get("filename") or self._last_uploaded_file
if filename:
try:
self._store.update_file_objects(filename, objs, svg)
log.info(f"Skip-Objekte für {filename}: {len(objs)} ({'mit SVG' if svg else 'ohne SVG'})")
except Exception as e:
log.warning(f"update_file_objects fehlgeschlagen: {e}")
self._push_status_update()
def _on_multicolor_box(self, payload: dict):
@@ -805,17 +858,112 @@ class KobraXBridge:
jobs = self._store.list_jobs(limit=limit, offset=offset)
return self._json_cors({"result": jobs})
async def handle_kx_file_objects(self, request):
"""Liefert die Objekt-Liste + optionales SVG für eine Datei.
GET /kx/files/{id}/objects → {"names": [...], "svg_b64": "..."}
Wenn Datei noch keine Objekte hat (alter Eintrag): file/fileDetails
beim Drucker abfragen und Antwort abwarten ist Aufgabe des Frontends
(Reload nach Upload). Hier nur Datenbankstand zurückgeben.
"""
fid = request.match_info.get("id", "")
f = self._store.get_file(fid)
if not f:
return self._json_cors({"error": "file not found"}, status=404)
try:
names = json.loads(f.get("objects_skip_parts") or "[]")
except Exception:
names = []
return self._json_cors({
"result": {
"names": names,
"svg_b64": f.get("svg_image") or "",
}
})
async def handle_kx_skip(self, request):
"""Mid-Print Skip auslösen.
POST /kx/skip body={"names": ["..", ".."]}
"""
try:
body = await request.json()
except Exception:
return self._json_cors({"error": "invalid json"}, status=400)
names = body.get("names") or []
if not isinstance(names, list) or not all(isinstance(n, str) for n in names):
return self._json_cors({"error": "names must be list[str]"}, status=400)
try:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: self.client.skip_objects(names))
except Exception as e:
return self._json_cors({"error": str(e)}, status=502)
return self._json_cors({"result": "ok", "names": names})
async def handle_kx_skip_query(self, request):
"""Druck-Objektliste vom Drucker neu abfragen.
POST /kx/skip/query → triggert skip/query_obj, gibt zuletzt bekannten
Stand zurück (skip/report kommt async, Frontend pollt /kx/skip/state).
"""
try:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: self.client.query_skip_objects())
except Exception as e:
return self._json_cors({"error": str(e)}, status=502)
return self._json_cors({"result": self._skip_state})
async def handle_kx_skip_state(self, request):
"""Aktueller Skip-State.
Kombiniert:
- Gesamt-Objektliste: aus dem GCode-Store, gematcht über den aktuell
laufenden filename (file/report beim Druckstart hat die Liste gefüllt).
skip/query_obj liefert nämlich NUR die bereits geskippten zurück,
nicht die Gesamtliste.
- Geskippt: aus self._skip_state (von skip/report aktualisiert).
"""
filename = self._state.get("filename", "")
all_objects: list[str] = []
svg = ""
if filename:
try:
f = self._store.get_file_by_name(filename)
if f:
all_objects = json.loads(f.get("objects_skip_parts") or "[]")
svg = f.get("svg_image") or ""
except Exception as e:
log.warning(f"skip_state lookup failed: {e}")
result = {
"objects": all_objects,
"skipped": list(self._skip_state.get("skipped", [])),
"svg_b64": svg,
"ts": self._skip_state.get("ts", 0),
"filename": filename,
}
return self._json_cors({"result": result})
async def handle_kx_printers(self, request):
# Aktive Drucker (mit IP) sammeln
active = [(pid, br) for pid, br in self._all_bridges.items()
if (br._args.printer_ip or "").strip()]
# Host für bridge_url: Browser-Sicht beibehalten, aber niemals "localhost" exportieren
# sonst scheitern Fetches aus dem Browser, wenn die UI über die LAN-IP geöffnet ist.
host = request.host.split(":")[0]
if host in ("localhost", "127.0.0.1", "::1", "0.0.0.0"):
host = ""
out = []
for pid, br in self._all_bridges.items():
if not (br._args.printer_ip or "").strip():
continue # "leerer" Drucker (kein IP) nicht in der Liste anzeigen
for pid, br in active:
port = getattr(br._args, "port", 7125)
# Nur bei Multi-Printer eine konkrete bridge_url setzen (Cross-Instance-Fetch).
# Single-Printer: leere bridge_url → JS nutzt relative Pfade (gleiche Origin wie UI).
bridge_url = ""
if len(active) > 1 and host:
bridge_url = f"http://{host}:{port}"
out.append({
"id": pid,
"name": br._state.get("printer_name") or f"Drucker {pid}",
"bridge_url": f"http://{host}:{port}",
"bridge_url": bridge_url,
"printer_ip": br._args.printer_ip,
"device_id": br._args.device_id or "",
})
@@ -838,6 +986,10 @@ class KobraXBridge:
# filament_assignments: [{slot_index, material, color_hex}, …]
assignments = body.get("filament_assignments")
# excluded_objects: ["name1","name2",...] Pre-Print Skip (v0.9.10)
excluded_objects = body.get("excluded_objects") or []
if not isinstance(excluded_objects, list):
excluded_objects = []
if assignments:
ams_box_mapping = [
@@ -902,11 +1054,11 @@ class KobraXBridge:
"ai_settings": {"status": 0, "count": 0, "type": 1},
"timelapse": {"status": 0, "count": 0, "type": 64},
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
"model_objects_skip_parts": [],
"model_objects_skip_parts": excluded_objects,
},
}
log.info(f"KX-Store Druckstart: {filename} ams={len(ams_box_mapping)} slots assignments={bool(assignments)}")
log.info(f"KX-Store Druckstart: {filename} ams={len(ams_box_mapping)} slots assignments={bool(assignments)} excluded={len(excluded_objects)}")
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None, lambda: self.client.publish("print", "start", payload, timeout=15.0)
@@ -1200,6 +1352,10 @@ class KobraXBridge:
# Optionale Slot-Auswahl aus dem Filament-Dialog
filament_assignments = body.get("filament_assignments")
# Pre-Print Skip (v0.9.10)
excluded_objects = body.get("excluded_objects") or []
if not isinstance(excluded_objects, list):
excluded_objects = []
if filament_assignments is not None:
ams_box_mapping = [
{
@@ -1261,7 +1417,7 @@ class KobraXBridge:
"ai_settings": {"status": 0, "count": 0, "type": 0},
"timelapse": {"status": 0, "count": 0, "type": 0},
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
"model_objects_skip_parts": [],
"model_objects_skip_parts": excluded_objects,
},
}
@@ -1413,6 +1569,7 @@ main{flex:1;overflow-y:auto;padding:20px}
.btn-start{background:var(--ok);color:#0d2010}
.btn-pause{background:var(--raised);color:var(--txt);border:1px solid var(--border)}
.btn-resume{background:#0d2d1a;color:var(--ok);border:1px solid var(--ok)}
.btn-skip{background:var(--raised);color:var(--warn);border:1px solid var(--warn)}
.btn-cancel{background:#2d0d0d;color:var(--err);border:1px solid var(--err)}
.btn-accent{background:var(--accent);color:#001a24}
.btn-sm{padding:7px 12px;font-size:12px}
@@ -1807,6 +1964,7 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<div class="ctrl-btns" id="d-ctrl-btns" style="margin-top:12px">
<button class="btn btn-pause btn-sm" id="d-btn-pause" onclick="printAction('pause')">⏸ Pause</button>
<button class="btn btn-resume btn-sm" id="d-btn-resume" onclick="printAction('resume')">▶ Weiter</button>
<button class="btn btn-skip btn-sm" id="d-btn-skip" onclick="openSkipDialog()" style="display:none">✂ <span id="d-btn-skip-label">Objekte</span></button>
<button class="btn btn-cancel btn-sm" id="d-btn-cancel" onclick="confirmCancel()">✕ Stopp</button>
</div>
</div>
@@ -2091,6 +2249,11 @@ var LANG_DE={
file_slots_btn:'🎨 Slots wählen',
file_cancel_btn:'✕ Abbrechen',
nav_printers:'Drucker',
skip_title:'✂ Objekte überspringen',skip_hint:'Objekte abwählen, die nicht weiter gedruckt werden sollen:',
skip_btn_label:'Objekte',skip_no_objects:'Keine Objekte in diesem Druck.',
skip_already:'übersprungen',skip_select_at_least_one:'Bitte mindestens ein Objekt wählen.',
skip_sending:'Sende …',skip_success:'Objekte werden übersprungen.',
fd_objects_hint:'Objekte überspringen (optional):',
add_printer:'Drucker hinzufügen',apd_lbl_ip:'Drucker-IP',apd_lbl_name:'Name (optional)',
apd_fetching:'Hole Daten vom Drucker…',apd_success:'Drucker hinzugefügt, Bridge startet neu…',apd_err_ip:'Bitte IP-Adresse eingeben',
printers_remove:'Drucker entfernen',printers_remove_confirm:'Drucker "{name}" entfernen? Die Bridge startet neu.',
@@ -2147,6 +2310,11 @@ var LANG_EN={
file_slots_btn:'🎨 Select Slots',
file_cancel_btn:'✕ Cancel',
nav_printers:'Printers',
skip_title:'✂ Skip objects',skip_hint:'Uncheck objects you no longer want to print:',
skip_btn_label:'Objects',skip_no_objects:'No objects in this print.',
skip_already:'skipped',skip_select_at_least_one:'Please pick at least one object.',
skip_sending:'Sending …',skip_success:'Objects will be skipped.',
fd_objects_hint:'Skip objects (optional):',
add_printer:'Add printer',apd_lbl_ip:'Printer IP',apd_lbl_name:'Name (optional)',
apd_fetching:'Fetching data from printer…',apd_success:'Printer added, bridge restarting…',apd_err_ip:'Please enter an IP address',
printers_remove:'Remove printer',printers_remove_confirm:'Remove printer "{name}"? The bridge will restart.',
@@ -2252,6 +2420,10 @@ function applyLang(){
setText('printers-panel-title','🖨 '+T.nav_printers);
setText('add-printer-btn-label',T.add_printer);
setText('apd-title',T.add_printer);
setText('skip-title',T.skip_title);
setText('skip-hint',T.skip_hint);
setText('d-btn-skip-label',T.skip_btn_label);
setText('fd-objects-hint',T.fd_objects_hint);
setText('apd-lbl-ip',T.apd_lbl_ip);
setText('apd-lbl-name',T.apd_lbl_name);
setText('store-panel-title','🗂 '+T.panel_browser_title);
@@ -2474,6 +2646,13 @@ function applyState(){
frb.style.display='flex';
}else{frb.style.display='none';}
}
// skip-button (mid-print) nur sichtbar wenn aktuell gedruckt wird
var skipBtn=document.getElementById('d-btn-skip');
if(skipBtn){
var printing=(s.print_state==='printing'||s.print_state==='paused');
skipBtn.style.display=printing?'':'none';
}
// header
var b=document.getElementById('h-badge');
b.className='hbadge '+s.print_state;
@@ -3096,6 +3275,32 @@ function startReadyFileWithSlots(){
}
var _amsSlots=[];
var _printObjects=[]; // [{name, skip}] für aktuell offenen Dialog
var _printObjectsSvg=''; // base64-SVG aus DB für Visualisierung
// Hilfsfunktionen für farbige Kanal/Slot-Marker (Issue #23)
function _contrastText(hex){
// Helle Farbe → dunkler Text, dunkle Farbe → heller Text
var c=(hex||'').replace('#','');
if(c.length===3)c=c[0]+c[0]+c[1]+c[1]+c[2]+c[2];
if(c.length<6)return '#fff';
var r=parseInt(c.slice(0,2),16),g=parseInt(c.slice(2,4),16),b=parseInt(c.slice(4,6),16);
// YIQ-Helligkeit
var y=(r*299 + g*587 + b*114)/1000;
return y>=140?'#111':'#fff';
}
function _updateSlotMarker(sel){
var opt=sel.options[sel.selectedIndex];
var color=opt&&opt.dataset.color?opt.dataset.color:'#888';
var slotIdx=parseInt(opt.value);
var paintIdx=sel.dataset.paint;
var marker=document.querySelector('.fd-slot-marker[data-for-paint="'+paintIdx+'"]');
if(marker){
marker.style.background=color;
marker.style.color=_contrastText(color);
marker.textContent=(slotIdx+1);
}
}
function openFilamentDialog(slots){
_amsSlots=slots.filter(function(s){return s.status==='loaded';});
@@ -3103,6 +3308,24 @@ function openFilamentDialog(slots){
var title=document.getElementById('fd-title');
var body=document.getElementById('fd-slots');
if(title)title.textContent=''+_storeFilename;
// Objekt-Liste laden (nur Store-Modus: per File-ID; Banner-Modus hat keine ID)
_printObjects=[];
_printObjectsSvg='';
var objSection=document.getElementById('fd-objects-section');
if(objSection)objSection.style.display='none';
if(_storeFileId){
fetch(_apiUrl('/kx/files/'+encodeURIComponent(_storeFileId)+'/objects'))
.then(function(r){return r.json()})
.then(function(d){
var names=(d.result&&d.result.names)||[];
_printObjectsSvg=(d.result&&d.result.svg_b64)||'';
if(names.length>=2){
_printObjects=names.map(function(n){return {name:n,skip:false};});
renderObjectChecklist(); renderObjectSvg();
if(objSection)objSection.style.display='block';
}
}).catch(function(){});
}
// GCode-Kanäle: bevorzugt aus _gcodeFilaments, sonst aus belegten AMS-Slots ableiten
var channels=_gcodeFilaments.length?_gcodeFilaments:_amsSlots.map(function(s,i){
@@ -3121,14 +3344,18 @@ function openFilamentDialog(slots){
var opts=compatible.map(function(s){
var sel=(s.slot_index===defaultSlot.slot_index)?'selected':'';
return '<option value="'+s.slot_index+'" data-color="'+s.color_hex+'" data-material="'+s.material+'" '+sel+'>'+
'Slot '+(s.slot_index+1)+' · '+s.material+'</option>';
'Slot '+(s.slot_index+1)+' · '+s.material+'</option>';
}).join('');
return '<div style="display:flex;align-items:center;gap:10px;padding:8px;border-radius:6px;background:var(--raised);border:1px solid var(--border)">'+
'<span style="display:inline-block;width:16px;height:16px;border-radius:50%;background:'+gc.color_hex+';border:1px solid var(--border);flex-shrink:0"></span>'+
'<span style="font-size:13px;font-weight:600;min-width:70px">Kanal '+(i+1)+'</span>'+
'<span style="font-size:11px;color:var(--txt2);min-width:32px">'+gc.material+'</span>'+
'<span style="font-size:18px;color:var(--txt2)">→</span>'+
'<select data-paint="'+i+'" data-paint-color="'+gc.color_hex+'" style="flex:1;padding:4px 6px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt);font-size:13px">'+
// Kanal-Box (links): farbige Box mit Nummer + auto Kontrast-Text
var txt=_contrastText(gc.color_hex);
var slotColor=defaultSlot?defaultSlot.color_hex:'#888';
var slotTxt=_contrastText(slotColor);
return '<div style="display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;background:var(--raised);border:1px solid var(--border)">'+
'<span style="display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;background:'+gc.color_hex+';color:'+txt+';font-weight:700;font-size:13px;border:1px solid var(--border);flex-shrink:0">'+(i+1)+'</span>'+
'<span style="font-size:11px;color:var(--txt2);min-width:36px">'+gc.material+'</span>'+
'<span style="font-size:16px;color:var(--txt2)">→</span>'+
'<span class="fd-slot-marker" data-for-paint="'+i+'" style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:5px;background:'+slotColor+';color:'+slotTxt+';font-weight:700;font-size:12px;border:1px solid var(--border);flex-shrink:0">'+(defaultSlot?defaultSlot.slot_index+1:'?')+'</span>'+
'<select data-paint="'+i+'" data-paint-color="'+gc.color_hex+'" onchange="_updateSlotMarker(this)" style="flex:1;min-width:0;padding:4px 6px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt);font-size:12px">'+
opts+'</select>'+
'</div>';
}).join('');
@@ -3164,12 +3391,14 @@ function confirmFilamentPrint(){
ams_color: hexToRgba(amsSlot.color_hex||'#ffffff'),
});
});
// Pre-Print Skip: Namen der abgehakten Objekte sammeln
var excludedObjects=_printObjects.filter(function(o){return o.skip;}).map(function(o){return o.name;});
closeFilamentDialog();
if(_filamentDialogMode==='banner'){
// Banner-Modus: normaler print/start mit Slot-Override
var btn=document.getElementById('file-ready-btn');
if(btn){btn.disabled=true;btn.textContent='';}
post('/printer/print/start',{filename:S.file_ready,filament_assignments:assignments})
post('/printer/print/start',{filename:S.file_ready,filament_assignments:assignments,excluded_objects:excludedObjects})
.then(function(r){return r.json();})
.then(function(){
document.getElementById('file-ready-banner').style.display='none';
@@ -3184,7 +3413,7 @@ function confirmFilamentPrint(){
fetch(_apiUrl('/kx/print'),{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({file_id:_storeFileId,filament_assignments:assignments})
body:JSON.stringify({file_id:_storeFileId,filament_assignments:assignments,excluded_objects:excludedObjects})
}).then(function(r){return r.json()}).then(function(d){
if(d.result==='ok'){clog('Druckstart: '+_storeFilename,'msg-ok');showPanel('dashboard');}
else{clog('Druckfehler: '+(d.error||'?'),'msg-err');}
@@ -3192,6 +3421,164 @@ function confirmFilamentPrint(){
}
}
function renderObjectChecklist(){
var box=document.getElementById('fd-objects');
if(!box)return;
box.innerHTML=_printObjects.map(function(o,i){
var label=o.name;
// Klipper-Namen sind oft "Datei.stl_id_N_copy_M" → schöner darstellen
var m=label.match(/^(.+)\.stl_id_(\d+)_copy_(\d+)$/);
if(m)label=m[1]+' #'+(parseInt(m[2])+1)+(m[3]!=='0'?' ('+(parseInt(m[3])+1)+')':'');
return '<label style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:var(--raised);border:1px solid var(--border);cursor:pointer;font-size:12px">'+
'<input type="checkbox" data-idx="'+i+'" '+(o.skip?'checked':'')+' onchange="_toggleObjectSkip('+i+',this.checked)">'+
'<span style="word-break:break-all">'+label+'</span>'+
'</label>';
}).join('');
}
function _toggleObjectSkip(idx,val){
if(_printObjects[idx])_printObjects[idx].skip=!!val;
renderObjectSvg();
}
function renderObjectSvg(){
var box=document.getElementById('fd-objects-svg');
if(!box)return;
if(!_printObjectsSvg||!_printObjects.length){box.style.display='none';box.innerHTML='';return;}
box.style.display='block';
var svg=''; try{ svg=atob(_printObjectsSvg);}catch(e){ box.style.display='none'; return; }
box.innerHTML=svg;
var svgEl=box.querySelector('svg');
if(!svgEl)return;
svgEl.style.width='100%'; svgEl.style.maxHeight='200px'; svgEl.style.height='auto';
_printObjects.forEach(function(o,i){
var g=svgEl.querySelector('g[id="'+CSS.escape(o.name)+'"]');
if(!g)return;
var path=g.querySelector('path');
g.style.cursor='pointer';
g.setAttribute('opacity', o.skip?'0.8':'0.35');
if(path){
path.setAttribute('fill', o.skip?'#ff5e5b':'#5fa7ff');
path.setAttribute('fill-opacity', o.skip?'0.4':'0.18');
}
g.onclick=function(){
_printObjects[i].skip=!_printObjects[i].skip;
renderObjectChecklist(); renderObjectSvg();
};
});
}
// ── Mid-Print Skip ──
var _skipObjects=[]; // [{name, skipped, willSkip}]
var _skipSvg='';
function openSkipDialog(){
document.getElementById('skip-status').textContent='';
document.getElementById('skip-confirm').disabled=false;
_refreshSkipDialog();
document.getElementById('skip-dialog').classList.add('open');
}
function _refreshSkipDialog(){
// Erst aktueller State (mit DB-Objects + svg), dann query_obj für frischen skipped
fetch(_apiUrl('/kx/skip/state')).then(function(r){return r.json()}).then(function(d){
var s=d.result||{};
_skipSvg=s.svg_b64||'';
_skipObjects=(s.objects||[]).map(function(n){
return {name:n, skipped:(s.skipped||[]).indexOf(n)>=0, willSkip:false};
});
renderSkipList(); renderSkipSvg();
});
// Frisch nachfragen (skipped-Liste aktualisieren)
fetch(_apiUrl('/kx/skip/query'),{method:'POST'}).then(function(r){return r.json()}).then(function(){
setTimeout(function(){
fetch(_apiUrl('/kx/skip/state')).then(function(r){return r.json()}).then(function(d){
var s=d.result||{};
var skipped=s.skipped||[];
_skipObjects.forEach(function(o){ o.skipped=skipped.indexOf(o.name)>=0; if(o.skipped)o.willSkip=false; });
renderSkipList(); renderSkipSvg();
});
}, 500);
}).catch(function(){});
}
function closeSkipDialog(){
document.getElementById('skip-dialog').classList.remove('open');
}
function _shortLabel(name){
var m=name.match(/^(.+)\.[sS][tT][lL]_id_(\d+)_copy_(\d+)$/);
if(!m)return name;
return m[1]+' #'+(parseInt(m[2])+1)+(m[3]!=='0'?' ('+(parseInt(m[3])+1)+')':'');
}
function renderSkipList(){
var box=document.getElementById('skip-list');
if(!box)return;
if(!_skipObjects.length){
box.innerHTML='<div style="color:var(--txt2);font-size:12px;padding:12px;text-align:center">'+(T.skip_no_objects||'Keine Objekte in diesem Druck.')+'</div>';
return;
}
box.innerHTML=_skipObjects.map(function(o,i){
var label=_shortLabel(o.name);
var dis=o.skipped?'disabled':'';
var note=o.skipped?'<span style="font-size:11px;color:var(--warn);margin-left:auto">'+(T.skip_already||'übersprungen')+'</span>':'';
return '<label style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:var(--raised);border:1px solid var(--border);font-size:12px;'+(o.skipped?'opacity:0.5':'')+'">'+
'<input type="checkbox" data-idx="'+i+'" '+(o.willSkip?'checked':'')+' '+dis+' onchange="_toggleWillSkip('+i+',this.checked)">'+
'<span style="word-break:break-all">'+label+'</span>'+note+
'</label>';
}).join('');
}
function renderSkipSvg(){
var box=document.getElementById('skip-svg');
if(!box)return;
if(!_skipSvg||!_skipObjects.length){box.style.display='none';box.innerHTML='';return;}
box.style.display='block';
// SVG aus base64 dekodieren
var svg='';
try{ svg=atob(_skipSvg); }catch(e){ box.style.display='none'; return; }
box.innerHTML=svg;
// Polygone interaktiv machen: jeder <g id="..."> entspricht einem Objekt
var svgEl=box.querySelector('svg');
if(!svgEl)return;
svgEl.style.width='100%'; svgEl.style.maxHeight='280px'; svgEl.style.height='auto';
_skipObjects.forEach(function(o,i){
var g=svgEl.querySelector('g[id="'+CSS.escape(o.name)+'"]');
if(!g)return;
var path=g.querySelector('path');
if(o.skipped){
// bereits übersprungen → ausgegraut, kein Klick
g.setAttribute('opacity','0.25');
if(path){path.setAttribute('fill','#888');path.setAttribute('fill-opacity','0.3');}
g.style.cursor='not-allowed';
} else {
g.style.cursor='pointer';
g.setAttribute('opacity', o.willSkip?'0.8':'0.35');
if(path){
path.setAttribute('fill', o.willSkip?'#ff5e5b':'#5fa7ff');
path.setAttribute('fill-opacity', o.willSkip?'0.4':'0.18');
}
g.onclick=function(){
_skipObjects[i].willSkip=!_skipObjects[i].willSkip;
renderSkipList(); renderSkipSvg();
};
}
});
}
function _toggleWillSkip(idx,val){
if(_skipObjects[idx])_skipObjects[idx].willSkip=!!val;
renderSkipSvg();
}
function confirmSkip(){
var names=_skipObjects.filter(function(o){return o.willSkip;}).map(function(o){return o.name;});
var st=document.getElementById('skip-status');
var btn=document.getElementById('skip-confirm');
if(!names.length){st.textContent=T.skip_select_at_least_one||'Bitte mindestens ein Objekt wählen.';st.style.color='var(--warn)';return;}
btn.disabled=true; st.textContent=T.skip_sending||'Sende …'; st.style.color='var(--txt2)';
fetch(_apiUrl('/kx/skip'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({names:names})})
.then(function(r){return r.json().then(function(j){return {ok:r.ok,j:j};});})
.then(function(res){
if(!res.ok){st.textContent=(res.j&&res.j.error)||'Fehler';st.style.color='var(--err)';btn.disabled=false;return;}
st.textContent=T.skip_success||'Objekte werden übersprungen.';st.style.color='var(--ok)';
// Dialog offen lassen + neu laden damit der "übersprungen"-Status erscheint
setTimeout(function(){ _refreshSkipDialog(); btn.disabled=false; st.textContent=''; }, 1500);
})
.catch(function(e){st.textContent=''+e;st.style.color='var(--err)';btn.disabled=false;});
}
function storeDelete(fileId){
if(!confirm(T.store_delete_confirm)) return;
fetch(_apiUrl('/kx/files/'+fileId),{method:'DELETE'}).then(function(r){
@@ -3312,11 +3699,16 @@ function loadPrinterTab(){
<span class="modal-title" id="fd-title" style="font-size:14px;word-break:break-all"></span>
<button onclick="closeFilamentDialog()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--txt2)">✕</button>
</div>
<p style="font-size:12px;color:var(--txt2);margin-bottom:10px">GCode-Kanal → AMS-Slot zuweisen:</p>
<p id="fd-slots-hint" style="font-size:12px;color:var(--txt2);margin-bottom:10px">GCode-Kanal → AMS-Slot zuweisen:</p>
<div id="fd-slots" style="display:flex;flex-direction:column;gap:8px;margin-bottom:16px"></div>
<div id="fd-objects-section" style="display:none;margin-bottom:16px">
<p id="fd-objects-hint" style="font-size:12px;color:var(--txt2);margin-bottom:8px">Objekte überspringen (optional):</p>
<div id="fd-objects-svg" style="display:none;background:var(--raised);border:1px solid var(--border);border-radius:8px;padding:6px;margin-bottom:8px;text-align:center"></div>
<div id="fd-objects" style="display:flex;flex-direction:column;gap:6px;max-height:140px;overflow-y:auto"></div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="closeFilamentDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>
<button onclick="confirmFilamentPrint()" style="padding:8px 18px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">▶ Drucken</button>
<button id="fd-cancel" onclick="closeFilamentDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>
<button id="fd-print" onclick="confirmFilamentPrint()" style="padding:8px 18px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">▶ Drucken</button>
</div>
</div>
</div>
@@ -3338,6 +3730,23 @@ function loadPrinterTab(){
</div>
</div>
</div>
<!-- Mid-Print Skip-Dialog -->
<div class="modal-overlay" id="skip-dialog" onclick="if(event.target===this)closeSkipDialog()">
<div class="modal-box" style="max-width:420px;width:100%">
<div class="modal-header" style="margin-bottom:14px">
<span class="modal-title" id="skip-title">✂ Objekte überspringen</span>
<button onclick="closeSkipDialog()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--txt2)">✕</button>
</div>
<p id="skip-hint" style="font-size:12px;color:var(--txt2);margin-bottom:10px">Objekte abwählen, die nicht weiter gedruckt werden sollen:</p>
<div id="skip-svg" style="display:none;background:var(--raised);border:1px solid var(--border);border-radius:8px;padding:6px;margin-bottom:10px;text-align:center"></div>
<div id="skip-list" style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow-y:auto;margin-bottom:12px"></div>
<div id="skip-status" style="font-size:12px;color:var(--txt2);min-height:16px;margin-bottom:8px"></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="closeSkipDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>
<button id="skip-confirm" onclick="confirmSkip()" style="padding:8px 18px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">Überspringen</button>
</div>
</div>
</div>
<footer style="text-align:center;padding:12px;font-size:11px;color:var(--txt2);border-top:1px solid var(--border);margin-top:auto">
&copy; ViewIT 2026
@@ -4425,6 +4834,10 @@ def build_app(bridge: KobraXBridge) -> web.Application:
r.add_delete("/kx/files/{file_id}", bridge.handle_kx_file_delete)
r.add_get("/kx/filament/slots", bridge.handle_kx_filament_slots)
r.add_get("/kx/history", bridge.handle_kx_history)
r.add_get("/kx/files/{id}/objects", bridge.handle_kx_file_objects)
r.add_post("/kx/skip", bridge.handle_kx_skip)
r.add_post("/kx/skip/query", bridge.handle_kx_skip_query)
r.add_get("/kx/skip/state", bridge.handle_kx_skip_state)
r.add_route("OPTIONS", "/kx/{path:.*}", bridge.handle_kx_options)
# Root + Printer-Routen (Single-Page, JS liest Pathname)