Compare commits

..

23 Commits

Author SHA1 Message Date
ae4777187f docs: Netzwerkhinweis auf Host-IP statt 0.0.0.0 verbessert 2026-04-26 20:30:55 +02:00
8ccafb96c4 docs: Logo in englische README eingefügt 2026-04-26 20:25:37 +02:00
21f340271b docs: Projektname auf 'Klipper Bridge', Version auf 0.9.1-beta15 2026-04-26 20:24:17 +02:00
2f56a1f056 feat: Logo ins README hinzugefügt 2026-04-26 20:20:44 +02:00
c3a62a13c5 release: v0.9.1-beta15 2026-04-26 15:52:27 +02:00
4f1eaf7e93 fix: apt ffmpeg entfernt, imageio-ffmpeg übernimmt 2026-04-26 15:18:38 +02:00
fc681316fc release: v0.9.1-beta14 2026-04-26 14:58:20 +02:00
23756b82a9 release: v0.9.1-beta12 2026-04-25 22:21:22 +02:00
3df73e89e3 release: v0.9.1-beta11 2026-04-25 22:16:35 +02:00
4026fcc60c release: v0.9.1-beta10 2026-04-25 17:09:03 +02:00
3687c28239 fix: Dockerfile Pfade für Release-Repo (kein 05_scripts/-Präfix) 2026-04-25 16:58:11 +02:00
68b282d170 docs: Version auf 0.9.1-beta10 korrigieren 2026-04-25 14:19:38 +02:00
f265a30994 docs: LAN-Modus korrekt beschreiben 2026-04-25 14:08:16 +02:00
3d2ac7b931 release: v0.9.1-beta10 2026-04-25 14:06:54 +02:00
98303f1197 release: v0.9.1-beta9 2026-04-25 14:02:51 +02:00
808ac13ce0 release: v0.9.1-beta8 2026-04-25 13:55:45 +02:00
14400dd799 release: v0.9.1-beta7 2026-04-22 00:27:37 +02:00
0fb9a71390 release: v0.9.1-beta6 2026-04-22 00:13:31 +02:00
e81cdefd71 docs: Version auf 0.9.1-beta5 aktualisiert 2026-04-22 00:02:16 +02:00
a7469cce9a release: v0.9.1-beta5 2026-04-21 23:59:04 +02:00
8394f77767 docs: README aktualisiert + englische Version hinzugefügt 2026-04-21 23:57:31 +02:00
c97253597b release: v0.9.1-beta4 2026-04-21 22:31:39 +02:00
3ceee973c5 release: v0.9.1-beta3 2026-04-21 22:27:01 +02:00
15 changed files with 1183 additions and 301 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,12 +2,14 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
COPY 05_scripts/requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY 05_scripts/kobrax_moonraker_bridge.py . COPY kobrax_moonraker_bridge.py .
COPY 05_scripts/env_loader.py . COPY env_loader.py .
COPY 05_scripts/kobrax_client.py . COPY kobrax_client.py .
COPY anycubic_slicer.crt .
COPY anycubic_slicer.key .
EXPOSE 7125 EXPOSE 7125

258
README.en.md Normal file
View File

@@ -0,0 +1,258 @@
<p align="center"><img src="knlogo.png" alt="KX-Bridge Logo" width="180"/></p>
# 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.
---
## What's supported?
- Printer status (temperature, progress, state)
- File transfer and print start
- Print control: pause, resume, cancel
- Temperature control during an active print
- Print speed (Silent / Normal / Sport)
- AMS filament change (load / unload)
- Light and fan control
- Web UI with dashboard, temperature cards, axis control, and camera view
- Settings and self-update directly in the browser (⚙ menu)
- OrcaSlicer connection (Moonraker protocol)
---
## Requirements
- 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
---
## Quick start Docker (recommended)
```bash
# 1. Start the bridge
./start.sh
```
`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
```
Stop:
```bash
docker-compose down
```
---
## Quick start Binary (Linux)
```bash
chmod +x kx-bridge
./kx-bridge
```
Open the web UI: `http://localhost:7125`
→ Settings (⚙) open automatically and guide you through the initial setup.
---
## Quick start Python directly
```bash
pip install aiohttp
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 (printer status is shown).
### Windows
```
extract_credentials.exe
```
### Linux
```bash
chmod +x extract_credentials
./extract_credentials
```
### Output
```
[*] Process found: AnycubicSlicerNext.exe (PID 1234)
[*] 1986 memory segments read (738.8 MB)
[*] Analyzing ... 100% (739 MB)
=======================================================
RESULTS
=======================================================
Username userXXXXXXXXXX (hits: 47)
Password *************** (hits: 1046)
Device-ID xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (hits: 3504)
Printer IP 192.168.x.x (hits: 3036)
=======================================================
```
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.
---
## Configuration (.env)
```env
PRINTER_IP=192.168.x.x # Printer IP address
MQTT_PORT=9883 # Default, do not change
MQTT_USERNAME=userXXXXXXXX # Starts with "user"
MQTT_PASSWORD=XXXXXXXXXXXXXX # ~15 characters, mixed case
DEVICE_ID=xxxxxxxx... # 32-character hex string
MODE_ID=20030 # Kobra X default
```
---
## OrcaSlicer setup
1. Add printer → **Anycubic Kobra X** (or generic Klipper printer)
2. Connection type: **Moonraker**
3. Host: `http://BRIDGE-HOST:7125`
4. Test connection → should show "Online"
---
## Web UI
The bridge serves a web interface at `http://BRIDGE-HOST:7125`:
| Section | Function |
|---------|----------|
| Dashboard | Printer status, progress, temperature overview |
| Temperatures | Set nozzle and bed temperature directly |
| Motion | X/Y/Z movement, motor release |
| Print Speed | Silent / Normal / Sport |
| Fan / Light | Fan speed and work light |
| AMS | Load / unload filament |
| Camera | Live preview (if supported by printer) |
| ⚙ Settings | MQTT credentials, poll interval, self-update |
### Self-update
The ⚙ menu in the web UI lets you check for new versions and update the bridge in place — no reinstallation needed. After the download the bridge restarts automatically with the new version.
---
## bridge.sh (Linux service manager)
```bash
./bridge.sh start # Start bridge in background
./bridge.sh stop # Stop bridge
./bridge.sh restart # Restart
./bridge.sh status # Check status and port
./bridge.sh log 50 # Show last 50 log lines
```
---
## Printer states
The bridge translates internal Kobra states into Moonraker-compatible states:
| Kobra state | Meaning |
|-------------|---------|
| free | Ready |
| printing / busy | Printing |
| pausing / paused | Paused |
| resuming / resumed | Resuming |
| stopping / stoped | Stopping |
| finished | Complete |
| canceled | Cancelled |
| failed | Error |
---
## Troubleshooting
**Port 7125 already in use:**
```bash
./bridge.sh stop # or: fuser -k 7125/tcp
./bridge.sh start
```
**Invalid credentials / connection refused:**
- Start AnycubicSlicerNext, connect to the printer, then run `extract_credentials` again
- If the result looks uncertain: `extract_credentials --verbose` shows all candidates
- Manually enter the correct candidate in `.env` and restart the bridge
**Temperature changes are ignored:**
- During an active print, temperature changes are sent via a separate channel — this is normal and the bridge handles it automatically.
**Docker: Permission denied:**
```bash
sudo usermod -aG docker $USER
# Log out and back in
```
**Docker: .env not found:**
```bash
# .env must be in the same directory as docker-compose.yml
cp .env.example .env && nano .env
```
---
## Logs
```bash
# Docker
docker compose logs -f kx-bridge
# Binary / Python
tail -f /tmp/bridge.log # when using bridge.sh
```
---
## Security notes
- 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
---
## License & legal
This project was created through interoperability research under §69e UrhG (German copyright law).
For private, non-commercial use only.

297
README.md
View File

@@ -1,185 +1,258 @@
# 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 Schnittstelle bereit, über die OrcaSlicer den Drucker direkt steuern kann: Druckstart, Temperatur, Fortschritt, AMS-Farbwechsel. **Version:** 0.9.1-beta15
**Status:** Public Beta für Heimanwender geeignet, Feedback willkommen
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.
--- ---
## Enthaltene Dateien ## Was wird unterstützt?
| Datei | Beschreibung | - Druckerstatus (Temperatur, Fortschritt, Zustand)
|-------|-------------| - Dateiübertragung und Druckstart
| `kobrax_moonraker_bridge.py` | Bridge-Hauptprogramm | - Drucksteuerung: Pause, Fortsetzen, Abbrechen
| `extract_credentials.exe` | Zugangsdaten aus AnycubicSlicerNext auslesen (Windows) | - Temperaturregelung während des laufenden Drucks
| `extract_credentials` | Zugangsdaten aus AnycubicSlicerNext auslesen (Linux) | - Druckgeschwindigkeit (Leise / Normal / Sport)
| `kobra_x_orcaslicer_preset.zip` | OrcaSlicer-Druckerprofil für den Kobra X | - AMS-Farbwechsel (Einziehen / Ausziehen)
| `bridge.sh` | Service-Manager für Linux | - Licht- und Lüftersteuerung
| `Dockerfile` / `docker-compose.yml` | Docker-Deployment | - Web-UI mit Dashboard, Temperaturkarten, Achsensteuerung und Kameraansicht
| `.env.example` | Konfigurationsvorlage | - Einstellungen und Self-Update direkt im Browser (⚙-Menü)
- OrcaSlicer-Verbindung (Moonraker-Protokoll)
--- ---
## Voraussetzungen ## Voraussetzungen
- Anycubic Kobra X im LAN-Modus (Drucker muss über LAN erreichbar sein, nicht nur über Anycubic-Cloud) - Anycubic Kobra X im lokalen Netzwerk, mit aktiviertem **LAN-Modus** (Drucker-Menü → LAN-Modus einschalten)
- PC, NAS oder Server im gleichen Netzwerk (Windows oder Linux) - MQTT-Credentials des Druckers (→ siehe [Credentials extrahieren](#credentials-extrahieren))
- Docker oder Python 3.9+ - Docker **oder** Python 3.9+ **oder** direkt die Linux-Binary
- MQTT-Zugangsdaten des Druckers → [Schritt 1](#schritt-1-zugangsdaten-ermitteln)
--- ---
## Schnellstart ## Schnellstart Docker (empfohlen)
### 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.
Die Zugangsdaten werden mit `extract_credentials` aus dem laufenden AnycubicSlicerNext ausgelesen.
Vorbereitung: AnycubicSlicerNext starten und mit dem Drucker verbinden (bis der Drucker-Status angezeigt wird).
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
```bash ```bash
cp .env.example .env # 1. Bridge starten
# .env öffnen und Werte kontrollieren ./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 ```bash
chmod +x kx-bridge chmod +x kx-bridge
./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 ```bash
pip install aiohttp pip install aiohttp
python kobrax_moonraker_bridge.py python kobrax_moonraker_bridge.py
``` ```
--- Web-UI öffnen: `http://localhost:7125`
→ Einstellungen (⚙) öffnen sich automatisch beim ersten Start.
### 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
--- ---
### 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.
2. Verbindungstyp: Moonraker
3. Adresse: `http://IP-DES-BRIDGE-PC:7125` eintragen
4. Auf "Test" klicken bei erfolgreicher Verbindung erscheint eine Bestätigungsmeldung
--- **Voraussetzung:** AnycubicSlicerNext muss gestartet und mit dem Drucker verbunden sein (Drucker-Status wird angezeigt).
## bridge.sh Service-Manager (Linux) ### Windows
```
extract_credentials.exe
```
### Linux
```bash ```bash
./bridge.sh start # Im Hintergrund starten chmod +x extract_credentials
./bridge.sh stop # Beenden ./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. IP: `http://BRIDGE-HOST:7125`
4. Verbindung testen → sollte "Online" anzeigen
---
## Web-UI
Die Bridge stellt unter `http://BRIDGE-HOST:7125` eine Web-Oberfläche bereit:
| Bereich | Funktion |
|---------|----------|
| Dashboard | Druckerstatus, Fortschritt, Temperaturübersicht |
| Temperaturen | Nozzle und Bett direkt setzen |
| Achsen | X/Y/Z-Bewegung, Motorfreigabe |
| Druckgeschwindigkeit | Leise / Normal / Sport |
| Lüfter / Licht | Lüfterdrehzahl und Drucklicht |
| AMS | Filament einziehen / ausziehen |
| 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 die Bridge automatisch mit der neuen Version neu.
---
## bridge.sh (Linux Service-Manager)
```bash
./bridge.sh start # Bridge im Hintergrund starten
./bridge.sh stop # Bridge beenden
./bridge.sh restart # Neustarten ./bridge.sh restart # Neustarten
./bridge.sh status # Status anzeigen ./bridge.sh status # Status und Port prüfen
./bridge.sh log 50 # Letzte 50 Log-Zeilen ./bridge.sh log 50 # Letzte 50 Log-Zeilen
``` ```
--- ---
## Docker Nützliche Befehle ## Druckerzustände
```bash Die Bridge übersetzt die internen Kobra-Zustände in Moonraker-kompatible Zustände:
docker compose up -d # Starten
docker compose down # Stoppen | Kobra-Zustand | Bedeutung |
docker compose logs -f # Logs verfolgen |---------------|-----------|
docker compose pull && docker compose up -d # Update | free | Bereit |
``` | printing / busy | Druckt |
| pausing / paused | Pausiert |
| resuming / resumed | Wird fortgesetzt |
| stopping / stoped | Wird gestoppt |
| finished | Abgeschlossen |
| canceled | Abgebrochen |
| failed | Fehler |
--- ---
## Fehlerbehebung ## Fehlerbehebung
Port 7125 belegt: **Port 7125 bereits belegt:**
```bash ```bash
./bridge.sh stop ./bridge.sh stop # oder: fuser -k 7125/tcp
./bridge.sh start ./bridge.sh start
``` ```
Verbindungstest in OrcaSlicer schlägt fehl: **Credentials ungültig / Verbindung abgelehnt:**
- Firewall prüfen: Port 7125 muss erreichbar sein - AnycubicSlicerNext starten, mit Drucker verbinden, `extract_credentials` erneut ausführen
- Bridge-Log prüfen: `./bridge.sh log` oder `docker compose logs` - Falls das Ergebnis unsicher wirkt: `extract_credentials --verbose` zeigt alle Kandidaten an
- Drucker-IP in `.env` korrekt? - Den richtigen Kandidaten manuell in `.env` eintragen und Bridge neu starten
Zugangsdaten werden abgelehnt: **Temperaturänderungen werden ignoriert:**
- AnycubicSlicerNext starten, mit Drucker verbinden - Während eines laufenden Drucks werden Temperaturänderungen über einen separaten Kanal gesendet — das ist normal und wird von der Bridge automatisch erkannt.
- `extract_credentials --verbose` ausführen und alle Kandidaten prüfen
- Richtigen Wert manuell in `.env` eintragen, Bridge neu starten
Docker: Permission denied: **Docker: Permission denied:**
```bash ```bash
sudo usermod -aG docker $USER 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 | ```bash
|-----------|-------------|---------| # Docker
| `PRINTER_IP` | IP-Adresse des Druckers | `192.168.1.100` | docker compose logs -f kx-bridge
| `MQTT_PORT` | MQTT-Port (nicht ändern) | `9883` |
| `MQTT_USERNAME` | Benutzername (beginnt mit „user") | `userXXXXXXXXXX` | # Binary / Python
| `MQTT_PASSWORD` | Passwort (~15 Zeichen) | `***` | tail -f /tmp/bridge.log # bei Nutzung von bridge.sh
| `DEVICE_ID` | Geräte-ID (32 Hex-Zeichen) | `xxxxxxxx...` | ```
| `MODE_ID` | Modell-ID (Kobra X Standard) | `20030` |
--- ---
## Sicherheitshinweise ## Sicherheitshinweise
- Alle Zugangsdaten werden ausschließlich lokal verarbeitet - Die Bridge ist im lokalen Netzwerk erreichbar unter `http://<Host-IP>:7125` — nicht ins Internet freigeben
- `.env` enthält Drucker-Credentials — nicht öffentlich teilen
- 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). Dieses Projekt entstand durch Interoperabilitätsforschung gem. §69e UrhG.
Ausschließlich für private, nicht-kommerzielle Nutzung.
`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 Tool funktioniert nur für den Prozess des Druckers, dem du selbst gehörst.
Das Projekt steht in keiner Verbindung zu Anycubic und wird nicht kommerziell betrieben.

View File

@@ -1 +1 @@
0.9.1-beta2 0.9.1-beta15

24
anycubic_slicer.crt Normal file
View File

@@ -0,0 +1,24 @@
-----BEGIN CERTIFICATE-----
MIIEDTCCAvWgAwIBAgICAZAwDQYJKoZIhvcNAQEFBQAwgZsxCzAJBgNVBAYTAkNO
MRIwEAYDVQQIDAlHdWFuZ2RvbmcxETAPBgNVBAcMCFNoZW56aGVuMREwDwYDVQQK
DAhBbnljdWJpYzERMA8GA1UECwwIQW55Y3ViaWMxEzARBgNVBAMMCkFDIFJvb3Qg
Q0ExKjAoBgkqhkiG9w0BCQEWG2FueWN1YmljX2Nsb3VkQGFueWN1YmljLmNvbTAg
Fw0yMzA3MjAwMzI3NTFaGA8yMTIzMDcyMTAzMjc1MVowgZ8xCzAJBgNVBAYTAkNO
MRIwEAYDVQQIDAlHdWFuZ2RvbmcxETAPBgNVBAcMCFNoZW56aGVuMREwDwYDVQQK
DAhBbnljdWJpYzERMA8GA1UECwwIQW55Y3ViaWMxFzAVBgNVBAMMDkFueWN1Ymlj
U2xpY2VyMSowKAYJKoZIhvcNAQkBFhthbnljdWJpY19jbG91ZEBhbnljdWJpYy5j
b20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDdoQ7g2F/yecfpdlqT
b8W/84r3vQ4ZEWx2PbSTBcGD55HmzJp2lwABHFHbn4CltT9YzoJWpOiVMHYnyPep
43tkNUIcGm7z0jrTD5djyYjVAzEitkNzJspKK/xcVmZe/V7Q3IAWXtzgWCd0YpVk
K3J0HqoqJvcTSnYe4VXxbIGwbpeYyji9W/DuG1M4Z+sFiPDWeR9xo5IXRU5ZwaTP
8OiCCLSBbeKgf0UFWTIZdJ1JXJ7efbbstZOjf5L9LhBIC0hLdL4jlMpF7r0ThecJ
cTx9Bnw/hhy+i32rJTRzZDIaLhKg/bka9ZrORZdxxQRiPoMjLjoxtr4+AUaeLWkI
ajSJAgMBAAGjUzBRMB0GA1UdDgQWBBRI4P3/uKdYYFPEcFIwYxdv1p9gETAfBgNV
HSMEGDAWgBQlkDqpFERfr3u1rR9gNbNKtgrHIjAPBgNVHRMBAf8EBTADAQH/MA0G
CSqGSIb3DQEBBQUAA4IBAQBP3ws80Y9eBR2lpjYP3rVvH8kA6+LnEXT4PpHj+fSw
jciaNskzpiwNvBy00m32ACR5YKlMUjevlQuyyw+LQbTUwAEOwyy9SDQpiXdjL6q3
SPQ4aB4A57nFXOGrthc/nb9yFcteWrZrKbwvVUu2vqU7U8n7lJKjhVuFRWSXS3SV
sPc9JZ21kpPYWKbGtfD6jUlW0Ip+PurLw9FrbVwnEcOMf/ezSlrH5c8mfJyo8pVk
aC/6PpReqijusOSRZ5oLyhPvtgddXseJFByun1Ud0CDlFA05nGGPmnVcXD+GMnHH
i6baCTeifwp5Jpdzv4imcCPvayKUNuX32vYNfNkWC/R5
-----END CERTIFICATE-----

28
anycubic_slicer.key Normal file
View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdoQ7g2F/yecfp
dlqTb8W/84r3vQ4ZEWx2PbSTBcGD55HmzJp2lwABHFHbn4CltT9YzoJWpOiVMHYn
yPep43tkNUIcGm7z0jrTD5djyYjVAzEitkNzJspKK/xcVmZe/V7Q3IAWXtzgWCd0
YpVkK3J0HqoqJvcTSnYe4VXxbIGwbpeYyji9W/DuG1M4Z+sFiPDWeR9xo5IXRU5Z
waTP8OiCCLSBbeKgf0UFWTIZdJ1JXJ7efbbstZOjf5L9LhBIC0hLdL4jlMpF7r0T
hecJcTx9Bnw/hhy+i32rJTRzZDIaLhKg/bka9ZrORZdxxQRiPoMjLjoxtr4+AUae
LWkIajSJAgMBAAECggEASwRkC9lRiLqN30kvWW5g6hsec8KrTfLm2pMCVy2AlgxB
B3VD51YvKzERyBwSKITT/1RPK9K/4xe3NrpAkmGsd3vLd8W+vorvXFePr7gct7VP
4Wb+J7D+keKXlg2sswRiHqI0PN45Nzq/iBaCaJiIMiPbB0+PHBl9J/Cv7XsD3tq+
9WKhvXf2g1g9GMrLaCCcWXWCqcu0LlbqJnw3yMnJLSltmyFTmlVLjDHM75bMVz97
4emQzOlnRN2yA5cWWCaM+mgjNM2aWwUsXBZzCgwSqSaj1QD4B/epCuDBORWHS9D6
jL15w8xjly9q8OS+4d6beR5h9GiPyMK4Ff2wXImCXQKBgQDwXxtrL+kVZrQ/qftj
24F3+QDN0j5Z3lUMTfZPn6ng/E/aBfn8KcWJHj2vYkKZdB5wOXJr56BYe3Hukzfp
QF0E2+g1WAGskF1mb/vVab54geox5Y6CA+ionRn2kcCwybVkktR/0JK2UV9Qjb/z
k1WU+RUhNrW/GDBqYulaadnR+wKBgQDsCf2/yKGPxj4pIvAtn5RFSlfscddgkSnc
ouBkDXEp5ta+5PGrlrdzS/F0vFhvBPbfbVJxVwRnM/Oqj8c0/bj7oc5RpPxirciO
AaovKVPTiORaviytnB2HgkflkJfy5vdXv4ZQahAV/UwtSmLwBshe+Ya68MAFrQRa
7M4z6k4QSwKBgQCm7OVVoofzXMeADsONrTpT3pA4XvD95/CYAuwyj2ah35Z0igH4
o+mSN3YO/eXSO1mIBdz4Inqv98o/K+2ABjqSzUSNBvjipb63DL2Oj0i+1zmUPR6i
G6TOs4r8OGvgWbOmjHEV8fpwskHG5ymONZsRQYjy79N3SY0V1GrJZwjlUQKBgD0x
AeWcP7YkMK09b4KEYk3sTgrwIGPafj3Cw+VsTrAMNhPbCoPvWLO9NmWLBmoRoWae
0sarRmry3vKSv5QPSsuBURl9aiiy4NFfwRzk2+R1Eq4rqy1+0XD152muKJZCJlFL
R6jFNlJdDkiXhjqvp3ZnvfPswfs2tXBU/8gZsA8tAoGBALXfc5m9I5R1l1zN7tpa
ncA0S3EKzqmuCc3KzlS6OS0e9Lz1MsmfEsvxvW3w4SrdfTbwQpEy9RNg89dlgPtc
rdId1QdN2eWPY5M4lz9n9EYdzi9ufoKAEYu2a0lP+qz690JwmL1Jx49bvQEn5Nu0
4swn72uwBRlhjAw46MF77SBQ
-----END PRIVATE KEY-----

View File

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

View File

@@ -3,12 +3,14 @@ env_loader.py lädt Verbindungsparameter aus .env (Repo-Root oder Arbeitsver
Umgebungsvariablen haben Vorrang vor .env-Werten. Umgebungsvariablen haben Vorrang vor .env-Werten.
""" """
import os import os
import sys
import pathlib import pathlib
_BASE = pathlib.Path(sys.executable).parent if getattr(sys, "frozen", False) else pathlib.Path(__file__).parent
def _find_env_file() -> pathlib.Path | None: def _find_env_file() -> pathlib.Path | None:
# Suche .env im selben Verzeichnis, dann im Parent (Repo-Root) for base in (_BASE, _BASE.parent):
for base in (pathlib.Path(__file__).parent, pathlib.Path(__file__).parent.parent):
p = base / ".env" p = base / ".env"
if p.is_file(): if p.is_file():
return p return p

View File

@@ -1,23 +1,23 @@
""" """
extract_credentials.py Extrahiert Anycubic LAN-MQTT-Credentials aus dem RAM extract_credentials.py Extracts Anycubic LAN-MQTT credentials from the RAM
des laufenden AnycubicSlicerNext-Prozesses. of the running AnycubicSlicerNext process.
Voraussetzungen: Requirements:
- AnycubicSlicerNext läuft und ist mit dem Drucker verbunden - AnycubicSlicerNext is running and connected to the printer
- Gleiches Benutzerkonto wie der Slicer-Prozess (kein Admin nötig) - Same user account as the slicer process (no admin required)
Verwendung: Usage:
python3 extract_credentials.py [--write-env] [--env-file ../.env] python3 extract_credentials.py [--write-env] [--env-file ../.env]
Funktionsweise: How it works:
1. Prozess "AnycubicSlicer.exe" (Windows) bzw. "AnycubicSlicer" (Linux) finden 1. Find process "AnycubicSlicer.exe" (Windows) or "AnycubicSlicer" (Linux)
2. Speicherseiten des Prozesses durchsuchen (nur r/rw, keine Exec-Pages) 2. Scan memory pages of the process (only r/rw, no exec pages)
3. Nach MQTT-Credential-Patterns suchen: 3. Search for MQTT credential patterns:
Username: user[A-Za-z0-9]{8,12} Username: user[A-Za-z0-9]{8,12}
Password: [A-Za-z0-9+/]{13,18} Password: [A-Za-z0-9+/]{13,18}
Drucker-IP: d{1,3}.d{1,3}.d{1,3}.d{1,3} Printer IP: d{1,3}.d{1,3}.d{1,3}.d{1,3}
4. Kandidaten nach Plausibilität filtern und ausgeben 4. Filter candidates by plausibility and print results
5. Optional: .env-Datei schreiben 5. Optionally write .env file
""" """
import argparse import argparse
@@ -28,48 +28,48 @@ import sys
import platform import platform
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Plattform-Erkennung # Platform detection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
IS_WINDOWS = platform.system() == "Windows" IS_WINDOWS = platform.system() == "Windows"
IS_LINUX = platform.system() == "Linux" 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]|$)') 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) # Password: 1320 alphanumeric characters (no / since no RTSP path)
# Anycubic-Passwörter: gemischte Groß/Klein/Ziffern, kein Slash # Anycubic passwords: mixed upper/lower/digits, no slash
RE_PASSWORD = re.compile(rb'[A-Za-z0-9]{13,20}(?=[^A-Za-z0-9]|$)') 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) 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( 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]|$)', rb'(user[A-Za-z0-9]{8,12}).{1,512}?([A-Za-z0-9]{13,20})(?=[^A-Za-z0-9]|$)',
re.DOTALL 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])') 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)') 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]|$)') 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": 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
import ctypes.wintypes import ctypes.wintypes
@@ -110,7 +110,7 @@ def _win_find_pid(name: str) -> "int | None":
def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]": 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
import ctypes.wintypes import ctypes.wintypes
@@ -134,7 +134,7 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]":
k32 = ctypes.windll.kernel32 k32 = ctypes.windll.kernel32
handle = k32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid) handle = k32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not handle: if not handle:
raise PermissionError(f"OpenProcess fehlgeschlagen (PID {pid}): {ctypes.GetLastError()}") raise PermissionError(f"OpenProcess failed (PID {pid}): {ctypes.GetLastError()}")
chunks = [] chunks = []
addr = 0 addr = 0
@@ -147,7 +147,7 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]":
mbi.State != MEM_COMMIT or mbi.State != MEM_COMMIT or
mbi.Protect & PAGE_NOACCESS or mbi.Protect & PAGE_NOACCESS or
mbi.Protect & PAGE_GUARD 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: if not skip:
buf = ctypes.create_string_buffer(mbi.RegionSize) 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)): buf, mbi.RegionSize, ctypes.byref(read)):
chunks.append(bytes(buf[:read.value])) chunks.append(bytes(buf[:read.value]))
addr += mbi.RegionSize addr += mbi.RegionSize
if addr >= 0x7FFFFFFFFFFF: # Ende des User-Space (64-bit) if addr >= 0x7FFFFFFFFFFF: # end of user space (64-bit)
break break
finally: finally:
k32.CloseHandle(handle) 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": 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"): for entry in os.listdir("/proc"):
if not entry.isdigit(): if not entry.isdigit():
continue continue
@@ -183,7 +183,7 @@ def _linux_find_pid(name: str) -> "int | None":
def _linux_read_memory(pid: int) -> "list[bytes]": def _linux_read_memory(pid: int) -> "list[bytes]":
"""Liest lesbare Speichersegmente aus /proc/{pid}/mem.""" """Read readable memory segments from /proc/{pid}/mem."""
chunks = [] chunks = []
maps_path = f"/proc/{pid}/maps" maps_path = f"/proc/{pid}/maps"
mem_path = f"/proc/{pid}/mem" mem_path = f"/proc/{pid}/mem"
@@ -193,8 +193,8 @@ def _linux_read_memory(pid: int) -> "list[bytes]":
mem = open(mem_path, "rb") mem = open(mem_path, "rb")
except PermissionError: except PermissionError:
raise PermissionError( raise PermissionError(
f"Kein Zugriff auf /proc/{pid}/mem — " f"No access to /proc/{pid}/mem — "
"Script als gleicher Benutzer wie der Slicer starten." "run the script as the same user as the slicer process."
) )
for line in maps: for line in maps:
@@ -202,16 +202,16 @@ def _linux_read_memory(pid: int) -> "list[bytes]":
if len(parts) < 2: if len(parts) < 2:
continue continue
perms = parts[1] perms = parts[1]
if "r" not in perms: # nur lesbare Seiten if "r" not in perms: # readable pages only
continue continue
if "x" in perms: # Code-Seiten überspringen (keine Strings) if "x" in perms: # skip code pages (no strings)
continue continue
try: try:
start, end = [int(x, 16) for x in parts[0].split("-")] start, end = [int(x, 16) for x in parts[0].split("-")]
except ValueError: except ValueError:
continue continue
size = end - start size = end - start
if size > 256 * 1024 * 1024: # >256 MB überspringen if size > 256 * 1024 * 1024: # skip >256 MB regions
continue continue
try: try:
mem.seek(start) 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: 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: def search_chunks(chunks: "list[bytes]") -> dict:
"""Durchsucht Speicher-Chunks nach Credential-Patterns.""" """Search memory chunks for credential patterns."""
usernames = {} # value → count usernames = {} # value → count
passwords = {} passwords = {}
ips = {} ips = {}
@@ -256,12 +256,12 @@ def search_chunks(chunks: "list[bytes]") -> dict:
if i % 50 == 0 or i == total - 1: if i % 50 == 0 or i == total - 1:
pct = (i + 1) * 100 // total pct = (i + 1) * 100 // total
mb_done = sum(len(c) for c in chunks[:i+1]) / 1024 / 1024 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): for m in RE_USERNAME.finditer(chunk):
v = m.group().decode("ascii", errors="replace") v = m.group().decode("ascii", errors="replace")
usernames[v] = usernames.get(v, 0) + 1 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): for m in RE_USER_PASS_PROXIMITY.finditer(chunk):
pw = m.group(2).decode("ascii", errors="replace") pw = m.group(2).decode("ascii", errors="replace")
has_upper = any(c.isupper() for c in pw) 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): for m in RE_PASSWORD.finditer(chunk):
v = m.group().decode("ascii", errors="replace") 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_upper = sum(1 for c in v if c.isupper()) >= 2
has_lower = sum(1 for c in v if c.islower()) >= 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 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") v = m.group().decode("ascii")
device_ids[v] = device_ids.get(v, 0) + 1 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): def top(d, n=10):
return sorted(d.items(), key=lambda x: -x[1])[:n] 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 = [ SLICER_NAMES = [
@@ -359,95 +359,95 @@ def write_env(results: dict, env_path: str,
with open(env_path, "w") as f: with open(env_path, "w") as f:
f.writelines(lines) f.writelines(lines)
print(f"\n✓ Credentials in '{env_path}' gespeichert.") print(f"\n✓ Credentials saved to '{env_path}'.")
def main(): def main():
parser = argparse.ArgumentParser( 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", 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, 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, 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", 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() args = parser.parse_args()
# .env-Pfad bestimmen # Determine .env path
if args.env_file: if args.env_file:
env_path = args.env_file env_path = args.env_file
else: else:
env_path = os.path.join(os.path.dirname(__file__), "..", ".env") env_path = os.path.join(os.path.dirname(__file__), "..", ".env")
env_path = os.path.normpath(env_path) env_path = os.path.normpath(env_path)
# Prozess finden # Find process
if args.pid: if args.pid:
pid, proc_name = args.pid, f"PID {args.pid}" pid, proc_name = args.pid, f"PID {args.pid}"
else: else:
print("[*] Suche AnycubicSlicer-Prozess ...") print("[*] Searching for AnycubicSlicer process ...")
pid, proc_name = find_slicer_pid() pid, proc_name = find_slicer_pid()
if not pid: if not pid:
print("✗ AnycubicSlicer nicht gefunden. Bitte den Slicer starten und " print("✗ AnycubicSlicer not found. Please start the slicer, connect it "
"mit dem Drucker verbinden, dann erneut ausführen.") "to the printer, then run this script again.")
sys.exit(1) sys.exit(1)
print(f"[*] Prozess gefunden: {proc_name} (PID {pid})") print(f"[*] Process found: {proc_name} (PID {pid})")
print(f"[*] Lese Prozess-Speicher ...") print(f"[*] Reading process memory ...")
try: try:
chunks = read_process(pid) chunks = read_process(pid)
except PermissionError as e: except PermissionError as e:
print(f"Zugriffsfehler: {e}") print(f"Permission error: {e}")
sys.exit(1) sys.exit(1)
total_mb = sum(len(c) for c in chunks) / 1024 / 1024 total_mb = sum(len(c) for c in chunks) / 1024 / 1024
print(f"[*] {len(chunks)} Speichersegmente gelesen ({total_mb:.1f} MB)") print(f"[*] {len(chunks)} memory segments read ({total_mb:.1f} MB)")
print(f"[*] Durchsuche nach Credentials ...") print(f"[*] Searching for credentials ...")
results = search_chunks(chunks) results = search_chunks(chunks)
# Ausgabe # Output
print("\n" + "="*55) print("\n" + "="*55)
print(" ERGEBNISSE") print(" RESULTS")
print("="*55) print("="*55)
def show(label, items, verbose): def show(label, items, verbose):
if not items: if not items:
print(f" {label:12s} — nicht gefunden") print(f" {label:12s} — not found")
return items[0][0] if items else "" return items[0][0] if items else ""
best = items[0][0] 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: if verbose and len(items) > 1:
for val, cnt in items[1:]: for val, cnt in items[1:]:
print(f" {'':12s} {val} (Treffer: {cnt})") print(f" {'':12s} {val} (matches: {cnt})")
return best return best
best_user = show("Username", results["usernames"], args.verbose) best_user = show("Username", results["usernames"], args.verbose)
best_pass = show("Password", results["passwords"], args.verbose) best_pass = show("Password", results["passwords"], args.verbose)
best_device = show("Device-ID", results["device_ids"], 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"] lan_ips = [(ip, cnt) for ip, cnt in results["ips"]
if ip.startswith("192.168.") or ip.startswith("10.") or ip.startswith("172.")] if ip.startswith("192.168.") or ip.startswith("10.") or ip.startswith("172.")]
if not lan_ips: if not lan_ips:
lan_ips = results["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) print("="*55)
if not best_user or not best_pass: if not best_user or not best_pass:
print("\nKeine vollständigen Credentials gefunden.") print("\nNo complete credentials found.")
print(" Stelle sicher dass der Slicer MIT dem Drucker verbunden ist.") print(" Make sure the slicer is connected to the printer.")
sys.exit(1) sys.exit(1)
if args.write_env: if args.write_env:
write_env(results, env_path, best_user, best_pass, best_ip, write_env(results, env_path, best_user, best_pass, best_ip,
device_id=best_device) device_id=best_device)
else: 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__": if __name__ == "__main__":

BIN
knlogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -20,6 +20,7 @@ import json
import os import os
import socket import socket
import ssl import ssl
import sys
import threading import threading
import time import time
import uuid import uuid
@@ -27,7 +28,7 @@ from datetime import datetime
import env_loader import env_loader
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) _SCRIPT_DIR = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os.path.dirname(os.path.abspath(__file__))
CERT_FILE = os.path.join(_SCRIPT_DIR, "anycubic_slicer.crt") CERT_FILE = os.path.join(_SCRIPT_DIR, "anycubic_slicer.crt")
KEY_FILE = os.path.join(_SCRIPT_DIR, "anycubic_slicer.key") KEY_FILE = os.path.join(_SCRIPT_DIR, "anycubic_slicer.key")
@@ -197,6 +198,7 @@ class KobraXClient:
def _read_loop(self): def _read_loop(self):
last_ping = time.time() last_ping = time.time()
_empty_count = 0
while self._running: while self._running:
if time.time() - last_ping > 30: if time.time() - last_ping > 30:
with self._lock: with self._lock:
@@ -211,7 +213,12 @@ class KobraXClient:
try: try:
data = self._sock.recv(65536) data = self._sock.recv(65536)
if not data: if not data:
# Windows SSL kann kurzzeitig b"" liefern ohne echten EOF
_empty_count += 1
if _empty_count >= 5:
raise ConnectionResetError("EOF") raise ConnectionResetError("EOF")
continue
_empty_count = 0
self._buf += data self._buf += data
self._drain() self._drain()
except ssl.SSLWantReadError: except ssl.SSLWantReadError:
@@ -394,14 +401,14 @@ class KobraXClient:
def stop_camera(self) -> dict | None: def stop_camera(self) -> dict | None:
return self.publish("video", "stopCapture") return self.publish("video", "stopCapture")
def pause_print(self) -> dict | None: def pause_print(self, taskid: str = "-1") -> dict | None:
return self.publish("print", "pause") return self.publish("print", "pause", {"taskid": taskid})
def resume_print(self) -> dict | None: def resume_print(self, taskid: str = "-1") -> dict | None:
return self.publish("print", "resume") return self.publish("print", "resume", {"taskid": taskid})
def stop_print(self) -> dict | None: def stop_print(self, taskid: str = "-1") -> dict | None:
return self.publish("print", "stop") return self.publish("print", "stop", {"taskid": taskid})
# -- G-Code Upload ------------------------------------------------------- # -- G-Code Upload -------------------------------------------------------

View File

@@ -24,10 +24,24 @@ import tempfile
import time import time
import threading import threading
# kobrax_client aus dem selben Verzeichnis importieren # Bei PyInstaller-Binary liegt alles neben sys.executable, sonst neben __file__
sys.path.insert(0, os.path.dirname(__file__)) _BASE = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _BASE)
from kobrax_client import KobraXClient 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: try:
from aiohttp import web from aiohttp import web
import aiohttp import aiohttp
@@ -48,6 +62,12 @@ KOBRA_TO_KLIPPER_STATE = {
"checking": "printing", "checking": "printing",
"updated": "printing", "updated": "printing",
"init": "printing", "init": "printing",
"pausing": "paused",
"paused": "paused",
"resuming": "printing",
"resumed": "printing",
"stopping": "printing",
"stoped": "standby",
"finished": "complete", "finished": "complete",
"failed": "error", "failed": "error",
"canceled": "standby", "canceled": "standby",
@@ -73,6 +93,7 @@ class KobraXBridge:
"filename": "", "filename": "",
"progress": 0.0, "progress": 0.0,
"print_duration": 0, "print_duration": 0,
"remain_time": 0,
"curr_layer": 0, "curr_layer": 0,
"total_layers": 0, "total_layers": 0,
"printer_name": "Anycubic Kobra X", "printer_name": "Anycubic Kobra X",
@@ -83,6 +104,7 @@ class KobraXBridge:
"light_on": False, "light_on": False,
"light_brightness": 80, "light_brightness": 80,
"taskid": "-1", "taskid": "-1",
"print_speed_mode": 2,
} }
self._ams_slots: list[dict] = [] self._ams_slots: list[dict] = []
self._ams_loaded_slot: int = -1 self._ams_loaded_slot: int = -1
@@ -117,17 +139,25 @@ class KobraXBridge:
self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "printing") self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "printing")
if kobra_state: if kobra_state:
self._state["kobra_state"] = kobra_state self._state["kobra_state"] = kobra_state
if kobra_state in ("stoped", "canceled"):
self._state["progress"] = 0.0
self._state["filename"] = ""
self._state["filename"] = d.get("filename", self._state["filename"]) self._state["filename"] = d.get("filename", self._state["filename"])
if "progress" in d: if "progress" in d:
self._state["progress"] = float(d["progress"]) / 100.0 self._state["progress"] = float(d["progress"]) / 100.0
if "print_time" in d: if "print_time" in d:
self._state["print_duration"] = int(d["print_time"]) * 60 self._state["print_duration"] = int(d["print_time"]) * 60
if "remain_time" in d:
self._state["remain_time"] = int(d["remain_time"]) * 60
if "curr_layer" in d: if "curr_layer" in d:
self._state["curr_layer"] = d["curr_layer"] self._state["curr_layer"] = d["curr_layer"]
if "total_layers" in d: if "total_layers" in d:
self._state["total_layers"] = d["total_layers"] self._state["total_layers"] = d["total_layers"]
if "taskid" in d: if "taskid" in d:
self._state["taskid"] = str(d["taskid"]) self._state["taskid"] = str(d["taskid"])
settings = d.get("settings") or {}
if "print_speed_mode" in settings:
self._state["print_speed_mode"] = int(settings["print_speed_mode"])
self._push_status_update() self._push_status_update()
def _on_info(self, payload: dict): def _on_info(self, payload: dict):
@@ -152,6 +182,9 @@ class KobraXBridge:
fan = d.get("fan_speed_pct") fan = d.get("fan_speed_pct")
if fan is not None: if fan is not None:
self._state["fan_speed"] = int(fan) self._state["fan_speed"] = int(fan)
speed_mode = d.get("print_speed_mode")
if speed_mode is not None:
self._state["print_speed_mode"] = int(speed_mode)
self._push_status_update() self._push_status_update()
def _on_file(self, payload: dict): def _on_file(self, payload: dict):
@@ -232,6 +265,7 @@ class KobraXBridge:
"filename": s["filename"], "filename": s["filename"],
"print_duration": s["print_duration"], "print_duration": s["print_duration"],
"total_duration": s["print_duration"], "total_duration": s["print_duration"],
"remain_time": s["remain_time"],
"info": { "info": {
"current_layer": s["curr_layer"], "current_layer": s["curr_layer"],
"total_layer": s["total_layers"], "total_layer": s["total_layers"],
@@ -424,17 +458,19 @@ class KobraXBridge:
}, status=201) }, status=201)
def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0): 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 = [ ams_box_mapping = [
{ {
"paint_index": i, "paint_index": i,
"ams_index": i, "ams_index": i,
"paint_color": [255, 255, 255, 255], "paint_color": [255, 255, 255, 255],
"ams_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 = { payload = {
"taskid": "-1", "taskid": "-1",
"url": url, "url": url,
@@ -482,16 +518,19 @@ class KobraXBridge:
log.info(f"Druck starten: {filename}") log.info(f"Druck starten: {filename}")
# AMS-Mapping aus gecachtem State # AMS-Mapping aus gecachtem State — leere Slots (status != 5) überspringen
ams_box_mapping = [] ams_box_mapping = []
for i, slot in enumerate(self._ams_slots): 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({ ams_box_mapping.append({
"slot_index": i, "slot_index": i,
"material_type": slot.get("material_type", "PLA"), "material_type": slot.get("type", "PLA"),
"color": slot.get("color", "FFFFFF"), "color": slot.get("color", [255, 255, 255]),
}) })
use_ams = len(self._ams_slots) > 0 use_ams = len(ams_box_mapping) > 0
payload = { payload = {
"filename": filename, "filename": filename,
@@ -512,17 +551,20 @@ class KobraXBridge:
async def handle_print_pause(self, request): async def handle_print_pause(self, request):
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self.client.pause_print) taskid = self._state.get("taskid", "-1")
await loop.run_in_executor(None, lambda: self.client.pause_print(taskid))
return web.json_response({"result": "ok"}) return web.json_response({"result": "ok"})
async def handle_print_resume(self, request): async def handle_print_resume(self, request):
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self.client.resume_print) taskid = self._state.get("taskid", "-1")
await loop.run_in_executor(None, lambda: self.client.resume_print(taskid))
return web.json_response({"result": "ok"}) return web.json_response({"result": "ok"})
async def handle_print_cancel(self, request): async def handle_print_cancel(self, request):
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self.client.stop_print) taskid = self._state.get("taskid", "-1")
await loop.run_in_executor(None, lambda: self.client.stop_print(taskid))
return web.json_response({"result": "ok"}) return web.json_response({"result": "ok"})
async def handle_octoprint_version(self, request): async def handle_octoprint_version(self, request):
@@ -573,6 +615,12 @@ header{background:var(--card);border-bottom:1px solid var(--border);
.theme-btn{background:none;border:1px solid var(--border);color:var(--txt2); .theme-btn{background:none;border:1px solid var(--border);color:var(--txt2);
border-radius:8px;padding:6px 10px;cursor:pointer;font-size:13px;transition:.15s} border-radius:8px;padding:6px 10px;cursor:pointer;font-size:13px;transition:.15s}
.theme-btn:hover{border-color:var(--accent);color:var(--accent)} .theme-btn:hover{border-color:var(--accent);color:var(--accent)}
.conn-btn{border-radius:8px;padding:6px 12px;cursor:pointer;font-size:13px;
font-weight:600;border:none;transition:.15s}
.conn-btn.disconnected{background:var(--accent);color:#fff}
.conn-btn.disconnected:hover{opacity:.85}
.conn-btn.connected{background:transparent;border:1px solid var(--border);color:var(--txt2)}
.conn-btn.connected:hover{border-color:#e05;color:#e05}
/* ── LAYOUT ── */ /* ── LAYOUT ── */
.layout{display:flex;flex:1;min-height:0} .layout{display:flex;flex:1;min-height:0}
@@ -637,6 +685,14 @@ main{flex:1;overflow-y:auto;padding:20px}
.btn-cancel{background:#2d0d0d;color:var(--err);border:1px solid var(--err)} .btn-cancel{background:#2d0d0d;color:var(--err);border:1px solid var(--err)}
.btn-accent{background:var(--accent);color:#001a24} .btn-accent{background:var(--accent);color:#001a24}
.btn-sm{padding:7px 12px;font-size:12px} .btn-sm{padding:7px 12px;font-size:12px}
.spd-btn{flex:1;border:1.5px solid var(--border);background:var(--raised);color:var(--txt);
border-radius:10px;padding:14px 8px;font-size:13px;font-weight:600;cursor:pointer;
transition:all .15s;display:flex;flex-direction:column;align-items:center;gap:4px}
.spd-btn:hover{border-color:var(--accent);color:var(--accent)}
.spd-btn.spd-active{border-color:var(--accent);background:rgba(0,200,255,.12);color:var(--accent)}
.spd-btn .spd-icon{font-size:22px}
.spd-bar{height:4px;border-radius:2px;background:var(--border);margin-top:10px;overflow:hidden}
.spd-bar-fill{height:100%;border-radius:2px;background:linear-gradient(90deg,var(--accent2),var(--accent));transition:width .3s}
/* ── TEMPS ── */ /* ── TEMPS ── */
.temp-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px} .temp-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px}
@@ -809,6 +865,7 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<button class="theme-btn" onclick="toggleTheme()">☀ / ☾</button> <button class="theme-btn" onclick="toggleTheme()">☀ / ☾</button>
<button class="theme-btn" onclick="toggleLang()" id="lang-btn">EN</button> <button class="theme-btn" onclick="toggleLang()" id="lang-btn">EN</button>
<button class="theme-btn" onclick="openSettings()" id="settings-btn" title="Einstellungen">⚙</button> <button class="theme-btn" onclick="openSettings()" id="settings-btn" title="Einstellungen">⚙</button>
<button class="conn-btn disconnected" id="conn-btn" onclick="toggleConnection()">⚡ Verbinden</button>
</header> </header>
<!-- ═══ SETTINGS MODAL ═══ --> <!-- ═══ SETTINGS MODAL ═══ -->
@@ -824,6 +881,7 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<div class="modal-field"> <div class="modal-field">
<label id="lbl-printer-ip">Drucker-IP</label> <label id="lbl-printer-ip">Drucker-IP</label>
<input type="text" id="s-printer-ip" placeholder="192.168.x.x"> <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>
<div class="modal-field"> <div class="modal-field">
<label id="lbl-mqtt-port">MQTT-Port</label> <label id="lbl-mqtt-port">MQTT-Port</label>
@@ -887,9 +945,9 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<!-- Kamera --> <!-- Kamera -->
<div class="card" style="grid-column:1/-1"> <div class="card" style="grid-column:1/-1">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<div class="card-title" style="margin-bottom:0"><span>📷</span> Kamera</div> <div class="card-title" style="margin-bottom:0"><span>📷</span> <span id="d-card-cam">Kamera</span></div>
<div style="display:flex;align-items:center;gap:10px"> <div style="display:flex;align-items:center;gap:10px">
<span style="font-size:12px;color:var(--txt2)">💡 Licht</span> <span id="d-lbl-light" style="font-size:12px;color:var(--txt2)">💡 Licht</span>
<label class="toggle"> <label class="toggle">
<input type="checkbox" id="d-light-toggle" onchange="setLight()"> <input type="checkbox" id="d-light-toggle" onchange="setLight()">
<span class="toggle-track"></span> <span class="toggle-track"></span>
@@ -916,6 +974,7 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<div class="progress-bar" style="margin:8px 0"><div class="progress-fill" id="d-pbar" style="width:0%"></div></div> <div class="progress-bar" style="margin:8px 0"><div class="progress-fill" id="d-pbar" style="width:0%"></div></div>
<div class="meta-row" style="margin-top:6px"> <div class="meta-row" style="margin-top:6px">
<span id="d-elapsed"></span> <span id="d-elapsed"></span>
<span id="d-remain" style="color:var(--acc)"></span>
<span id="d-layers" class="layer-badge"></span> <span id="d-layers" class="layer-badge"></span>
</div> </div>
<div class="fname" id="d-fname" title="" style="margin-top:6px"></div> <div class="fname" id="d-fname" title="" style="margin-top:6px"></div>
@@ -947,7 +1006,7 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
</div> </div>
</div> </div>
<div class="temp-block"> <div class="temp-block">
<div class="temp-label">Bett</div> <div class="temp-label" id="d-lbl-bed">Bett</div>
<div class="temp-row"> <div class="temp-row">
<div class="temp-val" id="d-bt"></div> <div class="temp-val" id="d-bt"></div>
<div class="temp-unit">°C</div> <div class="temp-unit">°C</div>
@@ -990,19 +1049,41 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<button class="step-btn" onclick="setStep(this,10)">10 mm</button> <button class="step-btn" onclick="setStep(this,10)">10 mm</button>
</div> </div>
<div class="home-btns"> <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="homeZ()"><span class="lbl-home-z">Home Z</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="homeXY()"><span class="lbl-home-xy">Home XY</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 btn-accent" onclick="homeAll()"><span class="lbl-home-all">Home All</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> </div>
<div class="card"> <div class="card">
<div class="card-title"><span>↕</span> <span id="ptitle-motion-z">Z-Achse</span></div> <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)"> <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>
<!-- Print Speed -->
<div class="card">
<div class="card-title"><span>🏎</span> <span id="d-card-speed">Druckgeschwindigkeit</span></div>
<div style="display:flex;gap:8px;margin-top:4px">
<button class="spd-btn" id="d-spd-1" onclick="setSpeed(1)">
<span class="spd-icon">🐢</span>
<span id="d-spd-lbl-1">Leise</span>
</button>
<button class="spd-btn spd-active" id="d-spd-2" onclick="setSpeed(2)">
<span class="spd-icon">⚡</span>
<span id="d-spd-lbl-2">Normal</span>
</button>
<button class="spd-btn" id="d-spd-3" onclick="setSpeed(3)">
<span class="spd-icon">🚀</span>
<span id="d-spd-lbl-3">Sport</span>
</button>
</div>
<div class="spd-bar" style="margin-top:12px">
<div class="spd-bar-fill" id="d-spd-bar" style="width:50%"></div>
</div> </div>
<div style="text-align:center;margin-top:8px;font-size:12px;color:var(--txt2)">Schrittweite: <span id="step-display">1</span> mm</div>
</div> </div>
<!-- Lüfter --> <!-- Lüfter -->
@@ -1036,8 +1117,8 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<span id="ams-slot-label" style="min-width:48px;font-size:13px;font-weight:600">Slot 1</span> <span id="ams-slot-label" style="min-width:48px;font-size:13px;font-weight:600">Slot 1</span>
</div> </div>
<div style="display:flex;gap:10px"> <div style="display:flex;gap:10px">
<button class="btn" style="flex:1" onclick="amsFeed(1)">⬇ Einziehen</button> <button class="btn" style="flex:1" onclick="amsFeed(1)">⬇ <span class="lbl-feed">Einziehen</span></button>
<button id="btn-unload" class="btn" style="flex:1" onclick="amsFeed(2)">⬆ Ausziehen</button> <button id="btn-unload" class="btn" style="flex:1" onclick="amsFeed(2)">⬆ <span class="lbl-unload">Ausziehen</span></button>
</div> </div>
</div> </div>
</div> </div>
@@ -1062,9 +1143,9 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<script> <script>
// ── State ── // ── State ──
var S={nozzle_temp:0,nozzle_target:0,bed_temp:0,bed_target:0, var S={nozzle_temp:0,nozzle_target:0,bed_temp:0,bed_target:0,
print_state:'standby',filename:'',progress:0,print_duration:0, print_state:'standby',filename:'',progress:0,print_duration:0,remain_time:0,
curr_layer:0,total_layers:0,printer_name:'Kobra X',firmware_version:'', curr_layer:0,total_layers:0,printer_name:'Kobra X',firmware_version:'',
camera_url:'',fan_speed:0,light_on:false,light_brightness:80,ams_slots:[]}; camera_url:'',fan_speed:0,print_speed_mode:2,light_on:false,light_brightness:80,ams_slots:[]};
var tempHistory={n:[],b:[]}; var tempHistory={n:[],b:[]};
var camOn=false; var camOn=false;
var currentStep=1; var currentStep=1;
@@ -1081,49 +1162,55 @@ function toggleTheme(){
// ── i18n ── // ── i18n ──
var LANG_DE={ var LANG_DE={
header_status_standby:'Bereit',header_status_printing:'Druckt',header_status_complete:'Fertig',header_status_error:'Fehler', header_status_standby:'Bereit',header_status_printing:'Druckt',header_status_complete:'Fertig',header_status_error:'Fehler',
kobra_free:'Bereit',kobra_busy:'Beschäftigt',kobra_printing:'Druckt',kobra_preheating:'Aufheizen',kobra_auto_leveling:'Nivellierung',kobra_checking:'Prüfung',kobra_updated:'Aktualisierung',kobra_init:'Initialisierung',kobra_finished:'Abgeschlossen',kobra_failed:'Fehler',kobra_canceled:'Abgebrochen',kobra_offline:'Offline', kobra_free:'Bereit',kobra_busy:'Beschäftigt',kobra_printing:'Druckt',kobra_preheating:'Aufheizen',kobra_auto_leveling:'Nivellierung',kobra_checking:'Prüfung',kobra_updated:'Aktualisierung',kobra_init:'Initialisierung',kobra_pausing:'Pausiert...',kobra_paused:'Pausiert',kobra_resuming:'Fortsetzen...',kobra_resumed:'Fortgesetzt',kobra_stopping:'Stoppt...',kobra_stoped:'Gestoppt',kobra_finished:'Abgeschlossen',kobra_failed:'Fehler',kobra_canceled:'Abgebrochen',kobra_offline:'Offline',
nav_dashboard:'Dashboard',nav_print:'Druck',nav_temps:'Temperaturen',nav_motion:'Achsen',nav_ams:'AMS',nav_extras:'Licht / Lüfter',nav_console:'Konsole', nav_dashboard:'Dashboard',nav_print:'Druck',nav_temps:'Temperaturen',nav_motion:'Achsen',nav_ams:'AMS',nav_extras:'Licht / Lüfter',nav_console:'Konsole',
card_progress:'Fortschritt',card_temps:'Temperaturen',card_light_fan:'Lüfter', card_progress:'Fortschritt',card_temps:'Temperaturen',card_light_fan:'Lüfter',card_speed:'Druckgeschwindigkeit',card_cam:'Kamera',lbl_elapsed:'Verstrichen',lbl_remaining:'verbleibend',
speed_silent:'🐢 Leise',speed_normal:'⚡ Normal',speed_sport:'🚀 Sport',
lbl_light:'💡 Licht',lbl_feed:'Einziehen',lbl_unload:'Ausziehen',
cam_placeholder:'📷 Kamera nicht gestartet',btn_cam_start:'▶ Kamera',btn_cam_stop:'◼ Kamera', cam_placeholder:'📷 Kamera nicht gestartet',btn_cam_start:'▶ Kamera',btn_cam_stop:'◼ Kamera',
btn_pause:'⏸ Pause',btn_resume:'▶ Weiter',btn_cancel:'✕ Stopp', btn_pause:'⏸ Pause',btn_resume:'▶ Weiter',btn_cancel:'✕ Stopp',
label_nozzle:'Nozzle',label_bed:'Bett',label_fan:'🌀 Lüfter',label_light:'💡 Licht',label_on_off:'Ein / Aus',label_speed:'Geschwindigkeit', label_nozzle:'Nozzle',label_bed:'Bett',label_fan:'🌀 Lüfter',label_light:'💡 Licht',label_on_off:'Ein / Aus',label_speed:'Geschwindigkeit',
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)', 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', 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_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_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', 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_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', 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:', 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?', confirm_cancel:'Druck wirklich abbrechen?',
settings_title:'Einstellungen',settings_connection:'Verbindung',settings_poll:'Poll-Intervall',settings_version:'Version', 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_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_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' update_apply:'Jetzt installieren',update_applying:'Lade herunter...',update_restarting:'Starte neu...',update_error:'Fehler',
btn_connect:'⚡ Verbinden',btn_disconnect:'✕ Trennen'
}; };
var LANG_EN={ var LANG_EN={
header_status_standby:'Ready',header_status_printing:'Printing',header_status_complete:'Complete',header_status_error:'Error', header_status_standby:'Ready',header_status_printing:'Printing',header_status_complete:'Complete',header_status_error:'Error',
kobra_free:'Ready',kobra_busy:'Busy',kobra_printing:'Printing',kobra_preheating:'Preheating',kobra_auto_leveling:'Auto Leveling',kobra_checking:'Checking',kobra_updated:'Updating',kobra_init:'Initializing',kobra_finished:'Finished',kobra_failed:'Error',kobra_canceled:'Cancelled',kobra_offline:'Offline', kobra_free:'Ready',kobra_busy:'Busy',kobra_printing:'Printing',kobra_preheating:'Preheating',kobra_auto_leveling:'Auto Leveling',kobra_checking:'Checking',kobra_updated:'Updating',kobra_init:'Initializing',kobra_pausing:'Pausing...',kobra_paused:'Paused',kobra_resuming:'Resuming...',kobra_resumed:'Resumed',kobra_stopping:'Stopping...',kobra_stoped:'Stopped',kobra_finished:'Finished',kobra_failed:'Error',kobra_canceled:'Cancelled',kobra_offline:'Offline',
nav_dashboard:'Dashboard',nav_print:'Print',nav_temps:'Temperatures',nav_motion:'Motion',nav_ams:'AMS',nav_extras:'Light / Fan',nav_console:'Console', nav_dashboard:'Dashboard',nav_print:'Print',nav_temps:'Temperatures',nav_motion:'Motion',nav_ams:'AMS',nav_extras:'Light / Fan',nav_console:'Console',
card_progress:'Progress',card_temps:'Temperatures',card_light_fan:'Fan', card_progress:'Progress',card_temps:'Temperatures',card_light_fan:'Fan',card_speed:'Print Speed',card_cam:'Camera',lbl_elapsed:'Elapsed',lbl_remaining:'remaining',
speed_silent:'🐢 Silent',speed_normal:'⚡ Normal',speed_sport:'🚀 Sport',
lbl_light:'💡 Light',lbl_feed:'Load',lbl_unload:'Unload',
cam_placeholder:'📷 Camera not started',btn_cam_start:'▶ Camera',btn_cam_stop:'◼ Camera', cam_placeholder:'📷 Camera not started',btn_cam_start:'▶ Camera',btn_cam_stop:'◼ Camera',
btn_pause:'⏸ Pause',btn_resume:'▶ Resume',btn_cancel:'✕ Stop', btn_pause:'⏸ Pause',btn_resume:'▶ Resume',btn_cancel:'✕ Stop',
label_nozzle:'Nozzle',label_bed:'Bed',label_fan:'🌀 Fan',label_light:'💡 Light',label_on_off:'On / Off',label_speed:'Speed', label_nozzle:'Nozzle',label_bed:'Bed',label_fan:'🌀 Fan',label_light:'💡 Light',label_on_off:'On / Off',label_speed:'Speed',
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)', 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', 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_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_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', 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_extras_light:'Light',panel_extras_fan:'Fan',panel_extras_camera:'Camera',btn_cam_start2:'▶ Start',btn_cam_stop2:'◼ Stop',
panel_console_title:'Event Log', 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:', 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?', confirm_cancel:'Really cancel the print?',
settings_title:'Settings',settings_connection:'Connection',settings_poll:'Poll Interval',settings_version:'Version', 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_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_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' update_apply:'Install Now',update_applying:'Downloading...',update_restarting:'Restarting...',update_error:'Error',
btn_connect:'⚡ Connect',btn_disconnect:'✕ Disconnect'
}; };
var currentLang='de'; var currentLang='de';
var T=LANG_DE; var T=LANG_DE;
@@ -1146,7 +1233,11 @@ function applyLang(){
setText('d-card-progress',T.card_progress); setText('d-card-progress',T.card_progress);
setText('d-card-temps',T.card_temps); setText('d-card-temps',T.card_temps);
setText('d-card-lightfan',T.card_light_fan); setText('d-card-lightfan',T.card_light_fan);
setText('d-card-speed',T.card_speed);
setText('d-card-cam',T.card_cam);
setText('d-card-ams',T.panel_ams_title); setText('d-card-ams',T.panel_ams_title);
setText('d-lbl-light',T.lbl_light);
setText('d-lbl-bed',T.label_bed);
// Dashboard buttons // Dashboard buttons
setText('d-btn-pause',T.btn_pause); setText('d-btn-pause',T.btn_pause);
setText('d-btn-resume',T.btn_resume); setText('d-btn-resume',T.btn_resume);
@@ -1160,11 +1251,12 @@ function applyLang(){
// Axis labels // Axis labels
setText('ptitle-motion-xy',T.panel_motion_xy); setText('ptitle-motion-xy',T.panel_motion_xy);
setText('ptitle-motion-z',T.panel_motion_z); 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-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-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('.lbl-step').forEach(e=>e.textContent=T.label_step);
document.querySelectorAll('.temp-input').forEach(e=>e.setAttribute('placeholder',T.label_target_c.replace(':','')));
// Console // Console
setText('ptitle-console',T.panel_console_title); setText('ptitle-console',T.panel_console_title);
// Settings modal // Settings modal
@@ -1181,6 +1273,15 @@ function applyLang(){
setText('lbl-mode-id',T.settings_mode_id); setText('lbl-mode-id',T.settings_mode_id);
setText('lbl-update-check',T.update_check); setText('lbl-update-check',T.update_check);
setText('lbl-update-apply',T.update_apply); setText('lbl-update-apply',T.update_apply);
// Speed buttons
setText('d-spd-lbl-1',T.speed_silent.replace(/^\S+\s/,''));
setText('d-spd-lbl-2',T.speed_normal.replace(/^\S+\s/,''));
setText('d-spd-lbl-3',T.speed_sport.replace(/^\S+\s/,''));
// AMS feed/unload
document.querySelectorAll('.lbl-feed').forEach(e=>e.textContent=T.lbl_feed);
document.querySelectorAll('.lbl-unload').forEach(e=>e.textContent=T.lbl_unload);
// conn-btn text (nur wenn nicht im Übergangszustand)
updateConnBtn();
} }
function setText(id,txt){var el=document.getElementById(id);if(el)el.textContent=txt;} function setText(id,txt){var el=document.getElementById(id);if(el)el.textContent=txt;}
(function(){ (function(){
@@ -1189,7 +1290,13 @@ function setText(id,txt){var el=document.getElementById(id);if(el)el.textContent
document.getElementById('lang-btn').textContent=l==='de'?'EN':'DE'; document.getElementById('lang-btn').textContent=l==='de'?'EN':'DE';
document.documentElement.setAttribute('lang',l); document.documentElement.setAttribute('lang',l);
// defer until DOM ready // defer until DOM ready
window.addEventListener('DOMContentLoaded',function(){applyLang();}); window.addEventListener('DOMContentLoaded',function(){
applyLang();
// Beim ersten Start (keine Zugangsdaten) Settings-Modal automatisch öffnen
fetch('/api/settings').then(function(r){return r.json()}).then(function(d){
if(!d.printer_ip||!d.device_id)openSettings();
}).catch(function(){});
});
})(); })();
// ── Panel nav ── // ── Panel nav ──
@@ -1248,6 +1355,8 @@ function applyState(){
var elapsed=fmtTime(s.print_duration); var elapsed=fmtTime(s.print_duration);
var delapsed=document.getElementById('d-elapsed');if(delapsed)delapsed.textContent=elapsed; var delapsed=document.getElementById('d-elapsed');if(delapsed)delapsed.textContent=elapsed;
var remain=s.remain_time>0?''+fmtTime(s.remain_time)+' '+T.lbl_remaining:'';
var dremain=document.getElementById('d-remain');if(dremain)dremain.textContent=remain;
var fn=s.filename||''; var fn=s.filename||'';
var dfname=document.getElementById('d-fname');if(dfname){dfname.textContent=fn;dfname.title=fn}; var dfname=document.getElementById('d-fname');if(dfname){dfname.textContent=fn;dfname.title=fn};
@@ -1271,17 +1380,27 @@ function applyState(){
var dfan=document.getElementById('d-fan');if(dfan)dfan.value=s.fan_speed; var dfan=document.getElementById('d-fan');if(dfan)dfan.value=s.fan_speed;
var dfanval=document.getElementById('d-fan-val');if(dfanval)dfanval.textContent=s.fan_speed; var dfanval=document.getElementById('d-fan-val');if(dfanval)dfanval.textContent=s.fan_speed;
// speed mode buttons
var spdWidths={1:25,2:55,3:90};
[1,2,3].forEach(function(m){
var b=document.getElementById('d-spd-'+m);
if(b) b.classList.toggle('spd-active', s.print_speed_mode===m);
});
var spdBar=document.getElementById('d-spd-bar');
if(spdBar) spdBar.style.width=(spdWidths[s.print_speed_mode]||55)+'%';
// AMS // AMS
if(s.ams_slots&&s.ams_slots.length){ if(s.ams_slots&&s.ams_slots.length){
var html=''; var html='';
s.ams_slots.forEach(function(slot,i){ 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 col='rgb('+rgb[0]+','+rgb[1]+','+rgb[2]+')';
var active=slot.status===1||slot.active; var active=slot.status===1||slot.active;
var pct=slot.consumables_percent!=null?slot.consumables_percent+'%':''; var pct=empty?T.ams_empty:(slot.consumables_percent!=null?slot.consumables_percent+'%':'');
html+='<div class="ams-slot'+(active?' active':'')+ '" style="--slot-color:'+col+'">' 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-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">Slot '+(slot.index!=null?slot.index+1:i+1)+'</div>'
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>' +'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>'
+'</div>'; +'</div>';
@@ -1297,6 +1416,33 @@ function applyState(){
if(s.print_state==='printing'&&!camOn&&s.camera_url){ if(s.print_state==='printing'&&!camOn&&s.camera_url){
camStart(); camStart();
} }
updateConnBtn();
}
function updateConnBtn(){
var btn=document.getElementById('conn-btn');
if(!btn)return;
var offline=S.kobra_state==='offline';
if(offline){
btn.className='conn-btn disconnected';
btn.textContent=T.btn_connect||'⚡ Verbinden';
} else {
btn.className='conn-btn connected';
btn.textContent=T.btn_disconnect||'✕ Trennen';
}
}
function toggleConnection(){
var btn=document.getElementById('conn-btn');
var offline=S.kobra_state==='offline';
btn.disabled=true;
btn.textContent='';
var url=offline?'/api/connect':'/api/disconnect';
post(url,{}).then(function(r){return r.json()}).then(function(r){
btn.disabled=false;
if(r.error)addLog('Error: '+r.error);
}).catch(function(){btn.disabled=false;});
} }
// ── Temp history + chart ── // ── Temp history + chart ──
@@ -1352,6 +1498,13 @@ function openSettings(){
function closeSettings(){ function closeSettings(){
document.getElementById('settings-modal').classList.remove('open'); 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){ function setPoll(ms){
document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')}); document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')});
var id='poll-'+Math.round(ms/1000); var id='poll-'+Math.round(ms/1000);
@@ -1457,16 +1610,25 @@ function move(axis,dir,dist){
.catch(function(e){clog('Achse-Fehler: '+e,'msg-err')}); .catch(function(e){clog('Achse-Fehler: '+e,'msg-err')});
} }
function homeAll(){ 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')}) .then(function(){clog('Home All','msg-ok')})
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')}); .catch(function(e){clog('Home-Fehler: '+e,'msg-err')});
} }
function homeAxis(ax){ function homeXY(){
var m={X:1,Y:2,Z:3}; post('/api/axis',{axis:4,move_type:2,distance:0})
post('/api/axis',{axis:m[ax],move_type:2,distance:0}) .then(function(){clog('Home XY','msg-ok')})
.then(function(){clog('Home '+ax,'msg-ok')})
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')}); .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 ── // ── Temperature ──
function setNozzle(){ function setNozzle(){
@@ -1478,7 +1640,7 @@ function setNozzle(){
function setBed(){ function setBed(){
var v=parseFloat(document.getElementById('p-bed-inp').value||0); var v=parseFloat(document.getElementById('p-bed-inp').value||0);
post('/api/temperature',{nozzle:S.nozzle_target,bed:v}) post('/api/temperature',{nozzle:S.nozzle_target,bed:v})
.then(function(){clog('Bett'+v+'°C','msg-ok')}) .then(function(){clog(T.label_bed+''+v+'°C','msg-ok')})
.catch(function(e){clog('Temp-Fehler: '+e,'msg-err')}); .catch(function(e){clog('Temp-Fehler: '+e,'msg-err')});
} }
@@ -1490,6 +1652,17 @@ function setLight(){
.catch(function(e){clog('Licht-Fehler: '+e,'msg-err')}); .catch(function(e){clog('Licht-Fehler: '+e,'msg-err')});
} }
// ── Print Speed ──
function setSpeed(mode){
S.print_speed_mode=mode;
[1,2,3].forEach(function(m){
var b=document.getElementById('d-spd-'+m);
if(b) b.classList.toggle('spd-active',m===mode);
});
post('/api/speed',{mode:mode})
.catch(function(e){clog('Speed-Fehler: '+e,'msg-err')});
}
// ── Fan ── // ── Fan ──
function setFan(){ function setFan(){
var v=parseInt(document.getElementById('d-fan').value); var v=parseInt(document.getElementById('d-fan').value);
@@ -1510,7 +1683,7 @@ function quickFan(v){
function amsFeed(type){ function amsFeed(type){
var slot=parseInt(document.getElementById('ams-slot-sel').value); var slot=parseInt(document.getElementById('ams-slot-sel').value);
post('/api/ams/feed',{slot_index:slot,type:type}) post('/api/ams/feed',{slot_index:slot,type:type})
.then(function(){clog((type===1?'Einziehen':'Ausziehen')+' Slot '+(slot+1),'msg-ok')}) .then(function(){clog((type===1?T.lbl_feed:T.lbl_unload)+' Slot '+(slot+1),'msg-ok')})
.catch(function(e){clog('AMS-Fehler: '+e,'msg-err')}); .catch(function(e){clog('AMS-Fehler: '+e,'msg-err')});
} }
@@ -1600,6 +1773,43 @@ function toggleCam(){if(camOn)camStop();else camStart()}
self._state["fan_speed"] = speed self._state["fan_speed"] = speed
return web.json_response({"result": "ok"}) return web.json_response({"result": "ok"})
async def handle_api_connect(self, request):
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(None, self.client.connect)
self._state["print_state"] = "standby"
self._state["kobra_state"] = "free"
log.info("Manuell verbunden")
return web.json_response({"result": "connected"})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def handle_api_disconnect(self, request):
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(None, self.client.disconnect)
except Exception:
pass
self._state["print_state"] = "error"
self._state["kobra_state"] = "offline"
log.info("Manuell getrennt")
return web.json_response({"result": "disconnected"})
async def handle_api_speed(self, request):
try:
body = await request.json()
except Exception:
body = {}
mode = int(body.get("mode", 2))
loop = asyncio.get_event_loop()
taskid = self._state.get("taskid", "-1")
await loop.run_in_executor(None, lambda: self.client.publish_web(
"print", "update",
{"taskid": taskid, "settings": {"print_speed_mode": mode}},
))
self._state["print_speed_mode"] = mode
return web.json_response({"result": "ok"})
async def handle_api_ams_feed(self, request): async def handle_api_ams_feed(self, request):
try: try:
body = await request.json() body = await request.json()
@@ -1626,10 +1836,16 @@ function toggleCam(){if(camOn)camStop();else camStart()}
body = await request.json() body = await request.json()
except Exception: except Exception:
body = {} body = {}
action = body.get("action", "move")
loop = asyncio.get_event_loop()
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)) axis = int(body.get("axis", 4))
move_type = int(body.get("move_type", 2)) move_type = int(body.get("move_type", 2))
distance = float(body.get("distance", 0)) distance = float(body.get("distance", 0))
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: self.client.publish( await loop.run_in_executor(None, lambda: self.client.publish(
"axis", "move", "axis", "move",
{"axis": axis, "move_type": move_type, "distance": distance}, {"axis": axis, "move_type": move_type, "distance": distance},
@@ -1698,14 +1914,6 @@ function toggleCam(){if(camOn)camStop();else camStart()}
if not url: if not url:
return web.Response(status=503, text="Keine Kamera-URL bekannt") 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://") is_rtsp = url.lower().startswith("rtsp://")
ffmpeg_input_args = [ ffmpeg_input_args = [
"-fflags", "nobuffer", "-fflags", "nobuffer",
@@ -1714,11 +1922,13 @@ function toggleCam(){if(camOn)camStop();else camStart()}
if is_rtsp: if is_rtsp:
ffmpeg_input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"] ffmpeg_input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
else: else:
# HTTP-FLV/HLS: braucht mehr Probe-Puffer für Container-Erkennung
ffmpeg_input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"] ffmpeg_input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"]
# 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( proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-loglevel", "quiet", _find_ffmpeg(), "-loglevel", "quiet",
*ffmpeg_input_args, *ffmpeg_input_args,
"-i", url, "-i", url,
"-vf", "fps=15,scale=640:-1", "-vf", "fps=15,scale=640:-1",
@@ -1730,6 +1940,20 @@ function toggleCam(){if(camOn)camStop();else camStart()}
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL, 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"" buf = b""
try: try:
@@ -1794,11 +2018,13 @@ function toggleCam(){if(camOn)camStop();else camStart()}
"bed_target": s["bed_target"], "bed_target": s["bed_target"],
"progress": s["progress"], "progress": s["progress"],
"print_duration": s["print_duration"], "print_duration": s["print_duration"],
"remain_time": s["remain_time"],
"curr_layer": s["curr_layer"], "curr_layer": s["curr_layer"],
"total_layers": s["total_layers"], "total_layers": s["total_layers"],
"filename": s["filename"], "filename": s["filename"],
"camera_url": s["camera_url"], "camera_url": s["camera_url"],
"fan_speed": s["fan_speed"], "fan_speed": s["fan_speed"],
"print_speed_mode": s["print_speed_mode"],
"light_on": s["light_on"], "light_on": s["light_on"],
"light_brightness": s["light_brightness"], "light_brightness": s["light_brightness"],
"ams_slots": self._ams_slots, "ams_slots": self._ams_slots,
@@ -1860,7 +2086,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
def _find_env_path(self) -> pathlib.Path: def _find_env_path(self) -> pathlib.Path:
"""Gibt den Pfad zur .env-Datei zurück (neben Script oder im Parent).""" """Gibt den Pfad zur .env-Datei zurück (neben Script oder im Parent)."""
script_dir = pathlib.Path(__file__).parent script_dir = pathlib.Path(_BASE)
for base in (script_dir, script_dir.parent): for base in (script_dir, script_dir.parent):
p = base / ".env" p = base / ".env"
if p.is_file(): if p.is_file():
@@ -1892,7 +2118,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
lines.append(line) lines.append(line)
# Werte aktualisieren # Werte aktualisieren
mapping = { 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_PORT": str(data.get("mqtt_port", existing.get("MQTT_PORT", "9883"))),
"MQTT_USERNAME": str(data.get("username", existing.get("MQTT_USERNAME",""))), "MQTT_USERNAME": str(data.get("username", existing.get("MQTT_USERNAME",""))),
"MQTT_PASSWORD": str(data.get("password", existing.get("MQTT_PASSWORD",""))), "MQTT_PASSWORD": str(data.get("password", existing.get("MQTT_PASSWORD",""))),
@@ -1923,7 +2149,12 @@ function toggleCam(){if(camOn)camStop();else camStart()}
def _restart_bridge(self): def _restart_bridge(self):
log.info("Bridge wird neu gestartet …") 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 ────────────────────────────────────────────────────────────── # ─── Update ──────────────────────────────────────────────────────────────
@@ -1931,20 +2162,19 @@ function toggleCam(){if(camOn)camStop();else camStart()}
GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag" GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag"
def _read_version(self) -> str: def _read_version(self) -> str:
for base in (pathlib.Path(__file__).parent, pathlib.Path(__file__).parent.parent): for base in (pathlib.Path(_BASE), pathlib.Path(_BASE).parent):
p = base / "VERSION" p = base / "VERSION"
if p.is_file(): if p.is_file():
return p.read_text(encoding="utf-8").strip() return p.read_text(encoding="utf-8").strip()
return "unknown" return "unknown"
def _write_version(self, version: str): def _write_version(self, version: str):
for base in (pathlib.Path(__file__).parent, pathlib.Path(__file__).parent.parent): for base in (pathlib.Path(_BASE), pathlib.Path(_BASE).parent):
p = base / "VERSION" p = base / "VERSION"
if p.is_file(): if p.is_file():
p.write_text(version + "\n", encoding="utf-8") p.write_text(version + "\n", encoding="utf-8")
return return
# Fallback: neben dem Script (pathlib.Path(_BASE) / "VERSION").write_text(version + "\n", encoding="utf-8")
(pathlib.Path(__file__).parent.parent / "VERSION").write_text(version + "\n", encoding="utf-8")
@staticmethod @staticmethod
def _parse_version(v: str) -> "tuple[int, ...]": def _parse_version(v: str) -> "tuple[int, ...]":
@@ -1990,7 +2220,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
new_tag = data.get("tag", "") new_tag = data.get("tag", "")
if not download_url: if not download_url:
return web.json_response({"error": "download_url fehlt"}, status=400) return web.json_response({"error": "download_url fehlt"}, status=400)
script_path = pathlib.Path(__file__).resolve() script_path = pathlib.Path(sys.executable if getattr(sys, "frozen", False) else __file__).resolve()
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(download_url, timeout=aiohttp.ClientTimeout(total=30)) as resp: async with session.get(download_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
@@ -2167,7 +2397,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
return False return False
def _poll_loop(self, stop_event: threading.Event): def _poll_loop(self, stop_event: threading.Event):
_offline = False # True = Drucker zuletzt nicht erreichbar _offline = self._state["kobra_state"] == "offline"
_probe_interval = 10.0 # Sekunden zwischen TCP-Probes im Offline-Modus _probe_interval = 10.0 # Sekunden zwischen TCP-Probes im Offline-Modus
while not stop_event.is_set(): while not stop_event.is_set():
@@ -2182,7 +2412,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
self._state["kobra_state"] = "free" self._state["kobra_state"] = "free"
log.info("MQTT-Verbindung wiederhergestellt") log.info("MQTT-Verbindung wiederhergestellt")
except Exception as e: except Exception as e:
log.warning(f"Verbindungsaufbau fehlgeschlagen: {e}") log.warning(f"Verbindungsaufbau fehlgeschlagen: {_mqtt_error_msg(e)}")
stop_event.wait(_probe_interval) stop_event.wait(_probe_interval)
continue continue
else: else:
@@ -2225,6 +2455,13 @@ function toggleCam(){if(camOn)camStop();else camStart()}
# App factory + main # 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: def build_app(bridge: KobraXBridge) -> web.Application:
app = web.Application() app = web.Application()
r = app.router r = app.router
@@ -2255,6 +2492,9 @@ def build_app(bridge: KobraXBridge) -> web.Application:
# New API endpoints # New API endpoints
r.add_post("/api/light", bridge.handle_api_light) r.add_post("/api/light", bridge.handle_api_light)
r.add_post("/api/fan", bridge.handle_api_fan) r.add_post("/api/fan", bridge.handle_api_fan)
r.add_post("/api/connect", bridge.handle_api_connect)
r.add_post("/api/disconnect", bridge.handle_api_disconnect)
r.add_post("/api/speed", bridge.handle_api_speed)
r.add_post("/api/ams/feed", bridge.handle_api_ams_feed) r.add_post("/api/ams/feed", bridge.handle_api_ams_feed)
r.add_post("/api/axis", bridge.handle_api_axis) r.add_post("/api/axis", bridge.handle_api_axis)
r.add_post("/api/temperature", bridge.handle_api_temperature) r.add_post("/api/temperature", bridge.handle_api_temperature)
@@ -2283,7 +2523,6 @@ def build_app(bridge: KobraXBridge) -> web.Application:
async def run_bridge(args): async def run_bridge(args):
log.info(f"Verbinde mit Drucker {args.printer_ip}:{args.mqtt_port}")
client = KobraXClient( client = KobraXClient(
host=args.printer_ip, host=args.printer_ip,
port=args.mqtt_port, port=args.mqtt_port,
@@ -2294,11 +2533,18 @@ async def run_bridge(args):
client_id="kobrax_bridge", client_id="kobrax_bridge",
) )
bridge = KobraXBridge(client, args=args)
# Verbindungsversuch beim Start bei Fehler im Offline-Modus weiterlaufen
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
log.info(f"Verbinde mit Drucker {args.printer_ip}:{args.mqtt_port}")
try:
await loop.run_in_executor(None, client.connect) await loop.run_in_executor(None, client.connect)
log.info("MQTT verbunden") log.info("MQTT verbunden")
except Exception as e:
bridge = KobraXBridge(client, args=args) 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) app = build_app(bridge)
stop_event = threading.Event() stop_event = threading.Event()
@@ -2342,6 +2588,12 @@ def main():
parser.add_argument("--port", type=int, default=7125, parser.add_argument("--port", type=int, default=7125,
help="HTTP/WS-Port (Moonraker-Standard: 7125)") help="HTTP/WS-Port (Moonraker-Standard: 7125)")
args = parser.parse_args() 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)) asyncio.run(run_bridge(args))

View File

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

67
start.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# start.sh KX-Bridge starten (baut Docker-Image automatisch wenn nötig)
set -euo pipefail
cd "$(dirname "$0")"
# .env anlegen falls nicht vorhanden
if [[ ! -f .env ]]; then
if [[ -f .env.example ]]; then
cp .env.example .env
echo "[start] .env aus .env.example erstellt"
else
touch .env
fi
fi
# Docker verfügbar?
if ! docker info > /dev/null 2>&1; then
echo "[start] Docker nicht gefunden bitte Docker installieren."
exit 1
fi
# Prüfen ob Build nötig ist
NEEDS_BUILD=0
if ! docker image inspect kx-bridge:latest > /dev/null 2>&1; then
echo "[start] Image nicht vorhanden baue kx-bridge:latest ..."
NEEDS_BUILD=1
else
# Image-Erstellungszeit in Unix-Sekunden
IMAGE_TS=$(docker inspect --format='{{.Created}}' kx-bridge:latest \
| python3 -c "import sys,datetime; s=sys.stdin.read().strip(); \
s=s[:26].rstrip('Z').replace('T',' '); \
print(int(datetime.datetime.fromisoformat(s).replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null || echo 0)
for f in Dockerfile \
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
echo "[start] '$f' ist neuer als das Image baue neu ..."
NEEDS_BUILD=1
break
fi
fi
done
fi
if [[ $NEEDS_BUILD -eq 1 ]]; then
docker build -t kx-bridge:latest .
fi
# Container starten
echo "[start] Starte KX-Bridge ..."
docker-compose down 2>/dev/null || true
docker-compose up -d
echo ""
echo " ✓ KX-Bridge läuft"
echo " Web-UI : http://$(hostname -I | awk '{print $1}'):7125"
echo " Logs : docker-compose logs -f"
echo " Stop : docker-compose down"