13 Commits

12 changed files with 581 additions and 300 deletions

166
CHANGELOG.md Normal file
View File

@@ -0,0 +1,166 @@
# Changelog
## [0.9.1-beta15] 2026-04-26
### Fixes
- AMS: Leere Slots werden beim Druckstart übersprungen kein `filament runout` mehr bei unbelegten Kanälen (Issue #5)
- AMS: Material-Typ wird jetzt korrekt aus dem Drucker-Protokoll gelesen (Feld `type` statt `material_type`)
- AMS UI: Leere Slots werden grau und transparent dargestellt mit „Leer"-Label
---
## [0.9.1-beta14] 2026-04-26
### Fixes
- Z-Achse: ▲ fährt jetzt aufwärts (Z+), ▼ abwärts (Z) Pfeile waren vertauscht (Issue #4)
- Home All: korrekter axis-Code 5 homed alle Achsen XYZ (Issue #4)
- Neuer Button „Home XY" (axis=4) in der UI
- Neuer Button „Motors Off" (axis turnOff) in der UI
---
## [0.9.1-beta13] 2026-04-26
### Fixes (Windows)
- Self-Update / Settings-Neustart: `os.execv` funktioniert jetzt korrekt in der PyInstaller-Binary (kein doppelter Pfad als Argument mehr)
- Kamera: `ffmpeg nicht gefunden` crasht nicht mehr saubere 503-Antwort wenn ffmpeg nicht installiert ist
- Reconnect-Loop: Kurzeitige leere TCP-Reads unter Windows führen nicht mehr sofort zu Reconnects
### Struktur
- `bridge/`: Bridge-Dateien aus `05_scripts/` herausgelöst
- `tools/`: `extract_credentials.py` als eigenständiges Tool mit eigenem README
- `_archive/`: RE-Forschungsordner, Analyse-Tools und alte Release-Checksums archiviert
- README komplett neu: klarer 3-Schritte-Schnellstart
---
## [0.9.1-beta12] 2026-04-25
### Fixes
- Fehlermeldung bei falschen MQTT-Zugangsdaten ist jetzt verständlich: `Falsche MQTT-Zugangsdaten (falscher Benutzername, Passwort oder Device-ID)` statt kryptischem `CONNACK failed: 20020005`
---
## [0.9.1-beta11] 2026-04-25
### Fixes
- Drucker-IP wird automatisch bereinigt wenn der Nutzer versehentlich den Port miteingibt (z.B. `192.168.1.102:9883``192.168.1.102`)
- Settings-Modal: Hinweis erscheint wenn ein `:` in der IP erkannt wird
- `docker-compose.yml`: `.env` wird als Volume in den Container gemountet Einstellungen bleiben nach `docker-compose restart` erhalten
---
## [0.9.1-beta10] 2026-04-25
### Neu
- `start.sh` startet die Bridge per Docker, baut das Image automatisch beim ersten Aufruf
- Tests: pytest-Suite (19 Tests) für API-State, Moonraker-Endpunkte, Settings; Installations-Smoke-Test (`test_install.sh`)
- Settings-Modal öffnet sich beim ersten Start automatisch wenn keine Zugangsdaten hinterlegt sind
### Geändert
- README (DE + EN): Schnellstart zeigt jetzt `./start.sh` statt manuellem `docker build`
- README: LAN-Modus korrekt als Drucker-Menüoption beschrieben (kein WLAN-Bezug)
- README: Versionsnummer wird ab jetzt automatisch bei jedem Release aktualisiert
- `extract_credentials`: kein `--write-env` mehr empfohlen Werte im ⚙-Menü eintragen
- Dockerfile im Release-Repo: Pfade ohne `05_scripts/`-Präfix (direkt aus Repo-Root)
- `release.sh`: Dockerfile für Release-Repo automatisch per `sed` angepasst
### Fixes
- Restdruckzeit (`remain_time`) wird jetzt korrekt aus `print/report` übernommen und in der UI angezeigt
- Übersetzung: „Schrittweite" und „Ziel"-Placeholder in Temperatureingaben werden jetzt korrekt übersetzt
---
## [0.9.1-beta9] 2026-04-25
### Neu
- OrcaSlicer-Profil (`kobra_x_orcaslicer_preset.zip`) als Release-Asset
- `release.sh`: OrcaSlicer-Profil wird automatisch ins Release-Repo kopiert und hochgeladen
### Geändert
- README: `extract_credentials` ohne `--write-env`, Werte manuell ins ⚙-Menü eintragen
- README: Docker-Schnellstart vereinfacht (kein `.env` anlegen vor dem Start nötig)
---
## [0.9.1-beta8] 2026-04-25
### Neu
- Restdruckzeit-Anzeige in der UI (≈ Xh Ym verbleibend) aus `remain_time`-Feld des Druckers
- Settings-Modal: Verbindungseinstellungen und Self-Update direkt im Browser
- Self-Update: Bridge prüft Gitea-Release-API auf neue Versionen und aktualisiert sich selbst
### Geändert
- Bridge startet im Offline-Modus wenn Drucker nicht erreichbar (kein Absturz)
- Verbinden/Trennen-Button im Header
---
## [0.9.1-beta7] 2026-04-22
### Neu
- Offline-Start: Bridge läuft auch ohne MQTT-Verbindung, verbindet automatisch sobald Drucker erreichbar
- Verbinden/Trennen-Button im Header
---
## [0.9.1-beta6] 2026-04-20
### Neu
- Release-ZIPs: `kx-bridge-linux.zip`, `kx-bridge-windows.zip`, `anycubic-certs.zip` mit Zertifikaten
### Fixes
- PyInstaller frozen-Binary: `__file__` durch `sys.executable`-Pfad ersetzt (Cert-Pfad-Fix)
---
## [0.9.1-beta5] 2026-04-19
### Neu
- `kx-bridge.exe` (Windows) wird automatisch via GitHub Actions gebaut
---
## [0.9.1-beta4] 2026-04-18
### Neu
- `release.sh`: baut Linux-Binary und Windows-EXE, lädt alle Assets auf Gitea hoch
- Englische README (`README.en.md`)
### Fixes
- `progress` und `filename` werden bei `stoped`/`canceled` korrekt auf 0 zurückgesetzt
---
## [0.9.1-beta3] 2026-04-17
### Neu
- Print-Speed-Card (Leise / Normal / Sport)
- Übersetzungen (DE/EN) vervollständigt
---
## [0.9.1-beta2] 2026-04-17
### Fixes
- Temperatursteuerung während eines laufenden Drucks
---
## [0.9.1-beta1] 2026-04-17
### Neu
- UI-Komplettüberarbeitung: Settings-Modal, Self-Update, Dashboard, Responsive Design
- Neue Drucker-Zustände: `pausing`, `paused`, `resuming`, `resumed`, `stopping`
- `release.sh`: Version-Bump und Release-Sync Skript
---
## [0.9.0-beta1] 2026-04-10
### Neu
- Erster öffentlicher Release
- Docker-Deployment, Linux-Binary, `extract_credentials`-Tool
- Moonraker-kompatible HTTP/WebSocket-Bridge für den Anycubic Kobra X
- AMS Einziehen/Ausziehen, Licht- und Lüftersteuerung
- Web-UI mit Dashboard, Temperaturkarten, Achsensteuerung

View File

@@ -2,15 +2,14 @@ FROM python:3.11-slim
WORKDIR /app
COPY 05_scripts/requirements.txt .
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY 05_scripts/kobrax_moonraker_bridge.py .
COPY 05_scripts/env_loader.py .
COPY 05_scripts/kobrax_client.py .
COPY 05_scripts/anycubic_slicer.crt .
COPY 05_scripts/anycubic_slicer.key .
COPY kobrax_moonraker_bridge.py .
COPY env_loader.py .
COPY kobrax_client.py .
COPY anycubic_slicer.crt .
COPY anycubic_slicer.key .
EXPOSE 7125

View File

@@ -1,6 +1,8 @@
# KX-Bridge Anycubic Kobra X Moonraker Bridge
<p align="center"><img src="knlogo.png" alt="KX-Bridge Logo" width="180"/></p>
**Version:** 0.9.1-beta5
# KX-Bridge Anycubic Kobra X Klipper Bridge
**Version:** 0.9.1-beta15
**Status:** Public Beta suitable for home users, feedback welcome
KX-Bridge is a Moonraker-compatible HTTP/WebSocket bridge for the **Anycubic Kobra X** 3D printer. It allows you to control the printer through OrcaSlicer and other Moonraker-compatible software — no Klipper, no Raspberry Pi required.
@@ -24,7 +26,7 @@ KX-Bridge is a Moonraker-compatible HTTP/WebSocket bridge for the **Anycubic Kob
## Requirements
- Anycubic Kobra X on your local network (LAN, no Wi-Fi client isolation)
- Anycubic Kobra X on your local network, with **LAN mode enabled** (printer menu → enable LAN mode)
- Printer MQTT credentials (→ see [Extracting credentials](#extracting-credentials))
- Docker **or** Python 3.9+ **or** the pre-built Linux binary
@@ -33,24 +35,28 @@ KX-Bridge is a Moonraker-compatible HTTP/WebSocket bridge for the **Anycubic Kob
## Quick start Docker (recommended)
```bash
# 1. Create .env
cp .env.example .env
# Fill in your printer data (→ extract_credentials)
# 1. Start the bridge
./start.sh
```
# 2. Start the bridge
docker compose up -d
`start.sh` builds the Docker image automatically on first run and starts the bridge.
```
# 2. Open the web UI: http://BRIDGE-IP:7125
# → Settings (⚙) open automatically on first start
# → Enter your credentials (→ see Extracting credentials)
# 3. In OrcaSlicer: add printer → "Moonraker" → http://BRIDGE-IP:7125
```
Check logs:
```bash
docker compose logs -f
docker-compose logs -f
```
Update:
Stop:
```bash
docker compose pull && docker compose up -d
docker-compose down
```
---
@@ -59,11 +65,11 @@ docker compose pull && docker compose up -d
```bash
chmod +x kx-bridge
./kx-bridge --printer-ip 192.168.x.x --username userXXXX --password XXXXX \
--device-id XXXXX --mode-id 20030
./kx-bridge
```
Or place a `.env` file in the same directory — the bridge reads it automatically.
Open the web UI: `http://localhost:7125`
→ Settings (⚙) open automatically and guide you through the initial setup.
---
@@ -71,32 +77,31 @@ Or place a `.env` file in the same directory — the bridge reads it automatical
```bash
pip install aiohttp
python kobrax_moonraker_bridge.py --printer-ip 192.168.x.x ...
# Or fill in .env and start without arguments
python kobrax_moonraker_bridge.py
```
Open the web UI: `http://localhost:7125`
→ Settings (⚙) open automatically on first start.
---
## Extracting credentials
The MQTT credentials are printer-specific and are generated on first connection with AnycubicSlicerNext. The `extract_credentials` tool reads them from the memory of the running slicer.
**Requirement:** AnycubicSlicerNext must be running and connected to the printer.
**Requirement:** AnycubicSlicerNext must be running and connected to the printer (printer status is shown).
### Windows
```
extract_credentials.exe --write-env
extract_credentials.exe
```
Writes the found credentials directly to `.env`.
### Linux
```bash
chmod +x extract_credentials
./extract_credentials --write-env
./extract_credentials
```
### Output
@@ -114,10 +119,13 @@ chmod +x extract_credentials
Device-ID xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (hits: 3504)
Printer IP 192.168.x.x (hits: 3036)
=======================================================
Hint: pass --write-env to save credentials to '.env'.
```
Enter the displayed values in the bridge settings:
Open web UI → **⚙ Settings** → fill in the fields → **Save & Restart**
> If the result looks uncertain: `--verbose` shows all found candidates.
All credentials are **processed locally only** — nothing is sent to external servers.
---
@@ -238,7 +246,7 @@ tail -f /tmp/bridge.log # when using bridge.sh
## Security notes
- The bridge binds to `0.0.0.0:7125` by default — use on your local network only
- The bridge is accessible on your local network via `http://<your-host-ip>:7125` — do not expose to the internet
- `.env` contains printer credentials — do not share publicly
- The credentials are printer-specific and have no access to Anycubic cloud services

271
README.md
View File

@@ -1,25 +1,11 @@
# KX-Bridge
<p align="center"><img src="knlogo.png" alt="KX-Bridge Logo" width="180"/></p>
Verbindet den Anycubic Kobra X mit OrcaSlicer ohne Klipper, ohne Raspberry Pi.
# KX-Bridge Anycubic Kobra X Klipper Bridge
KX-Bridge läuft auf deinem PC oder NAS und stellt eine Moonraker-kompatible Schnittstelle bereit, über die OrcaSlicer den Drucker direkt steuern kann: Druckstart, Temperatur, Fortschritt, Pause/Fortsetzen/Abbrechen, AMS-Farbwechsel, Druckgeschwindigkeit und mehr.
**Version:** 0.9.1-beta15
**Status:** Public Beta für Heimanwender geeignet, Feedback willkommen
**Version:** 0.9.1-beta5
---
## Enthaltene Dateien
| Datei | Beschreibung |
|-------|-------------|
| `kobrax_moonraker_bridge.py` | Bridge-Hauptprogramm |
| `kx-bridge` | Vorkompilierte Linux-Binary |
| `extract_credentials.exe` | Zugangsdaten aus AnycubicSlicerNext auslesen (Windows) |
| `extract_credentials` | Zugangsdaten aus AnycubicSlicerNext auslesen (Linux) |
| `kobra_x_orcaslicer_preset.zip` | OrcaSlicer-Druckerprofil für den Kobra X |
| `bridge.sh` | Service-Manager für Linux |
| `Dockerfile` / `docker-compose.yml` | Docker-Deployment |
| `.env.example` | Konfigurationsvorlage |
KX-Bridge ist eine Moonraker-kompatible HTTP/WebSocket-Bridge für den **Anycubic Kobra X** 3D-Drucker. Sie ermöglicht die Steuerung des Druckers über OrcaSlicer und andere Moonraker-kompatible Software, ohne dass Klipper oder ein Raspberry Pi benötigt wird.
---
@@ -40,93 +26,135 @@ KX-Bridge läuft auf deinem PC oder NAS und stellt eine Moonraker-kompatible Sch
## Voraussetzungen
- Anycubic Kobra X im LAN-Modus (Drucker muss über LAN erreichbar sein, nicht nur über Anycubic-Cloud)
- PC, NAS oder Server im gleichen Netzwerk (Windows oder Linux)
- Docker oder Python 3.9+
- MQTT-Zugangsdaten des Druckers → [Schritt 1](#schritt-1-zugangsdaten-ermitteln)
- Anycubic Kobra X im lokalen Netzwerk, mit aktiviertem **LAN-Modus** (Drucker-Menü → LAN-Modus einschalten)
- MQTT-Credentials des Druckers (→ siehe [Credentials extrahieren](#credentials-extrahieren))
- Docker **oder** Python 3.9+ **oder** direkt die Linux-Binary
---
## Schnellstart
### Schritt 1: Zugangsdaten ermitteln
Die Bridge benötigt druckerspezifische MQTT-Zugangsdaten.
> **Wichtig:** Der Drucker muss sich im LAN-Modus befinden. Nur wenn der Drucker direkt über LAN (nicht ausschließlich über die Anycubic-Cloud) erreichbar ist, können die Zugangsdaten ermittelt und die Bridge genutzt werden.
AnycubicSlicerNext starten und mit dem Drucker verbinden (bis der Drucker-Status angezeigt wird), dann:
**Windows:**
```
extract_credentials.exe --write-env
```
**Linux:**
```bash
chmod +x extract_credentials
./extract_credentials --write-env
```
Die Zugangsdaten werden automatisch in `.env` gespeichert.
> Falls das Ergebnis unsicher wirkt: `--verbose` zeigt alle gefundenen Kandidaten. Den richtigen Wert manuell in `.env` eintragen.
---
### Schritt 2: Konfiguration prüfen
## Schnellstart Docker (empfohlen)
```bash
cp .env.example .env
# .env öffnen und Werte kontrollieren
# 1. Bridge starten
./start.sh
```
`start.sh` baut das Docker-Image automatisch beim ersten Aufruf und startet die Bridge.
```
# 2. Web-UI öffnen: http://BRIDGE-IP:7125
# → Einstellungen (⚙) öffnen sich automatisch beim ersten Start
# → Zugangsdaten eintragen (→ siehe Credentials extrahieren)
# 3. In OrcaSlicer: Drucker → "Moonraker" → http://BRIDGE-IP:7125
```
Logs prüfen:
```bash
docker-compose logs -f
```
Stoppen:
```bash
docker-compose down
```
---
### Schritt 3: Bridge starten
## Schnellstart Binary (Linux)
**Option A Docker (empfohlen):**
```bash
docker compose up -d
```
Läuft im Hintergrund, startet automatisch nach Systemneustart.
**Option B Linux Binary:**
```bash
chmod +x kx-bridge
./kx-bridge
# Oder mit Service-Manager:
./bridge.sh start
```
**Option C Python direkt:**
Web-UI öffnen: `http://localhost:7125`
→ Einstellungen (⚙) öffnen sich automatisch und führen durch die Erstkonfiguration.
---
## Schnellstart Python direkt
```bash
pip install aiohttp
python kobrax_moonraker_bridge.py
```
---
### Schritt 4: OrcaSlicer-Profil installieren
1. `kobra_x_orcaslicer_preset.zip` in OrcaSlicer importieren:
Datei → Konfigurationen importieren → ZIP auswählen
2. Anycubic Kobra X als Drucker auswählen
Web-UI öffnen: `http://localhost:7125`
→ Einstellungen (⚙) öffnen sich automatisch beim ersten Start.
---
### Schritt 5: OrcaSlicer verbinden
## Credentials extrahieren
1. Drucker-Einstellungen öffnen
Die MQTT-Zugangsdaten sind druckerspezifisch und werden beim ersten Verbindungsaufbau mit dem AnycubicSlicerNext generiert. Das Tool `extract_credentials` liest sie aus dem RAM des laufenden Slicers aus.
**Voraussetzung:** AnycubicSlicerNext muss gestartet und mit dem Drucker verbunden sein (Drucker-Status wird angezeigt).
### Windows
```
extract_credentials.exe
```
### Linux
```bash
chmod +x extract_credentials
./extract_credentials
```
### Ausgabe
```
[*] Prozess gefunden: AnycubicSlicerNext.exe (PID 1234)
[*] 1986 Speichersegmente gelesen (738.8 MB)
[*] Analysiere ... 100% (739 MB)
=======================================================
ERGEBNISSE
=======================================================
Username userXXXXXXXXXX (Treffer: 47)
Password *************** (Treffer: 1046)
Device-ID xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (Treffer: 3504)
Drucker-IP 192.168.x.x (Treffer: 3036)
=======================================================
```
Die angezeigten Werte in die Bridge-Einstellungen übertragen:
Web-UI öffnen → **⚙ Einstellungen** → Felder ausfüllen → **Speichern & Neustart**
> Falls das Ergebnis unsicher wirkt: `--verbose` zeigt alle gefundenen Kandidaten.
Alle Credentials werden **ausschließlich lokal verarbeitet** — keine Übertragung an externe Server.
---
## Konfiguration (.env)
```env
PRINTER_IP=192.168.x.x # IP des Druckers
MQTT_PORT=9883 # Standard, nicht ändern
MQTT_USERNAME=userXXXXXXXX # Beginnt mit "user"
MQTT_PASSWORD=XXXXXXXXXXXXXX # ~15 Zeichen, gemischt
DEVICE_ID=xxxxxxxx... # 32-stelliger Hex-String
MODE_ID=20030 # Kobra X Standard
```
---
## OrcaSlicer verbinden
1. Drucker hinzufügen → **Anycubic Kobra X** (oder generischer Klipper-Drucker)
2. Verbindungstyp: **Moonraker**
3. Adresse: `http://IP-DES-BRIDGE-PC:7125` eintragen
4. Auf „Test" klicken bei erfolgreicher Verbindung erscheint eine Bestätigungsmeldung
3. IP: `http://BRIDGE-HOST:7125`
4. Verbindung testen → sollte "Online" anzeigen
---
## Web-UI
Die Bridge stellt unter `http://BRIDGE-IP:7125` eine Web-Oberfläche bereit:
Die Bridge stellt unter `http://BRIDGE-HOST:7125` eine Web-Oberfläche bereit:
| Bereich | Funktion |
|---------|----------|
@@ -136,55 +164,56 @@ Die Bridge stellt unter `http://BRIDGE-IP:7125` eine Web-Oberfläche bereit:
| Druckgeschwindigkeit | Leise / Normal / Sport |
| Lüfter / Licht | Lüfterdrehzahl und Drucklicht |
| AMS | Filament einziehen / ausziehen |
| Kamera | Live-Vorschau (falls vom Drucker unterstützt) |
| Kamera | Live-Vorschau (falls Drucker unterstützt) |
| ⚙ Einstellungen | MQTT-Zugangsdaten, Poll-Intervall, Self-Update |
### Self-Update
Über das ⚙-Menü in der Web-UI kann die Bridge auf neue Versionen prüfen und sich selbst aktualisieren — ohne Neuinstallation. Nach dem Download startet sie automatisch neu.
Über das ⚙-Menü in der Web-UI kann die Bridge auf neue Versionen prüfen und sich selbst aktualisieren — ohne Neuinstallation. Nach dem Download startet die Bridge automatisch mit der neuen Version neu.
---
## bridge.sh Service-Manager (Linux)
## bridge.sh (Linux Service-Manager)
```bash
./bridge.sh start # Im Hintergrund starten
./bridge.sh stop # Beenden
./bridge.sh restart # Neustarten
./bridge.sh status # Status anzeigen
./bridge.sh log 50 # Letzte 50 Log-Zeilen
./bridge.sh start # Bridge im Hintergrund starten
./bridge.sh stop # Bridge beenden
./bridge.sh restart # Neustarten
./bridge.sh status # Status und Port prüfen
./bridge.sh log 50 # Letzte 50 Log-Zeilen
```
---
## Docker Nützliche Befehle
## Druckerzustände
```bash
docker compose up -d # Starten
docker compose down # Stoppen
docker compose logs -f # Logs verfolgen
docker compose pull && docker compose up -d # Update
```
Die Bridge übersetzt die internen Kobra-Zustände in Moonraker-kompatible Zustände:
| Kobra-Zustand | Bedeutung |
|---------------|-----------|
| free | Bereit |
| printing / busy | Druckt |
| pausing / paused | Pausiert |
| resuming / resumed | Wird fortgesetzt |
| stopping / stoped | Wird gestoppt |
| finished | Abgeschlossen |
| canceled | Abgebrochen |
| failed | Fehler |
---
## Fehlerbehebung
**Port 7125 belegt:**
**Port 7125 bereits belegt:**
```bash
./bridge.sh stop
./bridge.sh stop # oder: fuser -k 7125/tcp
./bridge.sh start
```
**Verbindungstest in OrcaSlicer schlägt fehl:**
- Firewall prüfen: Port 7125 muss erreichbar sein
- Bridge-Log prüfen: `./bridge.sh log` oder `docker compose logs`
- Drucker-IP in `.env` korrekt?
**Zugangsdaten werden abgelehnt:**
- AnycubicSlicerNext starten, mit Drucker verbinden
- `extract_credentials --verbose` ausführen und alle Kandidaten prüfen
- Richtigen Wert manuell in `.env` eintragen, Bridge neu starten
**Credentials ungültig / Verbindung abgelehnt:**
- AnycubicSlicerNext starten, mit Drucker verbinden, `extract_credentials` erneut ausführen
- Falls das Ergebnis unsicher wirkt: `extract_credentials --verbose` zeigt alle Kandidaten an
- Den richtigen Kandidaten manuell in `.env` eintragen und Bridge neu starten
**Temperaturänderungen werden ignoriert:**
- Während eines laufenden Drucks werden Temperaturänderungen über einen separaten Kanal gesendet — das ist normal und wird von der Bridge automatisch erkannt.
@@ -192,36 +221,38 @@ docker compose pull && docker compose up -d # Update
**Docker: Permission denied:**
```bash
sudo usermod -aG docker $USER
# Neu einloggen, dann erneut versuchen
# Neu einloggen
```
**Docker: .env nicht gefunden:**
```bash
# .env muss im gleichen Verzeichnis wie docker-compose.yml liegen
cp .env.example .env && nano .env
```
---
## Konfigurationsreferenz (.env)
## Logs
| Parameter | Beschreibung | Beispiel |
|-----------|-------------|---------|
| `PRINTER_IP` | IP-Adresse des Druckers | `192.168.1.100` |
| `MQTT_PORT` | MQTT-Port (nicht ändern) | `9883` |
| `MQTT_USERNAME` | Benutzername (beginnt mit „user") | `userXXXXXXXXXX` |
| `MQTT_PASSWORD` | Passwort (~15 Zeichen) | `***` |
| `DEVICE_ID` | Geräte-ID (32 Hex-Zeichen) | `xxxxxxxx...` |
| `MODE_ID` | Modell-ID (Kobra X Standard) | `20030` |
```bash
# Docker
docker compose logs -f kx-bridge
# Binary / Python
tail -f /tmp/bridge.log # bei Nutzung von bridge.sh
```
---
## Sicherheitshinweise
- Die Bridge bindet standardmäßig auf `0.0.0.0:7125` — nur im lokalen Netzwerk nutzen
- Die Bridge ist im lokalen Netzwerk erreichbar unter `http://<Host-IP>:7125` — nicht ins Internet freigeben
- `.env` enthält Drucker-Credentials — nicht öffentlich teilen
- Alle Zugangsdaten werden ausschließlich lokal verarbeitet — keine Übertragung an externe Server
- Die Credentials sind druckerspezifisch und haben keinen Zugang zu Anycubic-Cloud-Diensten
---
## Hinweis zur Nutzung
## Lizenz & Rechtliches
Dieses Projekt dient der privaten Nutzung und der Herstellung von Interoperabilität zwischen dem Anycubic Kobra X und freier Software (OrcaSlicer).
`extract_credentials` liest ausschließlich den Arbeitsspeicher des auf deinem eigenen PC laufenden AnycubicSlicerNext-Prozesses. Es werden keine Daten übertragen oder gespeichert, außer in die lokale `.env`-Datei.
Das Projekt steht in keiner Verbindung zu Anycubic und wird nicht kommerziell betrieben.
Dieses Projekt entstand durch Interoperabilitätsforschung gem. §69e UrhG.
Ausschließlich für private, nicht-kommerzielle Nutzung.

View File

@@ -1 +1 @@
0.9.1-beta10
0.9.1-beta15

View File

@@ -3,6 +3,8 @@ services:
image: kx-bridge:latest
build: .
env_file: .env
volumes:
- ./.env:/app/.env
ports:
- "7125:7125"
restart: unless-stopped

View File

@@ -1,23 +1,23 @@
"""
extract_credentials.py Extrahiert Anycubic LAN-MQTT-Credentials aus dem RAM
des laufenden AnycubicSlicerNext-Prozesses.
extract_credentials.py Extracts Anycubic LAN-MQTT credentials from the RAM
of the running AnycubicSlicerNext process.
Voraussetzungen:
- AnycubicSlicerNext läuft und ist mit dem Drucker verbunden
- Gleiches Benutzerkonto wie der Slicer-Prozess (kein Admin nötig)
Requirements:
- AnycubicSlicerNext is running and connected to the printer
- Same user account as the slicer process (no admin required)
Verwendung:
Usage:
python3 extract_credentials.py [--write-env] [--env-file ../.env]
Funktionsweise:
1. Prozess "AnycubicSlicer.exe" (Windows) bzw. "AnycubicSlicer" (Linux) finden
2. Speicherseiten des Prozesses durchsuchen (nur r/rw, keine Exec-Pages)
3. Nach MQTT-Credential-Patterns suchen:
How it works:
1. Find process "AnycubicSlicer.exe" (Windows) or "AnycubicSlicer" (Linux)
2. Scan memory pages of the process (only r/rw, no exec pages)
3. Search for MQTT credential patterns:
Username: user[A-Za-z0-9]{8,12}
Password: [A-Za-z0-9+/]{13,18}
Drucker-IP: d{1,3}.d{1,3}.d{1,3}.d{1,3}
4. Kandidaten nach Plausibilität filtern und ausgeben
5. Optional: .env-Datei schreiben
Printer IP: d{1,3}.d{1,3}.d{1,3}.d{1,3}
4. Filter candidates by plausibility and print results
5. Optionally write .env file
"""
import argparse
@@ -28,48 +28,48 @@ import sys
import platform
# ---------------------------------------------------------------------------
# Plattform-Erkennung
# Platform detection
# ---------------------------------------------------------------------------
IS_WINDOWS = platform.system() == "Windows"
IS_LINUX = platform.system() == "Linux"
# ---------------------------------------------------------------------------
# Pattern
# Patterns
# ---------------------------------------------------------------------------
# Username: "user" + 812 alphanumerische Zeichen (drucker-generiert)
# Username: "user" + 812 alphanumeric characters (printer-generated)
RE_USERNAME = re.compile(rb'user[A-Za-z0-9]{8,12}(?=[^A-Za-z0-9]|$)')
# Password: 1320 alphanumerische Zeichen (kein / da kein RTSP-Pfad)
# Anycubic-Passwörter: gemischte Groß/Klein/Ziffern, kein Slash
# Password: 1320 alphanumeric characters (no / since no RTSP path)
# Anycubic passwords: mixed upper/lower/digits, no slash
RE_PASSWORD = re.compile(rb'[A-Za-z0-9]{13,20}(?=[^A-Za-z0-9]|$)')
# Kontext-Pattern: sucht Passwort das direkt nach "password" im Speicher steht
# Context pattern: password directly following "password" in memory
RE_PASSWORD_CTX = re.compile(rb'(?:password|passwd|Password)\x00{0,4}([A-Za-z0-9]{10,25})(?=[^A-Za-z0-9]|$)', re.IGNORECASE)
# Proximity-Pattern: Username gefolgt von Passwort in naher Umgebung (<512 Bytes)
# Proximity pattern: username followed by password within close range (<512 bytes)
RE_USER_PASS_PROXIMITY = re.compile(
rb'(user[A-Za-z0-9]{8,12}).{1,512}?([A-Za-z0-9]{13,20})(?=[^A-Za-z0-9]|$)',
re.DOTALL
)
# IPv4-Adresse (kein localhost, kein Broadcast)
# IPv4 address (no localhost, no broadcast)
RE_IP = re.compile(rb'(?<![.\d])(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?![.\d])')
# mode_id: 5-stellige Zahl (z.B. 20030)
# mode_id: 5-digit number (e.g. 20030)
RE_MODE_ID = re.compile(rb'(?<!\d)(2\d{4})(?!\d)')
# device_id: 32 Hex-Zeichen (MD5-Format)
# device_id: 32 hex characters (MD5 format)
RE_DEVICE_ID = re.compile(rb'[0-9a-f]{32}(?=[^0-9a-f]|$)')
# ---------------------------------------------------------------------------
# Windows Speicher lesen via ctypes / ReadProcessMemory
# Windows read memory via ctypes / ReadProcessMemory
# ---------------------------------------------------------------------------
def _win_find_pid(name: str) -> "int | None":
"""Findet die PID eines Prozesses anhand des Namens (case-insensitive)."""
"""Find the PID of a process by name (case-insensitive)."""
import ctypes
import ctypes.wintypes
@@ -110,15 +110,15 @@ def _win_find_pid(name: str) -> "int | None":
def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]":
"""Liest alle lesbaren Speicherseiten eines Windows-Prozesses."""
"""Read all readable memory pages of a Windows process."""
import ctypes
import ctypes.wintypes
PROCESS_VM_READ = 0x0010
PROCESS_VM_READ = 0x0010
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT = 0x1000
PAGE_NOACCESS = 0x01
PAGE_GUARD = 0x100
MEM_COMMIT = 0x1000
PAGE_NOACCESS = 0x01
PAGE_GUARD = 0x100
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
_fields_ = [
@@ -134,7 +134,7 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]":
k32 = ctypes.windll.kernel32
handle = k32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not handle:
raise PermissionError(f"OpenProcess fehlgeschlagen (PID {pid}): {ctypes.GetLastError()}")
raise PermissionError(f"OpenProcess failed (PID {pid}): {ctypes.GetLastError()}")
chunks = []
addr = 0
@@ -147,7 +147,7 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]":
mbi.State != MEM_COMMIT or
mbi.Protect & PAGE_NOACCESS or
mbi.Protect & PAGE_GUARD or
mbi.RegionSize > 256 * 1024 * 1024 # >256 MB überspringen
mbi.RegionSize > 256 * 1024 * 1024 # skip >256 MB regions
)
if not skip:
buf = ctypes.create_string_buffer(mbi.RegionSize)
@@ -156,7 +156,7 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]":
buf, mbi.RegionSize, ctypes.byref(read)):
chunks.append(bytes(buf[:read.value]))
addr += mbi.RegionSize
if addr >= 0x7FFFFFFFFFFF: # Ende des User-Space (64-bit)
if addr >= 0x7FFFFFFFFFFF: # end of user space (64-bit)
break
finally:
k32.CloseHandle(handle)
@@ -165,11 +165,11 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]":
# ---------------------------------------------------------------------------
# Linux Speicher lesen via /proc/{pid}/mem
# Linux read memory via /proc/{pid}/mem
# ---------------------------------------------------------------------------
def _linux_find_pid(name: str) -> "int | None":
"""Findet PID anhand des Prozessnamens in /proc."""
"""Find PID by process name in /proc."""
for entry in os.listdir("/proc"):
if not entry.isdigit():
continue
@@ -183,7 +183,7 @@ def _linux_find_pid(name: str) -> "int | None":
def _linux_read_memory(pid: int) -> "list[bytes]":
"""Liest lesbare Speichersegmente aus /proc/{pid}/mem."""
"""Read readable memory segments from /proc/{pid}/mem."""
chunks = []
maps_path = f"/proc/{pid}/maps"
mem_path = f"/proc/{pid}/mem"
@@ -193,8 +193,8 @@ def _linux_read_memory(pid: int) -> "list[bytes]":
mem = open(mem_path, "rb")
except PermissionError:
raise PermissionError(
f"Kein Zugriff auf /proc/{pid}/mem — "
"Script als gleicher Benutzer wie der Slicer starten."
f"No access to /proc/{pid}/mem — "
"run the script as the same user as the slicer process."
)
for line in maps:
@@ -202,16 +202,16 @@ def _linux_read_memory(pid: int) -> "list[bytes]":
if len(parts) < 2:
continue
perms = parts[1]
if "r" not in perms: # nur lesbare Seiten
if "r" not in perms: # readable pages only
continue
if "x" in perms: # Code-Seiten überspringen (keine Strings)
if "x" in perms: # skip code pages (no strings)
continue
try:
start, end = [int(x, 16) for x in parts[0].split("-")]
except ValueError:
continue
size = end - start
if size > 256 * 1024 * 1024: # >256 MB überspringen
if size > 256 * 1024 * 1024: # skip >256 MB regions
continue
try:
mem.seek(start)
@@ -226,7 +226,7 @@ def _linux_read_memory(pid: int) -> "list[bytes]":
# ---------------------------------------------------------------------------
# Pattern-Suche
# Pattern search
# ---------------------------------------------------------------------------
def _is_valid_ip(ip_bytes: bytes) -> bool:
@@ -245,7 +245,7 @@ def _is_valid_ip(ip_bytes: bytes) -> bool:
def search_chunks(chunks: "list[bytes]") -> dict:
"""Durchsucht Speicher-Chunks nach Credential-Patterns."""
"""Search memory chunks for credential patterns."""
usernames = {} # value → count
passwords = {}
ips = {}
@@ -256,12 +256,12 @@ def search_chunks(chunks: "list[bytes]") -> dict:
if i % 50 == 0 or i == total - 1:
pct = (i + 1) * 100 // total
mb_done = sum(len(c) for c in chunks[:i+1]) / 1024 / 1024
print(f"\r[*] Analysiere ... {pct:3d}% ({mb_done:.0f} MB)", end="", flush=True)
print(f"\r[*] Scanning ... {pct:3d}% ({mb_done:.0f} MB)", end="", flush=True)
for m in RE_USERNAME.finditer(chunk):
v = m.group().decode("ascii", errors="replace")
usernames[v] = usernames.get(v, 0) + 1
# Proximity: Passwort das innerhalb von 512 Bytes nach einem Username steht
# Proximity: password within 512 bytes after a username
for m in RE_USER_PASS_PROXIMITY.finditer(chunk):
pw = m.group(2).decode("ascii", errors="replace")
has_upper = any(c.isupper() for c in pw)
@@ -272,7 +272,7 @@ def search_chunks(chunks: "list[bytes]") -> dict:
for m in RE_PASSWORD.finditer(chunk):
v = m.group().decode("ascii", errors="replace")
# Filter: mindestens 2 Großbuchstaben + 2 Kleinbuchstaben + 1 Ziffer
# Filter: at least 2 uppercase + 2 lowercase + 1 digit
has_upper = sum(1 for c in v if c.isupper()) >= 2
has_lower = sum(1 for c in v if c.islower()) >= 2
has_digit = sum(1 for c in v if c.isdigit()) >= 1
@@ -289,9 +289,9 @@ def search_chunks(chunks: "list[bytes]") -> dict:
v = m.group().decode("ascii")
device_ids[v] = device_ids.get(v, 0) + 1
print() # Zeilenumbruch nach Fortschrittszeile
print() # newline after progress line
# Nach Häufigkeit sortieren, häufigste zuerst
# Sort by frequency, most frequent first
def top(d, n=10):
return sorted(d.items(), key=lambda x: -x[1])[:n]
@@ -304,7 +304,7 @@ def search_chunks(chunks: "list[bytes]") -> dict:
# ---------------------------------------------------------------------------
# Hauptprogramm
# Main
# ---------------------------------------------------------------------------
SLICER_NAMES = [
@@ -359,95 +359,95 @@ def write_env(results: dict, env_path: str,
with open(env_path, "w") as f:
f.writelines(lines)
print(f"\n✓ Credentials in '{env_path}' gespeichert.")
print(f"\n✓ Credentials saved to '{env_path}'.")
def main():
parser = argparse.ArgumentParser(
description="Extrahiert MQTT-Credentials aus dem RAM des AnycubicSlicer-Prozesses"
description="Extract MQTT credentials from the AnycubicSlicer process RAM"
)
parser.add_argument("--write-env", action="store_true",
help="Gefundene Credentials in .env schreiben")
help="Write found credentials to .env file")
parser.add_argument("--env-file", default=None,
help="Pfad zur .env-Datei (Standard: ../. env relativ zu diesem Script)")
help="Path to .env file (default: ../.env relative to this script)")
parser.add_argument("--pid", type=int, default=None,
help="Prozess-PID direkt angeben (überspringt Auto-Erkennung)")
help="Specify process PID directly (skips auto-detection)")
parser.add_argument("--verbose", action="store_true",
help="Alle Kandidaten ausgeben, nicht nur den besten")
help="Show all candidates, not just the best match")
args = parser.parse_args()
# .env-Pfad bestimmen
# Determine .env path
if args.env_file:
env_path = args.env_file
else:
env_path = os.path.join(os.path.dirname(__file__), "..", ".env")
env_path = os.path.normpath(env_path)
# Prozess finden
# Find process
if args.pid:
pid, proc_name = args.pid, f"PID {args.pid}"
else:
print("[*] Suche AnycubicSlicer-Prozess ...")
print("[*] Searching for AnycubicSlicer process ...")
pid, proc_name = find_slicer_pid()
if not pid:
print("✗ AnycubicSlicer nicht gefunden. Bitte den Slicer starten und "
"mit dem Drucker verbinden, dann erneut ausführen.")
print("✗ AnycubicSlicer not found. Please start the slicer, connect it "
"to the printer, then run this script again.")
sys.exit(1)
print(f"[*] Prozess gefunden: {proc_name} (PID {pid})")
print(f"[*] Lese Prozess-Speicher ...")
print(f"[*] Process found: {proc_name} (PID {pid})")
print(f"[*] Reading process memory ...")
try:
chunks = read_process(pid)
except PermissionError as e:
print(f"Zugriffsfehler: {e}")
print(f"Permission error: {e}")
sys.exit(1)
total_mb = sum(len(c) for c in chunks) / 1024 / 1024
print(f"[*] {len(chunks)} Speichersegmente gelesen ({total_mb:.1f} MB)")
print(f"[*] Durchsuche nach Credentials ...")
print(f"[*] {len(chunks)} memory segments read ({total_mb:.1f} MB)")
print(f"[*] Searching for credentials ...")
results = search_chunks(chunks)
# Ausgabe
# Output
print("\n" + "="*55)
print(" ERGEBNISSE")
print(" RESULTS")
print("="*55)
def show(label, items, verbose):
if not items:
print(f" {label:12s} — nicht gefunden")
print(f" {label:12s} — not found")
return items[0][0] if items else ""
best = items[0][0]
print(f" {label:12s} {best} (Treffer: {items[0][1]})")
print(f" {label:12s} {best} (matches: {items[0][1]})")
if verbose and len(items) > 1:
for val, cnt in items[1:]:
print(f" {'':12s} {val} (Treffer: {cnt})")
print(f" {'':12s} {val} (matches: {cnt})")
return best
best_user = show("Username", results["usernames"], args.verbose)
best_pass = show("Password", results["passwords"], args.verbose)
best_device = show("Device-ID", results["device_ids"], args.verbose)
best_user = show("Username", results["usernames"], args.verbose)
best_pass = show("Password", results["passwords"], args.verbose)
best_device = show("Device-ID", results["device_ids"], args.verbose)
# IP: 192.168.x.x bevorzugen
# IP: prefer 192.168.x.x
lan_ips = [(ip, cnt) for ip, cnt in results["ips"]
if ip.startswith("192.168.") or ip.startswith("10.") or ip.startswith("172.")]
if not lan_ips:
lan_ips = results["ips"]
best_ip = show("Drucker-IP", lan_ips, args.verbose)
best_ip = show("Printer IP", lan_ips, args.verbose)
print("="*55)
if not best_user or not best_pass:
print("\nKeine vollständigen Credentials gefunden.")
print(" Stelle sicher dass der Slicer MIT dem Drucker verbunden ist.")
print("\nNo complete credentials found.")
print(" Make sure the slicer is connected to the printer.")
sys.exit(1)
if args.write_env:
write_env(results, env_path, best_user, best_pass, best_ip,
device_id=best_device)
else:
print(f"\nHinweis: --write-env übergeben um Credentials in '{env_path}' zu speichern.")
print(f"\nTip: pass --write-env to save credentials to '{env_path}'.")
if __name__ == "__main__":

BIN
knlogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -198,6 +198,7 @@ class KobraXClient:
def _read_loop(self):
last_ping = time.time()
_empty_count = 0
while self._running:
if time.time() - last_ping > 30:
with self._lock:
@@ -212,7 +213,12 @@ class KobraXClient:
try:
data = self._sock.recv(65536)
if not data:
raise ConnectionResetError("EOF")
# Windows SSL kann kurzzeitig b"" liefern ohne echten EOF
_empty_count += 1
if _empty_count >= 5:
raise ConnectionResetError("EOF")
continue
_empty_count = 0
self._buf += data
self._drain()
except ssl.SSLWantReadError:

View File

@@ -29,6 +29,19 @@ _BASE = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os
sys.path.insert(0, _BASE)
from kobrax_client import KobraXClient
try:
import imageio_ffmpeg
def _find_ffmpeg() -> str:
return imageio_ffmpeg.get_ffmpeg_exe()
except ImportError:
def _find_ffmpeg() -> str:
exe_name = "ffmpeg.exe" if sys.platform == "win32" else "ffmpeg"
local = os.path.join(_BASE, exe_name)
if os.path.isfile(local):
return local
return "ffmpeg"
try:
from aiohttp import web
import aiohttp
@@ -445,17 +458,19 @@ class KobraXBridge:
}, status=201)
def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0):
use_ams = len(self._ams_slots) > 0
loaded = [(i, s) for i, s in enumerate(self._ams_slots) if s.get("status") == 5]
use_ams = len(loaded) > 0
ams_box_mapping = [
{
"paint_index": i,
"ams_index": i,
"paint_color": [255, 255, 255, 255],
"ams_color": [255, 255, 255, 255],
"material_type": s.get("material_type", "PLA"),
"material_type": s.get("type", "PLA"),
}
for i, s in enumerate(self._ams_slots)
for i, s in loaded
]
log.info(f"AMS-Slots: {len(loaded)}/{len(self._ams_slots)} belegt → {[i for i,_ in loaded]}")
payload = {
"taskid": "-1",
"url": url,
@@ -503,16 +518,19 @@ class KobraXBridge:
log.info(f"Druck starten: {filename}")
# AMS-Mapping aus gecachtem State
# AMS-Mapping aus gecachtem State — leere Slots (status != 5) überspringen
ams_box_mapping = []
for i, slot in enumerate(self._ams_slots):
if slot.get("status") != 5:
log.info(f"AMS-Slot {i} leer (status={slot.get('status')}) übersprungen")
continue
ams_box_mapping.append({
"slot_index": i,
"material_type": slot.get("material_type", "PLA"),
"color": slot.get("color", "FFFFFF"),
"material_type": slot.get("type", "PLA"),
"color": slot.get("color", [255, 255, 255]),
})
use_ams = len(self._ams_slots) > 0
use_ams = len(ams_box_mapping) > 0
payload = {
"filename": filename,
@@ -863,6 +881,7 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<div class="modal-field">
<label id="lbl-printer-ip">Drucker-IP</label>
<input type="text" id="s-printer-ip" placeholder="192.168.x.x">
<small id="lbl-ip-hint" style="color:#f80;display:none"></small>
</div>
<div class="modal-field">
<label id="lbl-mqtt-port">MQTT-Port</label>
@@ -1030,17 +1049,17 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<button class="step-btn" onclick="setStep(this,10)">10 mm</button>
</div>
<div class="home-btns">
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeAxis('X')"><span class="lbl-home-x">Home X</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeAxis('Y')"><span class="lbl-home-y">Home Y</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeAxis('Z')"><span class="lbl-home-z">Home Z</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeZ()"><span class="lbl-home-z">Home Z</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeXY()"><span class="lbl-home-xy">Home XY</span></button>
<button class="btn btn-sm btn-accent" onclick="homeAll()"><span class="lbl-home-all">Home All</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="disableMotors()"><span class="lbl-disable-motors">Motors Off</span></button>
</div>
</div>
<div class="card">
<div class="card-title"><span>↕</span> <span id="ptitle-motion-z">Z-Achse</span></div>
<div class="joypad" style="grid-template-columns:52px;grid-template-rows:repeat(2,52px)">
<button class="joy" onclick="move(2,-1,getStep())" title="Z">▲</button>
<button class="joy" onclick="move(2,1,getStep())" title="Z+">▼</button>
<button class="joy" onclick="move(2,1,getStep())" title="Z+">▲</button>
<button class="joy" onclick="move(2,-1,getStep())" title="Z">▼</button>
</div>
<div style="text-align:center;margin-top:8px;font-size:12px;color:var(--txt2)"><span class="lbl-step">Schrittweite:</span> <span id="step-display">1</span> mm</div>
</div>
@@ -1154,15 +1173,15 @@ var LANG_DE={
panel_print_title:'Drucksteuerung',panel_print_btn_pause:'⏸ Pause',panel_print_btn_resume:'▶ Fortsetzen',panel_print_btn_cancel:'✕ Abbrechen',panel_print_temps_live:'Temperaturen (Live)',
label_set:'Setzen',label_off:'Aus',
panel_temps_nozzle:'Nozzle',panel_temps_bed:'Heizbett',panel_temps_chart:'Verlauf (letzte 60 Messungen)',label_target_c:'Ziel:',
panel_motion_xy:'XY-Achsen',panel_motion_z:'Z-Achse',label_step:'Schrittweite:',btn_home_x:'Home X',btn_home_y:'Home Y',btn_home_z:'Home Z',btn_home_all:'Home All',
panel_ams_title:'AMS / Filamentbox',ams_no_data:'Keine AMS-Daten empfangen',label_slot:'Slot',
panel_motion_xy:'XY-Achsen',panel_motion_z:'Z-Achse',label_step:'Schrittweite:',btn_home_z:'Home Z',btn_home_xy:'Home XY',btn_home_all:'Home All',btn_disable_motors:'Motoren aus',
panel_ams_title:'AMS / Filamentbox',ams_no_data:'Keine AMS-Daten empfangen',label_slot:'Slot',ams_empty:'Leer',
panel_extras_light:'Licht',panel_extras_fan:'Lüfter',panel_extras_camera:'Kamera',btn_cam_start2:'▶ Start',btn_cam_stop2:'◼ Stop',
panel_console_title:'Ereignis-Log',
log_light_on:'Licht an',log_light_off:'Licht aus',log_fan:'Lüfter →',log_nozzle:'Nozzle →',log_bed:'Bett →',log_axis:'Achse',log_home:'Home',log_home_all:'Home All',log_cam_start:'Kamera gestartet:',log_cam_stop:'Kamera gestoppt',log_poll_error:'Poll-Fehler:',log_error:'Fehler:',
confirm_cancel:'Druck wirklich abbrechen?',
settings_title:'Einstellungen',settings_connection:'Verbindung',settings_poll:'Poll-Intervall',settings_version:'Version',
settings_save:'Speichern & Neustart',settings_printer_ip:'Drucker-IP',settings_mqtt_port:'MQTT-Port',
settings_username:'MQTT-Benutzername',settings_password:'MQTT-Passwort',settings_device_id:'Device-ID',settings_mode_id:'Mode-ID',
settings_username:'MQTT-Benutzername',settings_password:'MQTT-Passwort',settings_device_id:'Device-ID',settings_mode_id:'Mode-ID',hint_ip_no_port:'Nur IP-Adresse, kein Port (z.B. 192.168.1.102)',
update_check:'Auf Updates prüfen',update_checking:'Prüfe...',update_available:'verfügbar',update_none:'Bereits aktuell',
update_apply:'Jetzt installieren',update_applying:'Lade herunter...',update_restarting:'Starte neu...',update_error:'Fehler',
btn_connect:'⚡ Verbinden',btn_disconnect:'✕ Trennen'
@@ -1180,15 +1199,15 @@ var LANG_EN={
panel_print_title:'Print Control',panel_print_btn_pause:'⏸ Pause',panel_print_btn_resume:'▶ Resume',panel_print_btn_cancel:'✕ Cancel',panel_print_temps_live:'Temperatures (Live)',
label_set:'Set',label_off:'Off',
panel_temps_nozzle:'Nozzle',panel_temps_bed:'Heated Bed',panel_temps_chart:'History (last 60 readings)',label_target_c:'Target:',
panel_motion_xy:'XY Axes',panel_motion_z:'Z Axis',label_step:'Step size:',btn_home_x:'Home X',btn_home_y:'Home Y',btn_home_z:'Home Z',btn_home_all:'Home All',
panel_ams_title:'AMS / Filament Box',ams_no_data:'No AMS data received',label_slot:'Slot',
panel_motion_xy:'XY Axes',panel_motion_z:'Z Axis',label_step:'Step size:',btn_home_z:'Home Z',btn_home_xy:'Home XY',btn_home_all:'Home All',btn_disable_motors:'Motors Off',
panel_ams_title:'AMS / Filament Box',ams_no_data:'No AMS data received',label_slot:'Slot',ams_empty:'Empty',
panel_extras_light:'Light',panel_extras_fan:'Fan',panel_extras_camera:'Camera',btn_cam_start2:'▶ Start',btn_cam_stop2:'◼ Stop',
panel_console_title:'Event Log',
log_light_on:'Light on',log_light_off:'Light off',log_fan:'Fan →',log_nozzle:'Nozzle →',log_bed:'Bed →',log_axis:'Axis',log_home:'Home',log_home_all:'Home All',log_cam_start:'Camera started:',log_cam_stop:'Camera stopped',log_poll_error:'Poll error:',log_error:'Error:',
confirm_cancel:'Really cancel the print?',
settings_title:'Settings',settings_connection:'Connection',settings_poll:'Poll Interval',settings_version:'Version',
settings_save:'Save & Restart',settings_printer_ip:'Printer IP',settings_mqtt_port:'MQTT Port',
settings_username:'MQTT Username',settings_password:'MQTT Password',settings_device_id:'Device ID',settings_mode_id:'Mode ID',
settings_username:'MQTT Username',settings_password:'MQTT Password',settings_device_id:'Device ID',settings_mode_id:'Mode ID',hint_ip_no_port:'IP address only, no port (e.g. 192.168.1.102)',
update_check:'Check for Updates',update_checking:'Checking...',update_available:'available',update_none:'Already up to date',
update_apply:'Install Now',update_applying:'Downloading...',update_restarting:'Restarting...',update_error:'Error',
btn_connect:'⚡ Connect',btn_disconnect:'✕ Disconnect'
@@ -1232,10 +1251,10 @@ function applyLang(){
// Axis labels
setText('ptitle-motion-xy',T.panel_motion_xy);
setText('ptitle-motion-z',T.panel_motion_z);
document.querySelectorAll('.lbl-home-x').forEach(e=>e.textContent=T.btn_home_x);
document.querySelectorAll('.lbl-home-y').forEach(e=>e.textContent=T.btn_home_y);
document.querySelectorAll('.lbl-home-z').forEach(e=>e.textContent=T.btn_home_z);
document.querySelectorAll('.lbl-home-xy').forEach(e=>e.textContent=T.btn_home_xy);
document.querySelectorAll('.lbl-home-all').forEach(e=>e.textContent=T.btn_home_all);
document.querySelectorAll('.lbl-disable-motors').forEach(e=>e.textContent=T.btn_disable_motors);
document.querySelectorAll('.lbl-step').forEach(e=>e.textContent=T.label_step);
document.querySelectorAll('.temp-input').forEach(e=>e.setAttribute('placeholder',T.label_target_c.replace(':','')));
// Console
@@ -1374,13 +1393,14 @@ function applyState(){
if(s.ams_slots&&s.ams_slots.length){
var html='';
s.ams_slots.forEach(function(slot,i){
var rgb=Array.isArray(slot.color)?slot.color:[128,128,128];
var empty=slot.status!==5;
var rgb=empty?[80,80,80]:(Array.isArray(slot.color)?slot.color:[128,128,128]);
var col='rgb('+rgb[0]+','+rgb[1]+','+rgb[2]+')';
var active=slot.status===1||slot.active;
var pct=slot.consumables_percent!=null?slot.consumables_percent+'%':'';
html+='<div class="ams-slot'+(active?' active':'')+ '" style="--slot-color:'+col+'">'
var pct=empty?T.ams_empty:(slot.consumables_percent!=null?slot.consumables_percent+'%':'');
html+='<div class="ams-slot'+(active?' active':'')+(empty?' empty':'')+ '" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+'">'
+'<div class="slot-circle" style="background:'+col+'"></div>'
+'<div class="slot-material">'+(slot.type||slot.material_type||'')+'</div>'
+'<div class="slot-material">'+(empty?'':(slot.type||slot.material_type||''))+'</div>'
+'<div class="slot-label">Slot '+(slot.index!=null?slot.index+1:i+1)+'</div>'
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>'
+'</div>';
@@ -1478,6 +1498,13 @@ function openSettings(){
function closeSettings(){
document.getElementById('settings-modal').classList.remove('open');
}
document.addEventListener('DOMContentLoaded',function(){
document.getElementById('s-printer-ip').addEventListener('input',function(){
var hint=document.getElementById('lbl-ip-hint');
if(this.value.includes(':')){hint.textContent=T.hint_ip_no_port;hint.style.display='block';}
else{hint.style.display='none';}
});
});
function setPoll(ms){
document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')});
var id='poll-'+Math.round(ms/1000);
@@ -1583,16 +1610,25 @@ function move(axis,dir,dist){
.catch(function(e){clog('Achse-Fehler: '+e,'msg-err')});
}
function homeAll(){
post('/api/axis',{axis:4,move_type:2,distance:0})
post('/api/axis',{axis:5,move_type:2,distance:0})
.then(function(){clog('Home All','msg-ok')})
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')});
}
function homeAxis(ax){
var m={X:1,Y:2,Z:3};
post('/api/axis',{axis:m[ax],move_type:2,distance:0})
.then(function(){clog('Home '+ax,'msg-ok')})
function homeXY(){
post('/api/axis',{axis:4,move_type:2,distance:0})
.then(function(){clog('Home XY','msg-ok')})
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')});
}
function homeZ(){
post('/api/axis',{axis:3,move_type:2,distance:0})
.then(function(){clog('Home Z','msg-ok')})
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')});
}
function disableMotors(){
post('/api/axis',{action:'turnOff'})
.then(function(){clog('Motors Off','msg-ok')})
.catch(function(e){clog('Motors-Fehler: '+e,'msg-err')});
}
// ── Temperature ──
function setNozzle(){
@@ -1800,15 +1836,21 @@ function toggleCam(){if(camOn)camStop();else camStart()}
body = await request.json()
except Exception:
body = {}
axis = int(body.get("axis", 4))
move_type = int(body.get("move_type", 2))
distance = float(body.get("distance", 0))
action = body.get("action", "move")
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: self.client.publish(
"axis", "move",
{"axis": axis, "move_type": move_type, "distance": distance},
timeout=0
))
if action == "turnOff":
await loop.run_in_executor(None, lambda: self.client.publish(
"axis", "turnOff", None, timeout=0
))
else:
axis = int(body.get("axis", 4))
move_type = int(body.get("move_type", 2))
distance = float(body.get("distance", 0))
await loop.run_in_executor(None, lambda: self.client.publish(
"axis", "move",
{"axis": axis, "move_type": move_type, "distance": distance},
timeout=0
))
return web.json_response({"result": "ok"})
async def handle_api_temperature(self, request):
@@ -1872,14 +1914,6 @@ function toggleCam(){if(camOn)camStop();else camStart()}
if not url:
return web.Response(status=503, text="Keine Kamera-URL bekannt")
boundary = "kobraxframe"
resp = web.StreamResponse(headers={
"Content-Type": f"multipart/x-mixed-replace;boundary={boundary}",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
})
await resp.prepare(request)
is_rtsp = url.lower().startswith("rtsp://")
ffmpeg_input_args = [
"-fflags", "nobuffer",
@@ -1888,22 +1922,38 @@ function toggleCam(){if(camOn)camStop();else camStart()}
if is_rtsp:
ffmpeg_input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
else:
# HTTP-FLV/HLS: braucht mehr Probe-Puffer für Container-Erkennung
ffmpeg_input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"]
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-loglevel", "quiet",
*ffmpeg_input_args,
"-i", url,
"-vf", "fps=15,scale=640:-1",
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "3",
"-flush_packets", "1",
"pipe:1",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
# ffmpeg erst starten BEVOR der StreamResponse geöffnet wird
# (damit wir bei Fehler noch eine normale HTTP-Response senden können)
try:
proc = await asyncio.create_subprocess_exec(
_find_ffmpeg(), "-loglevel", "quiet",
*ffmpeg_input_args,
"-i", url,
"-vf", "fps=15,scale=640:-1",
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "3",
"-flush_packets", "1",
"pipe:1",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
except (FileNotFoundError, OSError) as e:
log.warning("Kamera: ffmpeg nicht gefunden Kamerastream nicht verfügbar")
return web.Response(status=503, text="ffmpeg not found")
except Exception as e:
log.warning(f"Kamera: ffmpeg konnte nicht gestartet werden: {e}")
return web.Response(status=503, text=str(e))
boundary = "kobraxframe"
resp = web.StreamResponse(headers={
"Content-Type": f"multipart/x-mixed-replace;boundary={boundary}",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
})
await resp.prepare(request)
buf = b""
try:
@@ -2068,7 +2118,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
lines.append(line)
# Werte aktualisieren
mapping = {
"PRINTER_IP": str(data.get("printer_ip", existing.get("PRINTER_IP", ""))),
"PRINTER_IP": str(data.get("printer_ip", existing.get("PRINTER_IP", ""))).split(":")[0],
"MQTT_PORT": str(data.get("mqtt_port", existing.get("MQTT_PORT", "9883"))),
"MQTT_USERNAME": str(data.get("username", existing.get("MQTT_USERNAME",""))),
"MQTT_PASSWORD": str(data.get("password", existing.get("MQTT_PASSWORD",""))),
@@ -2099,7 +2149,12 @@ function toggleCam(){if(camOn)camStop();else camStart()}
def _restart_bridge(self):
log.info("Bridge wird neu gestartet …")
os.execv(sys.executable, [sys.executable] + sys.argv)
exe = sys.executable
# PyInstaller frozen binary: sys.argv[0] == sys.executable → nicht doppelt übergeben
if getattr(sys, "frozen", False):
os.execv(exe, [exe])
else:
os.execv(exe, [exe] + sys.argv)
# ─── Update ──────────────────────────────────────────────────────────────
@@ -2357,7 +2412,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
self._state["kobra_state"] = "free"
log.info("MQTT-Verbindung wiederhergestellt")
except Exception as e:
log.warning(f"Verbindungsaufbau fehlgeschlagen: {e}")
log.warning(f"Verbindungsaufbau fehlgeschlagen: {_mqtt_error_msg(e)}")
stop_event.wait(_probe_interval)
continue
else:
@@ -2400,6 +2455,13 @@ function toggleCam(){if(camOn)camStop();else camStart()}
# App factory + main
# ---------------------------------------------------------------------------
def _mqtt_error_msg(exc: Exception) -> str:
msg = str(exc)
if "20020005" in msg:
return "Falsche MQTT-Zugangsdaten (falscher Benutzername, Passwort oder Device-ID)"
return msg
def build_app(bridge: KobraXBridge) -> web.Application:
app = web.Application()
r = app.router
@@ -2480,7 +2542,7 @@ async def run_bridge(args):
await loop.run_in_executor(None, client.connect)
log.info("MQTT verbunden")
except Exception as e:
log.warning(f"Drucker nicht erreichbar ({e}) starte im Offline-Modus")
log.warning(f"Verbindung fehlgeschlagen: {_mqtt_error_msg(e)} starte im Offline-Modus")
bridge._state["print_state"] = "error"
bridge._state["kobra_state"] = "offline"
app = build_app(bridge)
@@ -2526,6 +2588,12 @@ def main():
parser.add_argument("--port", type=int, default=7125,
help="HTTP/WS-Port (Moonraker-Standard: 7125)")
args = parser.parse_args()
if args.printer_ip and ":" in args.printer_ip:
args.printer_ip = args.printer_ip.split(":")[0]
# Windows braucht ProactorEventLoop für asyncio.create_subprocess_exec
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
asyncio.run(run_bridge(args))

View File

@@ -1 +1,2 @@
aiohttp>=3.9
imageio-ffmpeg>=0.4.9

View File

@@ -34,12 +34,12 @@ else
print(int(datetime.datetime.fromisoformat(s).replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null || echo 0)
for f in Dockerfile \
05_scripts/kobrax_moonraker_bridge.py \
05_scripts/kobrax_client.py \
05_scripts/env_loader.py \
05_scripts/requirements.txt \
05_scripts/anycubic_slicer.crt \
05_scripts/anycubic_slicer.key; do
bridge/kobrax_moonraker_bridge.py \
bridge/kobrax_client.py \
bridge/env_loader.py \
bridge/requirements.txt \
bridge/anycubic_slicer.crt \
bridge/anycubic_slicer.key; do
if [[ -f "$f" ]]; then
FILE_TS=$(python3 -c "import os; print(int(os.path.getmtime('$f')))" 2>/dev/null || echo 0)
if [[ $FILE_TS -gt $IMAGE_TS ]]; then