Compare commits
23 Commits
v0.9.1-bet
...
v0.9.1-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ae4777187f | |||
| 8ccafb96c4 | |||
| 21f340271b | |||
| 2f56a1f056 | |||
| c3a62a13c5 | |||
| 4f1eaf7e93 | |||
| fc681316fc | |||
| 23756b82a9 | |||
| 3df73e89e3 | |||
| 4026fcc60c | |||
| 3687c28239 | |||
| 68b282d170 | |||
| f265a30994 | |||
| 3d2ac7b931 | |||
| 98303f1197 | |||
| 808ac13ce0 | |||
| 14400dd799 | |||
| 0fb9a71390 | |||
| e81cdefd71 | |||
| a7469cce9a | |||
| 8394f77767 | |||
| c97253597b | |||
| 3ceee973c5 |
166
CHANGELOG.md
Normal file
166
CHANGELOG.md
Normal 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
|
||||
10
Dockerfile
10
Dockerfile
@@ -2,12 +2,14 @@ FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY 05_scripts/requirements.txt .
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY 05_scripts/kobrax_moonraker_bridge.py .
|
||||
COPY 05_scripts/env_loader.py .
|
||||
COPY 05_scripts/kobrax_client.py .
|
||||
COPY kobrax_moonraker_bridge.py .
|
||||
COPY env_loader.py .
|
||||
COPY kobrax_client.py .
|
||||
COPY anycubic_slicer.crt .
|
||||
COPY anycubic_slicer.key .
|
||||
|
||||
EXPOSE 7125
|
||||
|
||||
|
||||
258
README.en.md
Normal file
258
README.en.md
Normal 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
297
README.md
@@ -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 |
|
||||
|-------|-------------|
|
||||
| `kobrax_moonraker_bridge.py` | Bridge-Hauptprogramm |
|
||||
| `extract_credentials.exe` | Zugangsdaten aus AnycubicSlicerNext auslesen (Windows) |
|
||||
| `extract_credentials` | Zugangsdaten aus AnycubicSlicerNext auslesen (Linux) |
|
||||
| `kobra_x_orcaslicer_preset.zip` | OrcaSlicer-Druckerprofil für den Kobra X |
|
||||
| `bridge.sh` | Service-Manager für Linux |
|
||||
| `Dockerfile` / `docker-compose.yml` | Docker-Deployment |
|
||||
| `.env.example` | Konfigurationsvorlage |
|
||||
- Druckerstatus (Temperatur, Fortschritt, Zustand)
|
||||
- Dateiübertragung und Druckstart
|
||||
- Drucksteuerung: Pause, Fortsetzen, Abbrechen
|
||||
- Temperaturregelung während des laufenden Drucks
|
||||
- Druckgeschwindigkeit (Leise / Normal / Sport)
|
||||
- AMS-Farbwechsel (Einziehen / Ausziehen)
|
||||
- Licht- und Lüftersteuerung
|
||||
- Web-UI mit Dashboard, Temperaturkarten, Achsensteuerung und Kameraansicht
|
||||
- Einstellungen und Self-Update direkt im Browser (⚙-Menü)
|
||||
- OrcaSlicer-Verbindung (Moonraker-Protokoll)
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Anycubic Kobra X im LAN-Modus (Drucker muss über LAN erreichbar sein, nicht nur über Anycubic-Cloud)
|
||||
- PC, NAS oder Server im gleichen Netzwerk (Windows oder Linux)
|
||||
- Docker oder Python 3.9+
|
||||
- MQTT-Zugangsdaten des Druckers → [Schritt 1](#schritt-1-zugangsdaten-ermitteln)
|
||||
- Anycubic Kobra X im lokalen Netzwerk, mit aktiviertem **LAN-Modus** (Drucker-Menü → LAN-Modus einschalten)
|
||||
- MQTT-Credentials des Druckers (→ siehe [Credentials extrahieren](#credentials-extrahieren))
|
||||
- Docker **oder** Python 3.9+ **oder** direkt die Linux-Binary
|
||||
|
||||
---
|
||||
|
||||
## Schnellstart
|
||||
|
||||
### Schritt 1: Zugangsdaten ermitteln
|
||||
|
||||
Die Bridge benötigt druckerspezifische MQTT-Zugangsdaten.
|
||||
|
||||
> Wichtig: Der Drucker muss sich im LAN-Modus befinden. Nur wenn der Drucker direkt über LAN (nicht ausschließlich über die Anycubic-Cloud) erreichbar ist, können die Zugangsdaten ermittelt und die Bridge genutzt werden.
|
||||
|
||||
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
|
||||
## Schnellstart – Docker (empfohlen)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# .env öffnen und Werte kontrollieren
|
||||
# 1. Bridge starten
|
||||
./start.sh
|
||||
```
|
||||
|
||||
`start.sh` baut das Docker-Image automatisch beim ersten Aufruf und startet die Bridge.
|
||||
|
||||
```
|
||||
# 2. Web-UI öffnen: http://BRIDGE-IP:7125
|
||||
# → Einstellungen (⚙) öffnen sich automatisch beim ersten Start
|
||||
# → Zugangsdaten eintragen (→ siehe Credentials extrahieren)
|
||||
|
||||
# 3. In OrcaSlicer: Drucker → "Moonraker" → http://BRIDGE-IP:7125
|
||||
```
|
||||
|
||||
Logs prüfen:
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
Stoppen:
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Schritt 3: Bridge starten
|
||||
## Schnellstart – Binary (Linux)
|
||||
|
||||
Option A – Docker (empfohlen):
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Läuft im Hintergrund, startet automatisch nach Systemneustart.
|
||||
|
||||
Option B – Linux Binary:
|
||||
```bash
|
||||
chmod +x kx-bridge
|
||||
./kx-bridge
|
||||
# Oder mit Service-Manager:
|
||||
./bridge.sh start
|
||||
```
|
||||
|
||||
Option C – Python direkt:
|
||||
Web-UI öffnen: `http://localhost:7125`
|
||||
→ Einstellungen (⚙) öffnen sich automatisch und führen durch die Erstkonfiguration.
|
||||
|
||||
---
|
||||
|
||||
## Schnellstart – Python direkt
|
||||
|
||||
```bash
|
||||
pip install aiohttp
|
||||
python kobrax_moonraker_bridge.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Schritt 4: OrcaSlicer-Profil installieren
|
||||
|
||||
1. `kobra_x_orcaslicer_preset.zip` in OrcaSlicer importieren:
|
||||
Datei → Konfigurationen importieren → ZIP auswählen
|
||||
2. Anycubic Kobra X als Drucker auswählen
|
||||
Web-UI öffnen: `http://localhost:7125`
|
||||
→ Einstellungen (⚙) öffnen sich automatisch beim ersten Start.
|
||||
|
||||
---
|
||||
|
||||
### Schritt 5: OrcaSlicer verbinden
|
||||
## Credentials extrahieren
|
||||
|
||||
1. Drucker-Einstellungen öffnen
|
||||
2. Verbindungstyp: Moonraker
|
||||
3. Adresse: `http://IP-DES-BRIDGE-PC:7125` eintragen
|
||||
4. Auf "Test" klicken – bei erfolgreicher Verbindung erscheint eine Bestätigungsmeldung
|
||||
Die MQTT-Zugangsdaten sind druckerspezifisch und werden beim ersten Verbindungsaufbau mit dem AnycubicSlicerNext generiert. Das Tool `extract_credentials` liest sie aus dem RAM des laufenden Slicers aus.
|
||||
|
||||
---
|
||||
**Voraussetzung:** AnycubicSlicerNext muss gestartet und mit dem Drucker verbunden sein (Drucker-Status wird angezeigt).
|
||||
|
||||
## bridge.sh – Service-Manager (Linux)
|
||||
### Windows
|
||||
|
||||
```
|
||||
extract_credentials.exe
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
./bridge.sh start # Im Hintergrund starten
|
||||
./bridge.sh stop # Beenden
|
||||
chmod +x extract_credentials
|
||||
./extract_credentials
|
||||
```
|
||||
|
||||
### Ausgabe
|
||||
|
||||
```
|
||||
[*] Prozess gefunden: AnycubicSlicerNext.exe (PID 1234)
|
||||
[*] 1986 Speichersegmente gelesen (738.8 MB)
|
||||
[*] Analysiere ... 100% (739 MB)
|
||||
|
||||
=======================================================
|
||||
ERGEBNISSE
|
||||
=======================================================
|
||||
Username userXXXXXXXXXX (Treffer: 47)
|
||||
Password *************** (Treffer: 1046)
|
||||
Device-ID xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (Treffer: 3504)
|
||||
Drucker-IP 192.168.x.x (Treffer: 3036)
|
||||
=======================================================
|
||||
```
|
||||
|
||||
Die angezeigten Werte in die Bridge-Einstellungen übertragen:
|
||||
Web-UI öffnen → **⚙ Einstellungen** → Felder ausfüllen → **Speichern & Neustart**
|
||||
|
||||
> Falls das Ergebnis unsicher wirkt: `--verbose` zeigt alle gefundenen Kandidaten.
|
||||
|
||||
Alle Credentials werden **ausschließlich lokal verarbeitet** — keine Übertragung an externe Server.
|
||||
|
||||
---
|
||||
|
||||
## Konfiguration (.env)
|
||||
|
||||
```env
|
||||
PRINTER_IP=192.168.x.x # IP des Druckers
|
||||
MQTT_PORT=9883 # Standard, nicht ändern
|
||||
MQTT_USERNAME=userXXXXXXXX # Beginnt mit "user"
|
||||
MQTT_PASSWORD=XXXXXXXXXXXXXX # ~15 Zeichen, gemischt
|
||||
DEVICE_ID=xxxxxxxx... # 32-stelliger Hex-String
|
||||
MODE_ID=20030 # Kobra X Standard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OrcaSlicer verbinden
|
||||
|
||||
1. Drucker hinzufügen → **Anycubic Kobra X** (oder generischer Klipper-Drucker)
|
||||
2. Verbindungstyp: **Moonraker**
|
||||
3. 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 status # Status anzeigen
|
||||
./bridge.sh status # Status und Port prüfen
|
||||
./bridge.sh log 50 # Letzte 50 Log-Zeilen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker – Nützliche Befehle
|
||||
## Druckerzustände
|
||||
|
||||
```bash
|
||||
docker compose up -d # Starten
|
||||
docker compose down # Stoppen
|
||||
docker compose logs -f # Logs verfolgen
|
||||
docker compose pull && docker compose up -d # Update
|
||||
```
|
||||
Die Bridge übersetzt die internen Kobra-Zustände in Moonraker-kompatible Zustände:
|
||||
|
||||
| Kobra-Zustand | Bedeutung |
|
||||
|---------------|-----------|
|
||||
| free | Bereit |
|
||||
| printing / busy | Druckt |
|
||||
| pausing / paused | Pausiert |
|
||||
| resuming / resumed | Wird fortgesetzt |
|
||||
| stopping / stoped | Wird gestoppt |
|
||||
| finished | Abgeschlossen |
|
||||
| canceled | Abgebrochen |
|
||||
| failed | Fehler |
|
||||
|
||||
---
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
Port 7125 belegt:
|
||||
**Port 7125 bereits belegt:**
|
||||
```bash
|
||||
./bridge.sh stop
|
||||
./bridge.sh stop # oder: fuser -k 7125/tcp
|
||||
./bridge.sh start
|
||||
```
|
||||
|
||||
Verbindungstest in OrcaSlicer schlägt fehl:
|
||||
- Firewall prüfen: Port 7125 muss erreichbar sein
|
||||
- Bridge-Log prüfen: `./bridge.sh log` oder `docker compose logs`
|
||||
- Drucker-IP in `.env` korrekt?
|
||||
**Credentials ungültig / Verbindung abgelehnt:**
|
||||
- AnycubicSlicerNext starten, mit Drucker verbinden, `extract_credentials` erneut ausführen
|
||||
- Falls das Ergebnis unsicher wirkt: `extract_credentials --verbose` zeigt alle Kandidaten an
|
||||
- Den richtigen Kandidaten manuell in `.env` eintragen und Bridge neu starten
|
||||
|
||||
Zugangsdaten werden abgelehnt:
|
||||
- AnycubicSlicerNext starten, mit Drucker verbinden
|
||||
- `extract_credentials --verbose` ausführen und alle Kandidaten prüfen
|
||||
- Richtigen Wert manuell in `.env` eintragen, Bridge neu starten
|
||||
**Temperaturänderungen werden ignoriert:**
|
||||
- Während eines laufenden Drucks werden Temperaturänderungen über einen separaten Kanal gesendet — das ist normal und wird von der Bridge automatisch erkannt.
|
||||
|
||||
Docker: Permission denied:
|
||||
**Docker: Permission denied:**
|
||||
```bash
|
||||
sudo usermod -aG docker $USER
|
||||
# Neu einloggen, dann erneut versuchen
|
||||
# Neu einloggen
|
||||
```
|
||||
|
||||
**Docker: .env nicht gefunden:**
|
||||
```bash
|
||||
# .env muss im gleichen Verzeichnis wie docker-compose.yml liegen
|
||||
cp .env.example .env && nano .env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Konfigurationsreferenz (.env)
|
||||
## Logs
|
||||
|
||||
| Parameter | Beschreibung | Beispiel |
|
||||
|-----------|-------------|---------|
|
||||
| `PRINTER_IP` | IP-Adresse des Druckers | `192.168.1.100` |
|
||||
| `MQTT_PORT` | MQTT-Port (nicht ändern) | `9883` |
|
||||
| `MQTT_USERNAME` | Benutzername (beginnt mit „user") | `userXXXXXXXXXX` |
|
||||
| `MQTT_PASSWORD` | Passwort (~15 Zeichen) | `***` |
|
||||
| `DEVICE_ID` | Geräte-ID (32 Hex-Zeichen) | `xxxxxxxx...` |
|
||||
| `MODE_ID` | Modell-ID (Kobra X Standard) | `20030` |
|
||||
```bash
|
||||
# Docker
|
||||
docker compose logs -f kx-bridge
|
||||
|
||||
# Binary / Python
|
||||
tail -f /tmp/bridge.log # bei Nutzung von bridge.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sicherheitshinweise
|
||||
|
||||
- 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).
|
||||
|
||||
`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.
|
||||
Dieses Projekt entstand durch Interoperabilitätsforschung gem. §69e UrhG.
|
||||
Ausschließlich für private, nicht-kommerzielle Nutzung.
|
||||
|
||||
24
anycubic_slicer.crt
Normal file
24
anycubic_slicer.crt
Normal 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
28
anycubic_slicer.key
Normal 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-----
|
||||
@@ -3,6 +3,8 @@ services:
|
||||
image: kx-bridge:latest
|
||||
build: .
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./.env:/app/.env
|
||||
ports:
|
||||
- "7125:7125"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -3,12 +3,14 @@ env_loader.py – lädt Verbindungsparameter aus .env (Repo-Root oder Arbeitsver
|
||||
Umgebungsvariablen haben Vorrang vor .env-Werten.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
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:
|
||||
# Suche .env im selben Verzeichnis, dann im Parent (Repo-Root)
|
||||
for base in (pathlib.Path(__file__).parent, pathlib.Path(__file__).parent.parent):
|
||||
for base in (_BASE, _BASE.parent):
|
||||
p = base / ".env"
|
||||
if p.is_file():
|
||||
return p
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
"""
|
||||
extract_credentials.py – Extrahiert Anycubic LAN-MQTT-Credentials aus dem RAM
|
||||
des laufenden AnycubicSlicerNext-Prozesses.
|
||||
extract_credentials.py – Extracts Anycubic LAN-MQTT credentials from the RAM
|
||||
of the running AnycubicSlicerNext process.
|
||||
|
||||
Voraussetzungen:
|
||||
- AnycubicSlicerNext läuft und ist mit dem Drucker verbunden
|
||||
- Gleiches Benutzerkonto wie der Slicer-Prozess (kein Admin nötig)
|
||||
Requirements:
|
||||
- AnycubicSlicerNext is running and connected to the printer
|
||||
- Same user account as the slicer process (no admin required)
|
||||
|
||||
Verwendung:
|
||||
Usage:
|
||||
python3 extract_credentials.py [--write-env] [--env-file ../.env]
|
||||
|
||||
Funktionsweise:
|
||||
1. Prozess "AnycubicSlicer.exe" (Windows) bzw. "AnycubicSlicer" (Linux) finden
|
||||
2. Speicherseiten des Prozesses durchsuchen (nur r/rw, keine Exec-Pages)
|
||||
3. Nach MQTT-Credential-Patterns suchen:
|
||||
How it works:
|
||||
1. Find process "AnycubicSlicer.exe" (Windows) or "AnycubicSlicer" (Linux)
|
||||
2. Scan memory pages of the process (only r/rw, no exec pages)
|
||||
3. Search for MQTT credential patterns:
|
||||
Username: user[A-Za-z0-9]{8,12}
|
||||
Password: [A-Za-z0-9+/]{13,18}
|
||||
Drucker-IP: d{1,3}.d{1,3}.d{1,3}.d{1,3}
|
||||
4. Kandidaten nach Plausibilität filtern und ausgeben
|
||||
5. Optional: .env-Datei schreiben
|
||||
Printer IP: d{1,3}.d{1,3}.d{1,3}.d{1,3}
|
||||
4. Filter candidates by plausibility and print results
|
||||
5. Optionally write .env file
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -28,48 +28,48 @@ import sys
|
||||
import platform
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plattform-Erkennung
|
||||
# Platform detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
IS_WINDOWS = platform.system() == "Windows"
|
||||
IS_LINUX = platform.system() == "Linux"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pattern
|
||||
# Patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Username: "user" + 8–12 alphanumerische Zeichen (drucker-generiert)
|
||||
# Username: "user" + 8–12 alphanumeric characters (printer-generated)
|
||||
RE_USERNAME = re.compile(rb'user[A-Za-z0-9]{8,12}(?=[^A-Za-z0-9]|$)')
|
||||
|
||||
# Password: 13–20 alphanumerische Zeichen (kein / da kein RTSP-Pfad)
|
||||
# Anycubic-Passwörter: gemischte Groß/Klein/Ziffern, kein Slash
|
||||
# Password: 13–20 alphanumeric characters (no / since no RTSP path)
|
||||
# Anycubic passwords: mixed upper/lower/digits, no slash
|
||||
RE_PASSWORD = re.compile(rb'[A-Za-z0-9]{13,20}(?=[^A-Za-z0-9]|$)')
|
||||
|
||||
# Kontext-Pattern: sucht Passwort das direkt nach "password" im Speicher steht
|
||||
# Context pattern: password directly following "password" in memory
|
||||
RE_PASSWORD_CTX = re.compile(rb'(?:password|passwd|Password)\x00{0,4}([A-Za-z0-9]{10,25})(?=[^A-Za-z0-9]|$)', re.IGNORECASE)
|
||||
|
||||
# Proximity-Pattern: Username gefolgt von Passwort in naher Umgebung (<512 Bytes)
|
||||
# Proximity pattern: username followed by password within close range (<512 bytes)
|
||||
RE_USER_PASS_PROXIMITY = re.compile(
|
||||
rb'(user[A-Za-z0-9]{8,12}).{1,512}?([A-Za-z0-9]{13,20})(?=[^A-Za-z0-9]|$)',
|
||||
re.DOTALL
|
||||
)
|
||||
|
||||
# IPv4-Adresse (kein localhost, kein Broadcast)
|
||||
# IPv4 address (no localhost, no broadcast)
|
||||
RE_IP = re.compile(rb'(?<![.\d])(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?![.\d])')
|
||||
|
||||
# mode_id: 5-stellige Zahl (z.B. 20030)
|
||||
# mode_id: 5-digit number (e.g. 20030)
|
||||
RE_MODE_ID = re.compile(rb'(?<!\d)(2\d{4})(?!\d)')
|
||||
|
||||
# device_id: 32 Hex-Zeichen (MD5-Format)
|
||||
# device_id: 32 hex characters (MD5 format)
|
||||
RE_DEVICE_ID = re.compile(rb'[0-9a-f]{32}(?=[^0-9a-f]|$)')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows – Speicher lesen via ctypes / ReadProcessMemory
|
||||
# Windows – read memory via ctypes / ReadProcessMemory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _win_find_pid(name: str) -> "int | None":
|
||||
"""Findet die PID eines Prozesses anhand des Namens (case-insensitive)."""
|
||||
"""Find the PID of a process by name (case-insensitive)."""
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
|
||||
@@ -110,7 +110,7 @@ def _win_find_pid(name: str) -> "int | None":
|
||||
|
||||
|
||||
def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]":
|
||||
"""Liest alle lesbaren Speicherseiten eines Windows-Prozesses."""
|
||||
"""Read all readable memory pages of a Windows process."""
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
|
||||
@@ -134,7 +134,7 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]":
|
||||
k32 = ctypes.windll.kernel32
|
||||
handle = k32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
raise PermissionError(f"OpenProcess fehlgeschlagen (PID {pid}): {ctypes.GetLastError()}")
|
||||
raise PermissionError(f"OpenProcess failed (PID {pid}): {ctypes.GetLastError()}")
|
||||
|
||||
chunks = []
|
||||
addr = 0
|
||||
@@ -147,7 +147,7 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]":
|
||||
mbi.State != MEM_COMMIT or
|
||||
mbi.Protect & PAGE_NOACCESS or
|
||||
mbi.Protect & PAGE_GUARD or
|
||||
mbi.RegionSize > 256 * 1024 * 1024 # >256 MB überspringen
|
||||
mbi.RegionSize > 256 * 1024 * 1024 # skip >256 MB regions
|
||||
)
|
||||
if not skip:
|
||||
buf = ctypes.create_string_buffer(mbi.RegionSize)
|
||||
@@ -156,7 +156,7 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]":
|
||||
buf, mbi.RegionSize, ctypes.byref(read)):
|
||||
chunks.append(bytes(buf[:read.value]))
|
||||
addr += mbi.RegionSize
|
||||
if addr >= 0x7FFFFFFFFFFF: # Ende des User-Space (64-bit)
|
||||
if addr >= 0x7FFFFFFFFFFF: # end of user space (64-bit)
|
||||
break
|
||||
finally:
|
||||
k32.CloseHandle(handle)
|
||||
@@ -165,11 +165,11 @@ def _win_read_memory(pid: int, chunk_size: int = 0x10000) -> "list[bytes]":
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Linux – Speicher lesen via /proc/{pid}/mem
|
||||
# Linux – read memory via /proc/{pid}/mem
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _linux_find_pid(name: str) -> "int | None":
|
||||
"""Findet PID anhand des Prozessnamens in /proc."""
|
||||
"""Find PID by process name in /proc."""
|
||||
for entry in os.listdir("/proc"):
|
||||
if not entry.isdigit():
|
||||
continue
|
||||
@@ -183,7 +183,7 @@ def _linux_find_pid(name: str) -> "int | None":
|
||||
|
||||
|
||||
def _linux_read_memory(pid: int) -> "list[bytes]":
|
||||
"""Liest lesbare Speichersegmente aus /proc/{pid}/mem."""
|
||||
"""Read readable memory segments from /proc/{pid}/mem."""
|
||||
chunks = []
|
||||
maps_path = f"/proc/{pid}/maps"
|
||||
mem_path = f"/proc/{pid}/mem"
|
||||
@@ -193,8 +193,8 @@ def _linux_read_memory(pid: int) -> "list[bytes]":
|
||||
mem = open(mem_path, "rb")
|
||||
except PermissionError:
|
||||
raise PermissionError(
|
||||
f"Kein Zugriff auf /proc/{pid}/mem — "
|
||||
"Script als gleicher Benutzer wie der Slicer starten."
|
||||
f"No access to /proc/{pid}/mem — "
|
||||
"run the script as the same user as the slicer process."
|
||||
)
|
||||
|
||||
for line in maps:
|
||||
@@ -202,16 +202,16 @@ def _linux_read_memory(pid: int) -> "list[bytes]":
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
perms = parts[1]
|
||||
if "r" not in perms: # nur lesbare Seiten
|
||||
if "r" not in perms: # readable pages only
|
||||
continue
|
||||
if "x" in perms: # Code-Seiten überspringen (keine Strings)
|
||||
if "x" in perms: # skip code pages (no strings)
|
||||
continue
|
||||
try:
|
||||
start, end = [int(x, 16) for x in parts[0].split("-")]
|
||||
except ValueError:
|
||||
continue
|
||||
size = end - start
|
||||
if size > 256 * 1024 * 1024: # >256 MB überspringen
|
||||
if size > 256 * 1024 * 1024: # skip >256 MB regions
|
||||
continue
|
||||
try:
|
||||
mem.seek(start)
|
||||
@@ -226,7 +226,7 @@ def _linux_read_memory(pid: int) -> "list[bytes]":
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pattern-Suche
|
||||
# Pattern search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _is_valid_ip(ip_bytes: bytes) -> bool:
|
||||
@@ -245,7 +245,7 @@ def _is_valid_ip(ip_bytes: bytes) -> bool:
|
||||
|
||||
|
||||
def search_chunks(chunks: "list[bytes]") -> dict:
|
||||
"""Durchsucht Speicher-Chunks nach Credential-Patterns."""
|
||||
"""Search memory chunks for credential patterns."""
|
||||
usernames = {} # value → count
|
||||
passwords = {}
|
||||
ips = {}
|
||||
@@ -256,12 +256,12 @@ def search_chunks(chunks: "list[bytes]") -> dict:
|
||||
if i % 50 == 0 or i == total - 1:
|
||||
pct = (i + 1) * 100 // total
|
||||
mb_done = sum(len(c) for c in chunks[:i+1]) / 1024 / 1024
|
||||
print(f"\r[*] Analysiere ... {pct:3d}% ({mb_done:.0f} MB)", end="", flush=True)
|
||||
print(f"\r[*] Scanning ... {pct:3d}% ({mb_done:.0f} MB)", end="", flush=True)
|
||||
for m in RE_USERNAME.finditer(chunk):
|
||||
v = m.group().decode("ascii", errors="replace")
|
||||
usernames[v] = usernames.get(v, 0) + 1
|
||||
|
||||
# Proximity: Passwort das innerhalb von 512 Bytes nach einem Username steht
|
||||
# Proximity: password within 512 bytes after a username
|
||||
for m in RE_USER_PASS_PROXIMITY.finditer(chunk):
|
||||
pw = m.group(2).decode("ascii", errors="replace")
|
||||
has_upper = any(c.isupper() for c in pw)
|
||||
@@ -272,7 +272,7 @@ def search_chunks(chunks: "list[bytes]") -> dict:
|
||||
|
||||
for m in RE_PASSWORD.finditer(chunk):
|
||||
v = m.group().decode("ascii", errors="replace")
|
||||
# Filter: mindestens 2 Großbuchstaben + 2 Kleinbuchstaben + 1 Ziffer
|
||||
# Filter: at least 2 uppercase + 2 lowercase + 1 digit
|
||||
has_upper = sum(1 for c in v if c.isupper()) >= 2
|
||||
has_lower = sum(1 for c in v if c.islower()) >= 2
|
||||
has_digit = sum(1 for c in v if c.isdigit()) >= 1
|
||||
@@ -289,9 +289,9 @@ def search_chunks(chunks: "list[bytes]") -> dict:
|
||||
v = m.group().decode("ascii")
|
||||
device_ids[v] = device_ids.get(v, 0) + 1
|
||||
|
||||
print() # Zeilenumbruch nach Fortschrittszeile
|
||||
print() # newline after progress line
|
||||
|
||||
# Nach Häufigkeit sortieren, häufigste zuerst
|
||||
# Sort by frequency, most frequent first
|
||||
def top(d, n=10):
|
||||
return sorted(d.items(), key=lambda x: -x[1])[:n]
|
||||
|
||||
@@ -304,7 +304,7 @@ def search_chunks(chunks: "list[bytes]") -> dict:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hauptprogramm
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SLICER_NAMES = [
|
||||
@@ -359,95 +359,95 @@ def write_env(results: dict, env_path: str,
|
||||
|
||||
with open(env_path, "w") as f:
|
||||
f.writelines(lines)
|
||||
print(f"\n✓ Credentials in '{env_path}' gespeichert.")
|
||||
print(f"\n✓ Credentials saved to '{env_path}'.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extrahiert MQTT-Credentials aus dem RAM des AnycubicSlicer-Prozesses"
|
||||
description="Extract MQTT credentials from the AnycubicSlicer process RAM"
|
||||
)
|
||||
parser.add_argument("--write-env", action="store_true",
|
||||
help="Gefundene Credentials in .env schreiben")
|
||||
help="Write found credentials to .env file")
|
||||
parser.add_argument("--env-file", default=None,
|
||||
help="Pfad zur .env-Datei (Standard: ../. env relativ zu diesem Script)")
|
||||
help="Path to .env file (default: ../.env relative to this script)")
|
||||
parser.add_argument("--pid", type=int, default=None,
|
||||
help="Prozess-PID direkt angeben (überspringt Auto-Erkennung)")
|
||||
help="Specify process PID directly (skips auto-detection)")
|
||||
parser.add_argument("--verbose", action="store_true",
|
||||
help="Alle Kandidaten ausgeben, nicht nur den besten")
|
||||
help="Show all candidates, not just the best match")
|
||||
args = parser.parse_args()
|
||||
|
||||
# .env-Pfad bestimmen
|
||||
# Determine .env path
|
||||
if args.env_file:
|
||||
env_path = args.env_file
|
||||
else:
|
||||
env_path = os.path.join(os.path.dirname(__file__), "..", ".env")
|
||||
env_path = os.path.normpath(env_path)
|
||||
|
||||
# Prozess finden
|
||||
# Find process
|
||||
if args.pid:
|
||||
pid, proc_name = args.pid, f"PID {args.pid}"
|
||||
else:
|
||||
print("[*] Suche AnycubicSlicer-Prozess ...")
|
||||
print("[*] Searching for AnycubicSlicer process ...")
|
||||
pid, proc_name = find_slicer_pid()
|
||||
if not pid:
|
||||
print("✗ AnycubicSlicer nicht gefunden. Bitte den Slicer starten und "
|
||||
"mit dem Drucker verbinden, dann erneut ausführen.")
|
||||
print("✗ AnycubicSlicer not found. Please start the slicer, connect it "
|
||||
"to the printer, then run this script again.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[*] Prozess gefunden: {proc_name} (PID {pid})")
|
||||
print(f"[*] Lese Prozess-Speicher ...")
|
||||
print(f"[*] Process found: {proc_name} (PID {pid})")
|
||||
print(f"[*] Reading process memory ...")
|
||||
|
||||
try:
|
||||
chunks = read_process(pid)
|
||||
except PermissionError as e:
|
||||
print(f"✗ Zugriffsfehler: {e}")
|
||||
print(f"✗ Permission error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
total_mb = sum(len(c) for c in chunks) / 1024 / 1024
|
||||
print(f"[*] {len(chunks)} Speichersegmente gelesen ({total_mb:.1f} MB)")
|
||||
print(f"[*] Durchsuche nach Credentials ...")
|
||||
print(f"[*] {len(chunks)} memory segments read ({total_mb:.1f} MB)")
|
||||
print(f"[*] Searching for credentials ...")
|
||||
|
||||
results = search_chunks(chunks)
|
||||
|
||||
# Ausgabe
|
||||
# Output
|
||||
print("\n" + "="*55)
|
||||
print(" ERGEBNISSE")
|
||||
print(" RESULTS")
|
||||
print("="*55)
|
||||
|
||||
def show(label, items, verbose):
|
||||
if not items:
|
||||
print(f" {label:12s} — nicht gefunden")
|
||||
print(f" {label:12s} — not found")
|
||||
return items[0][0] if items else ""
|
||||
best = items[0][0]
|
||||
print(f" {label:12s} {best} (Treffer: {items[0][1]})")
|
||||
print(f" {label:12s} {best} (matches: {items[0][1]})")
|
||||
if verbose and len(items) > 1:
|
||||
for val, cnt in items[1:]:
|
||||
print(f" {'':12s} {val} (Treffer: {cnt})")
|
||||
print(f" {'':12s} {val} (matches: {cnt})")
|
||||
return best
|
||||
|
||||
best_user = show("Username", results["usernames"], args.verbose)
|
||||
best_pass = show("Password", results["passwords"], args.verbose)
|
||||
best_device = show("Device-ID", results["device_ids"], args.verbose)
|
||||
|
||||
# IP: 192.168.x.x bevorzugen
|
||||
# IP: prefer 192.168.x.x
|
||||
lan_ips = [(ip, cnt) for ip, cnt in results["ips"]
|
||||
if ip.startswith("192.168.") or ip.startswith("10.") or ip.startswith("172.")]
|
||||
if not lan_ips:
|
||||
lan_ips = results["ips"]
|
||||
best_ip = show("Drucker-IP", lan_ips, args.verbose)
|
||||
best_ip = show("Printer IP", lan_ips, args.verbose)
|
||||
|
||||
print("="*55)
|
||||
|
||||
if not best_user or not best_pass:
|
||||
print("\n⚠ Keine vollständigen Credentials gefunden.")
|
||||
print(" Stelle sicher dass der Slicer MIT dem Drucker verbunden ist.")
|
||||
print("\n⚠ No complete credentials found.")
|
||||
print(" Make sure the slicer is connected to the printer.")
|
||||
sys.exit(1)
|
||||
|
||||
if args.write_env:
|
||||
write_env(results, env_path, best_user, best_pass, best_ip,
|
||||
device_id=best_device)
|
||||
else:
|
||||
print(f"\nHinweis: --write-env übergeben um Credentials in '{env_path}' zu speichern.")
|
||||
print(f"\nTip: pass --write-env to save credentials to '{env_path}'.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
BIN
knlogo.png
Normal file
BIN
knlogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -20,6 +20,7 @@ import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
@@ -27,7 +28,7 @@ from datetime import datetime
|
||||
|
||||
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")
|
||||
KEY_FILE = os.path.join(_SCRIPT_DIR, "anycubic_slicer.key")
|
||||
|
||||
@@ -197,6 +198,7 @@ class KobraXClient:
|
||||
|
||||
def _read_loop(self):
|
||||
last_ping = time.time()
|
||||
_empty_count = 0
|
||||
while self._running:
|
||||
if time.time() - last_ping > 30:
|
||||
with self._lock:
|
||||
@@ -211,7 +213,12 @@ class KobraXClient:
|
||||
try:
|
||||
data = self._sock.recv(65536)
|
||||
if not data:
|
||||
# Windows SSL kann kurzzeitig b"" liefern ohne echten EOF
|
||||
_empty_count += 1
|
||||
if _empty_count >= 5:
|
||||
raise ConnectionResetError("EOF")
|
||||
continue
|
||||
_empty_count = 0
|
||||
self._buf += data
|
||||
self._drain()
|
||||
except ssl.SSLWantReadError:
|
||||
@@ -394,14 +401,14 @@ class KobraXClient:
|
||||
def stop_camera(self) -> dict | None:
|
||||
return self.publish("video", "stopCapture")
|
||||
|
||||
def pause_print(self) -> dict | None:
|
||||
return self.publish("print", "pause")
|
||||
def pause_print(self, taskid: str = "-1") -> dict | None:
|
||||
return self.publish("print", "pause", {"taskid": taskid})
|
||||
|
||||
def resume_print(self) -> dict | None:
|
||||
return self.publish("print", "resume")
|
||||
def resume_print(self, taskid: str = "-1") -> dict | None:
|
||||
return self.publish("print", "resume", {"taskid": taskid})
|
||||
|
||||
def stop_print(self) -> dict | None:
|
||||
return self.publish("print", "stop")
|
||||
def stop_print(self, taskid: str = "-1") -> dict | None:
|
||||
return self.publish("print", "stop", {"taskid": taskid})
|
||||
|
||||
# -- G-Code Upload -------------------------------------------------------
|
||||
|
||||
|
||||
@@ -24,10 +24,24 @@ import tempfile
|
||||
import time
|
||||
import threading
|
||||
|
||||
# kobrax_client aus dem selben Verzeichnis importieren
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
# Bei PyInstaller-Binary liegt alles neben sys.executable, sonst neben __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
|
||||
|
||||
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
def _find_ffmpeg() -> str:
|
||||
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||
except ImportError:
|
||||
def _find_ffmpeg() -> str:
|
||||
exe_name = "ffmpeg.exe" if sys.platform == "win32" else "ffmpeg"
|
||||
local = os.path.join(_BASE, exe_name)
|
||||
if os.path.isfile(local):
|
||||
return local
|
||||
return "ffmpeg"
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
import aiohttp
|
||||
@@ -48,6 +62,12 @@ KOBRA_TO_KLIPPER_STATE = {
|
||||
"checking": "printing",
|
||||
"updated": "printing",
|
||||
"init": "printing",
|
||||
"pausing": "paused",
|
||||
"paused": "paused",
|
||||
"resuming": "printing",
|
||||
"resumed": "printing",
|
||||
"stopping": "printing",
|
||||
"stoped": "standby",
|
||||
"finished": "complete",
|
||||
"failed": "error",
|
||||
"canceled": "standby",
|
||||
@@ -73,6 +93,7 @@ class KobraXBridge:
|
||||
"filename": "",
|
||||
"progress": 0.0,
|
||||
"print_duration": 0,
|
||||
"remain_time": 0,
|
||||
"curr_layer": 0,
|
||||
"total_layers": 0,
|
||||
"printer_name": "Anycubic Kobra X",
|
||||
@@ -83,6 +104,7 @@ class KobraXBridge:
|
||||
"light_on": False,
|
||||
"light_brightness": 80,
|
||||
"taskid": "-1",
|
||||
"print_speed_mode": 2,
|
||||
}
|
||||
self._ams_slots: list[dict] = []
|
||||
self._ams_loaded_slot: int = -1
|
||||
@@ -117,17 +139,25 @@ class KobraXBridge:
|
||||
self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "printing")
|
||||
if 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"])
|
||||
if "progress" in d:
|
||||
self._state["progress"] = float(d["progress"]) / 100.0
|
||||
if "print_time" in d:
|
||||
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:
|
||||
self._state["curr_layer"] = d["curr_layer"]
|
||||
if "total_layers" in d:
|
||||
self._state["total_layers"] = d["total_layers"]
|
||||
if "taskid" in d:
|
||||
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()
|
||||
|
||||
def _on_info(self, payload: dict):
|
||||
@@ -152,6 +182,9 @@ class KobraXBridge:
|
||||
fan = d.get("fan_speed_pct")
|
||||
if fan is not None:
|
||||
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()
|
||||
|
||||
def _on_file(self, payload: dict):
|
||||
@@ -232,6 +265,7 @@ class KobraXBridge:
|
||||
"filename": s["filename"],
|
||||
"print_duration": s["print_duration"],
|
||||
"total_duration": s["print_duration"],
|
||||
"remain_time": s["remain_time"],
|
||||
"info": {
|
||||
"current_layer": s["curr_layer"],
|
||||
"total_layer": s["total_layers"],
|
||||
@@ -424,17 +458,19 @@ class KobraXBridge:
|
||||
}, status=201)
|
||||
|
||||
def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0):
|
||||
use_ams = len(self._ams_slots) > 0
|
||||
loaded = [(i, s) for i, s in enumerate(self._ams_slots) if s.get("status") == 5]
|
||||
use_ams = len(loaded) > 0
|
||||
ams_box_mapping = [
|
||||
{
|
||||
"paint_index": i,
|
||||
"ams_index": i,
|
||||
"paint_color": [255, 255, 255, 255],
|
||||
"ams_color": [255, 255, 255, 255],
|
||||
"material_type": s.get("material_type", "PLA"),
|
||||
"material_type": s.get("type", "PLA"),
|
||||
}
|
||||
for i, s in enumerate(self._ams_slots)
|
||||
for i, s in loaded
|
||||
]
|
||||
log.info(f"AMS-Slots: {len(loaded)}/{len(self._ams_slots)} belegt → {[i for i,_ in loaded]}")
|
||||
payload = {
|
||||
"taskid": "-1",
|
||||
"url": url,
|
||||
@@ -482,16 +518,19 @@ class KobraXBridge:
|
||||
|
||||
log.info(f"Druck starten: {filename}")
|
||||
|
||||
# AMS-Mapping aus gecachtem State
|
||||
# AMS-Mapping aus gecachtem State — leere Slots (status != 5) überspringen
|
||||
ams_box_mapping = []
|
||||
for i, slot in enumerate(self._ams_slots):
|
||||
if slot.get("status") != 5:
|
||||
log.info(f"AMS-Slot {i} leer (status={slot.get('status')}) – übersprungen")
|
||||
continue
|
||||
ams_box_mapping.append({
|
||||
"slot_index": i,
|
||||
"material_type": slot.get("material_type", "PLA"),
|
||||
"color": slot.get("color", "FFFFFF"),
|
||||
"material_type": slot.get("type", "PLA"),
|
||||
"color": slot.get("color", [255, 255, 255]),
|
||||
})
|
||||
|
||||
use_ams = len(self._ams_slots) > 0
|
||||
use_ams = len(ams_box_mapping) > 0
|
||||
|
||||
payload = {
|
||||
"filename": filename,
|
||||
@@ -512,17 +551,20 @@ class KobraXBridge:
|
||||
|
||||
async def handle_print_pause(self, request):
|
||||
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"})
|
||||
|
||||
async def handle_print_resume(self, request):
|
||||
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"})
|
||||
|
||||
async def handle_print_cancel(self, request):
|
||||
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"})
|
||||
|
||||
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);
|
||||
border-radius:8px;padding:6px 10px;cursor:pointer;font-size:13px;transition:.15s}
|
||||
.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{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-accent{background:var(--accent);color:#001a24}
|
||||
.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 ── */
|
||||
.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="toggleLang()" id="lang-btn">EN</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>
|
||||
|
||||
<!-- ═══ SETTINGS MODAL ═══ -->
|
||||
@@ -824,6 +881,7 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
|
||||
<div class="modal-field">
|
||||
<label id="lbl-printer-ip">Drucker-IP</label>
|
||||
<input type="text" id="s-printer-ip" placeholder="192.168.x.x">
|
||||
<small id="lbl-ip-hint" style="color:#f80;display:none"></small>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label id="lbl-mqtt-port">MQTT-Port</label>
|
||||
@@ -887,9 +945,9 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
|
||||
<!-- Kamera -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<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">
|
||||
<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">
|
||||
<input type="checkbox" id="d-light-toggle" onchange="setLight()">
|
||||
<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="meta-row" style="margin-top:6px">
|
||||
<span id="d-elapsed">–</span>
|
||||
<span id="d-remain" style="color:var(--acc)">–</span>
|
||||
<span id="d-layers" class="layer-badge">–</span>
|
||||
</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 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-val" id="d-bt">–</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>
|
||||
</div>
|
||||
<div class="home-btns">
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeAxis('X')"><span class="lbl-home-x">Home X</span></button>
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeAxis('Y')"><span class="lbl-home-y">Home Y</span></button>
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeAxis('Z')"><span class="lbl-home-z">Home Z</span></button>
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeZ()"><span class="lbl-home-z">Home Z</span></button>
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeXY()"><span class="lbl-home-xy">Home XY</span></button>
|
||||
<button class="btn btn-sm btn-accent" onclick="homeAll()"><span class="lbl-home-all">Home All</span></button>
|
||||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="disableMotors()"><span class="lbl-disable-motors">Motors Off</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title"><span>↕</span> <span id="ptitle-motion-z">Z-Achse</span></div>
|
||||
<div class="joypad" style="grid-template-columns:52px;grid-template-rows:repeat(2,52px)">
|
||||
<button class="joy" onclick="move(2,-1,getStep())" title="Z−">▲</button>
|
||||
<button class="joy" onclick="move(2,1,getStep())" title="Z+">▼</button>
|
||||
<button class="joy" onclick="move(2,1,getStep())" title="Z+">▲</button>
|
||||
<button class="joy" onclick="move(2,-1,getStep())" title="Z−">▼</button>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:8px;font-size:12px;color:var(--txt2)"><span class="lbl-step">Schrittweite:</span> <span id="step-display">1</span> mm</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 style="text-align:center;margin-top:8px;font-size:12px;color:var(--txt2)">Schrittweite: <span id="step-display">1</span> mm</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<button class="btn" style="flex:1" onclick="amsFeed(1)">⬇ Einziehen</button>
|
||||
<button id="btn-unload" class="btn" style="flex:1" onclick="amsFeed(2)">⬆ Ausziehen</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)">⬆ <span class="lbl-unload">Ausziehen</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1062,9 +1143,9 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
|
||||
<script>
|
||||
// ── State ──
|
||||
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:'–',
|
||||
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 camOn=false;
|
||||
var currentStep=1;
|
||||
@@ -1081,49 +1162,55 @@ function toggleTheme(){
|
||||
// ── i18n ──
|
||||
var LANG_DE={
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
panel_print_title:'Drucksteuerung',panel_print_btn_pause:'⏸ Pause',panel_print_btn_resume:'▶ Fortsetzen',panel_print_btn_cancel:'✕ Abbrechen',panel_print_temps_live:'Temperaturen (Live)',
|
||||
label_set:'Setzen',label_off:'Aus',
|
||||
panel_temps_nozzle:'Nozzle',panel_temps_bed:'Heizbett',panel_temps_chart:'Verlauf (letzte 60 Messungen)',label_target_c:'Ziel:',
|
||||
panel_motion_xy:'XY-Achsen',panel_motion_z:'Z-Achse',label_step:'Schrittweite:',btn_home_x:'Home X',btn_home_y:'Home Y',btn_home_z:'Home Z',btn_home_all:'Home All',
|
||||
panel_ams_title:'AMS / Filamentbox',ams_no_data:'Keine AMS-Daten empfangen',label_slot:'Slot',
|
||||
panel_motion_xy:'XY-Achsen',panel_motion_z:'Z-Achse',label_step:'Schrittweite:',btn_home_z:'Home Z',btn_home_xy:'Home XY',btn_home_all:'Home All',btn_disable_motors:'Motoren aus',
|
||||
panel_ams_title:'AMS / Filamentbox',ams_no_data:'Keine AMS-Daten empfangen',label_slot:'Slot',ams_empty:'Leer',
|
||||
panel_extras_light:'Licht',panel_extras_fan:'Lüfter',panel_extras_camera:'Kamera',btn_cam_start2:'▶ Start',btn_cam_stop2:'◼ Stop',
|
||||
panel_console_title:'Ereignis-Log',
|
||||
log_light_on:'Licht an',log_light_off:'Licht aus',log_fan:'Lüfter →',log_nozzle:'Nozzle →',log_bed:'Bett →',log_axis:'Achse',log_home:'Home',log_home_all:'Home All',log_cam_start:'Kamera gestartet:',log_cam_stop:'Kamera gestoppt',log_poll_error:'Poll-Fehler:',log_error:'Fehler:',
|
||||
confirm_cancel:'Druck wirklich abbrechen?',
|
||||
settings_title:'Einstellungen',settings_connection:'Verbindung',settings_poll:'Poll-Intervall',settings_version:'Version',
|
||||
settings_save:'Speichern & Neustart',settings_printer_ip:'Drucker-IP',settings_mqtt_port:'MQTT-Port',
|
||||
settings_username:'MQTT-Benutzername',settings_password:'MQTT-Passwort',settings_device_id:'Device-ID',settings_mode_id:'Mode-ID',
|
||||
settings_username:'MQTT-Benutzername',settings_password:'MQTT-Passwort',settings_device_id:'Device-ID',settings_mode_id:'Mode-ID',hint_ip_no_port:'Nur IP-Adresse, kein Port (z.B. 192.168.1.102)',
|
||||
update_check:'Auf Updates prüfen',update_checking:'Prüfe...',update_available:'verfügbar',update_none:'Bereits aktuell',
|
||||
update_apply:'Jetzt installieren',update_applying:'Lade herunter...',update_restarting:'Starte neu...',update_error:'Fehler'
|
||||
update_apply:'Jetzt installieren',update_applying:'Lade herunter...',update_restarting:'Starte neu...',update_error:'Fehler',
|
||||
btn_connect:'⚡ Verbinden',btn_disconnect:'✕ Trennen'
|
||||
};
|
||||
var LANG_EN={
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
panel_print_title:'Print Control',panel_print_btn_pause:'⏸ Pause',panel_print_btn_resume:'▶ Resume',panel_print_btn_cancel:'✕ Cancel',panel_print_temps_live:'Temperatures (Live)',
|
||||
label_set:'Set',label_off:'Off',
|
||||
panel_temps_nozzle:'Nozzle',panel_temps_bed:'Heated Bed',panel_temps_chart:'History (last 60 readings)',label_target_c:'Target:',
|
||||
panel_motion_xy:'XY Axes',panel_motion_z:'Z Axis',label_step:'Step size:',btn_home_x:'Home X',btn_home_y:'Home Y',btn_home_z:'Home Z',btn_home_all:'Home All',
|
||||
panel_ams_title:'AMS / Filament Box',ams_no_data:'No AMS data received',label_slot:'Slot',
|
||||
panel_motion_xy:'XY Axes',panel_motion_z:'Z Axis',label_step:'Step size:',btn_home_z:'Home Z',btn_home_xy:'Home XY',btn_home_all:'Home All',btn_disable_motors:'Motors Off',
|
||||
panel_ams_title:'AMS / Filament Box',ams_no_data:'No AMS data received',label_slot:'Slot',ams_empty:'Empty',
|
||||
panel_extras_light:'Light',panel_extras_fan:'Fan',panel_extras_camera:'Camera',btn_cam_start2:'▶ Start',btn_cam_stop2:'◼ Stop',
|
||||
panel_console_title:'Event Log',
|
||||
log_light_on:'Light on',log_light_off:'Light off',log_fan:'Fan →',log_nozzle:'Nozzle →',log_bed:'Bed →',log_axis:'Axis',log_home:'Home',log_home_all:'Home All',log_cam_start:'Camera started:',log_cam_stop:'Camera stopped',log_poll_error:'Poll error:',log_error:'Error:',
|
||||
confirm_cancel:'Really cancel the print?',
|
||||
settings_title:'Settings',settings_connection:'Connection',settings_poll:'Poll Interval',settings_version:'Version',
|
||||
settings_save:'Save & Restart',settings_printer_ip:'Printer IP',settings_mqtt_port:'MQTT Port',
|
||||
settings_username:'MQTT Username',settings_password:'MQTT Password',settings_device_id:'Device ID',settings_mode_id:'Mode ID',
|
||||
settings_username:'MQTT Username',settings_password:'MQTT Password',settings_device_id:'Device ID',settings_mode_id:'Mode ID',hint_ip_no_port:'IP address only, no port (e.g. 192.168.1.102)',
|
||||
update_check:'Check for Updates',update_checking:'Checking...',update_available:'available',update_none:'Already up to date',
|
||||
update_apply:'Install Now',update_applying:'Downloading...',update_restarting:'Restarting...',update_error:'Error'
|
||||
update_apply:'Install Now',update_applying:'Downloading...',update_restarting:'Restarting...',update_error:'Error',
|
||||
btn_connect:'⚡ Connect',btn_disconnect:'✕ Disconnect'
|
||||
};
|
||||
var currentLang='de';
|
||||
var T=LANG_DE;
|
||||
@@ -1146,7 +1233,11 @@ function applyLang(){
|
||||
setText('d-card-progress',T.card_progress);
|
||||
setText('d-card-temps',T.card_temps);
|
||||
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-lbl-light',T.lbl_light);
|
||||
setText('d-lbl-bed',T.label_bed);
|
||||
// Dashboard buttons
|
||||
setText('d-btn-pause',T.btn_pause);
|
||||
setText('d-btn-resume',T.btn_resume);
|
||||
@@ -1160,11 +1251,12 @@ function applyLang(){
|
||||
// Axis labels
|
||||
setText('ptitle-motion-xy',T.panel_motion_xy);
|
||||
setText('ptitle-motion-z',T.panel_motion_z);
|
||||
document.querySelectorAll('.lbl-home-x').forEach(e=>e.textContent=T.btn_home_x);
|
||||
document.querySelectorAll('.lbl-home-y').forEach(e=>e.textContent=T.btn_home_y);
|
||||
document.querySelectorAll('.lbl-home-z').forEach(e=>e.textContent=T.btn_home_z);
|
||||
document.querySelectorAll('.lbl-home-xy').forEach(e=>e.textContent=T.btn_home_xy);
|
||||
document.querySelectorAll('.lbl-home-all').forEach(e=>e.textContent=T.btn_home_all);
|
||||
document.querySelectorAll('.lbl-disable-motors').forEach(e=>e.textContent=T.btn_disable_motors);
|
||||
document.querySelectorAll('.lbl-step').forEach(e=>e.textContent=T.label_step);
|
||||
document.querySelectorAll('.temp-input').forEach(e=>e.setAttribute('placeholder',T.label_target_c.replace(':','')));
|
||||
// Console
|
||||
setText('ptitle-console',T.panel_console_title);
|
||||
// Settings modal
|
||||
@@ -1181,6 +1273,15 @@ function applyLang(){
|
||||
setText('lbl-mode-id',T.settings_mode_id);
|
||||
setText('lbl-update-check',T.update_check);
|
||||
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(){
|
||||
@@ -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.documentElement.setAttribute('lang',l);
|
||||
// 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 ──
|
||||
@@ -1248,6 +1355,8 @@ function applyState(){
|
||||
|
||||
var elapsed=fmtTime(s.print_duration);
|
||||
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 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 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
|
||||
if(s.ams_slots&&s.ams_slots.length){
|
||||
var html='';
|
||||
s.ams_slots.forEach(function(slot,i){
|
||||
var rgb=Array.isArray(slot.color)?slot.color:[128,128,128];
|
||||
var empty=slot.status!==5;
|
||||
var rgb=empty?[80,80,80]:(Array.isArray(slot.color)?slot.color:[128,128,128]);
|
||||
var col='rgb('+rgb[0]+','+rgb[1]+','+rgb[2]+')';
|
||||
var active=slot.status===1||slot.active;
|
||||
var pct=slot.consumables_percent!=null?slot.consumables_percent+'%':'–';
|
||||
html+='<div class="ams-slot'+(active?' active':'')+ '" style="--slot-color:'+col+'">'
|
||||
var pct=empty?T.ams_empty:(slot.consumables_percent!=null?slot.consumables_percent+'%':'–');
|
||||
html+='<div class="ams-slot'+(active?' active':'')+(empty?' empty':'')+ '" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+'">'
|
||||
+'<div class="slot-circle" style="background:'+col+'"></div>'
|
||||
+'<div class="slot-material">'+(slot.type||slot.material_type||'–')+'</div>'
|
||||
+'<div class="slot-material">'+(empty?'–':(slot.type||slot.material_type||'–'))+'</div>'
|
||||
+'<div class="slot-label">Slot '+(slot.index!=null?slot.index+1:i+1)+'</div>'
|
||||
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>'
|
||||
+'</div>';
|
||||
@@ -1297,6 +1416,33 @@ function applyState(){
|
||||
if(s.print_state==='printing'&&!camOn&&s.camera_url){
|
||||
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 ──
|
||||
@@ -1352,6 +1498,13 @@ function openSettings(){
|
||||
function closeSettings(){
|
||||
document.getElementById('settings-modal').classList.remove('open');
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded',function(){
|
||||
document.getElementById('s-printer-ip').addEventListener('input',function(){
|
||||
var hint=document.getElementById('lbl-ip-hint');
|
||||
if(this.value.includes(':')){hint.textContent=T.hint_ip_no_port;hint.style.display='block';}
|
||||
else{hint.style.display='none';}
|
||||
});
|
||||
});
|
||||
function setPoll(ms){
|
||||
document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')});
|
||||
var id='poll-'+Math.round(ms/1000);
|
||||
@@ -1457,16 +1610,25 @@ function move(axis,dir,dist){
|
||||
.catch(function(e){clog('Achse-Fehler: '+e,'msg-err')});
|
||||
}
|
||||
function homeAll(){
|
||||
post('/api/axis',{axis:4,move_type:2,distance:0})
|
||||
post('/api/axis',{axis:5,move_type:2,distance:0})
|
||||
.then(function(){clog('Home All','msg-ok')})
|
||||
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')});
|
||||
}
|
||||
function homeAxis(ax){
|
||||
var m={X:1,Y:2,Z:3};
|
||||
post('/api/axis',{axis:m[ax],move_type:2,distance:0})
|
||||
.then(function(){clog('Home '+ax,'msg-ok')})
|
||||
function homeXY(){
|
||||
post('/api/axis',{axis:4,move_type:2,distance:0})
|
||||
.then(function(){clog('Home XY','msg-ok')})
|
||||
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')});
|
||||
}
|
||||
function homeZ(){
|
||||
post('/api/axis',{axis:3,move_type:2,distance:0})
|
||||
.then(function(){clog('Home Z','msg-ok')})
|
||||
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')});
|
||||
}
|
||||
function disableMotors(){
|
||||
post('/api/axis',{action:'turnOff'})
|
||||
.then(function(){clog('Motors Off','msg-ok')})
|
||||
.catch(function(e){clog('Motors-Fehler: '+e,'msg-err')});
|
||||
}
|
||||
|
||||
// ── Temperature ──
|
||||
function setNozzle(){
|
||||
@@ -1478,7 +1640,7 @@ function setNozzle(){
|
||||
function setBed(){
|
||||
var v=parseFloat(document.getElementById('p-bed-inp').value||0);
|
||||
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')});
|
||||
}
|
||||
|
||||
@@ -1490,6 +1652,17 @@ function setLight(){
|
||||
.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 ──
|
||||
function setFan(){
|
||||
var v=parseInt(document.getElementById('d-fan').value);
|
||||
@@ -1510,7 +1683,7 @@ function quickFan(v){
|
||||
function amsFeed(type){
|
||||
var slot=parseInt(document.getElementById('ams-slot-sel').value);
|
||||
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')});
|
||||
}
|
||||
|
||||
@@ -1600,6 +1773,43 @@ function toggleCam(){if(camOn)camStop();else camStart()}
|
||||
self._state["fan_speed"] = speed
|
||||
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):
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -1626,10 +1836,16 @@ function toggleCam(){if(camOn)camStop();else camStart()}
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
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))
|
||||
move_type = int(body.get("move_type", 2))
|
||||
distance = float(body.get("distance", 0))
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, lambda: self.client.publish(
|
||||
"axis", "move",
|
||||
{"axis": axis, "move_type": move_type, "distance": distance},
|
||||
@@ -1698,14 +1914,6 @@ function toggleCam(){if(camOn)camStop();else camStart()}
|
||||
if not url:
|
||||
return web.Response(status=503, text="Keine Kamera-URL bekannt")
|
||||
|
||||
boundary = "kobraxframe"
|
||||
resp = web.StreamResponse(headers={
|
||||
"Content-Type": f"multipart/x-mixed-replace;boundary={boundary}",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
})
|
||||
await resp.prepare(request)
|
||||
|
||||
is_rtsp = url.lower().startswith("rtsp://")
|
||||
ffmpeg_input_args = [
|
||||
"-fflags", "nobuffer",
|
||||
@@ -1714,11 +1922,13 @@ function toggleCam(){if(camOn)camStop();else camStart()}
|
||||
if is_rtsp:
|
||||
ffmpeg_input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
|
||||
else:
|
||||
# HTTP-FLV/HLS: braucht mehr Probe-Puffer für Container-Erkennung
|
||||
ffmpeg_input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"]
|
||||
|
||||
# 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(
|
||||
"ffmpeg", "-loglevel", "quiet",
|
||||
_find_ffmpeg(), "-loglevel", "quiet",
|
||||
*ffmpeg_input_args,
|
||||
"-i", url,
|
||||
"-vf", "fps=15,scale=640:-1",
|
||||
@@ -1730,6 +1940,20 @@ function toggleCam(){if(camOn)camStop();else camStart()}
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
log.warning("Kamera: ffmpeg nicht gefunden – Kamerastream nicht verfügbar")
|
||||
return web.Response(status=503, text="ffmpeg not found")
|
||||
except Exception as e:
|
||||
log.warning(f"Kamera: ffmpeg konnte nicht gestartet werden: {e}")
|
||||
return web.Response(status=503, text=str(e))
|
||||
|
||||
boundary = "kobraxframe"
|
||||
resp = web.StreamResponse(headers={
|
||||
"Content-Type": f"multipart/x-mixed-replace;boundary={boundary}",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
})
|
||||
await resp.prepare(request)
|
||||
|
||||
buf = b""
|
||||
try:
|
||||
@@ -1794,11 +2018,13 @@ function toggleCam(){if(camOn)camStop();else camStart()}
|
||||
"bed_target": s["bed_target"],
|
||||
"progress": s["progress"],
|
||||
"print_duration": s["print_duration"],
|
||||
"remain_time": s["remain_time"],
|
||||
"curr_layer": s["curr_layer"],
|
||||
"total_layers": s["total_layers"],
|
||||
"filename": s["filename"],
|
||||
"camera_url": s["camera_url"],
|
||||
"fan_speed": s["fan_speed"],
|
||||
"print_speed_mode": s["print_speed_mode"],
|
||||
"light_on": s["light_on"],
|
||||
"light_brightness": s["light_brightness"],
|
||||
"ams_slots": self._ams_slots,
|
||||
@@ -1860,7 +2086,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
|
||||
|
||||
def _find_env_path(self) -> pathlib.Path:
|
||||
"""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):
|
||||
p = base / ".env"
|
||||
if p.is_file():
|
||||
@@ -1892,7 +2118,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
|
||||
lines.append(line)
|
||||
# Werte aktualisieren
|
||||
mapping = {
|
||||
"PRINTER_IP": str(data.get("printer_ip", existing.get("PRINTER_IP", ""))),
|
||||
"PRINTER_IP": str(data.get("printer_ip", existing.get("PRINTER_IP", ""))).split(":")[0],
|
||||
"MQTT_PORT": str(data.get("mqtt_port", existing.get("MQTT_PORT", "9883"))),
|
||||
"MQTT_USERNAME": str(data.get("username", existing.get("MQTT_USERNAME",""))),
|
||||
"MQTT_PASSWORD": str(data.get("password", existing.get("MQTT_PASSWORD",""))),
|
||||
@@ -1923,7 +2149,12 @@ function toggleCam(){if(camOn)camStop();else camStart()}
|
||||
|
||||
def _restart_bridge(self):
|
||||
log.info("Bridge wird neu gestartet …")
|
||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||
exe = sys.executable
|
||||
# PyInstaller frozen binary: sys.argv[0] == sys.executable → nicht doppelt übergeben
|
||||
if getattr(sys, "frozen", False):
|
||||
os.execv(exe, [exe])
|
||||
else:
|
||||
os.execv(exe, [exe] + sys.argv)
|
||||
|
||||
# ─── Update ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"
|
||||
|
||||
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"
|
||||
if p.is_file():
|
||||
return p.read_text(encoding="utf-8").strip()
|
||||
return "unknown"
|
||||
|
||||
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"
|
||||
if p.is_file():
|
||||
p.write_text(version + "\n", encoding="utf-8")
|
||||
return
|
||||
# Fallback: neben dem Script
|
||||
(pathlib.Path(__file__).parent.parent / "VERSION").write_text(version + "\n", encoding="utf-8")
|
||||
(pathlib.Path(_BASE) / "VERSION").write_text(version + "\n", encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _parse_version(v: str) -> "tuple[int, ...]":
|
||||
@@ -1990,7 +2220,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
|
||||
new_tag = data.get("tag", "")
|
||||
if not download_url:
|
||||
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:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
while not stop_event.is_set():
|
||||
@@ -2182,7 +2412,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
|
||||
self._state["kobra_state"] = "free"
|
||||
log.info("MQTT-Verbindung wiederhergestellt")
|
||||
except Exception as e:
|
||||
log.warning(f"Verbindungsaufbau fehlgeschlagen: {e}")
|
||||
log.warning(f"Verbindungsaufbau fehlgeschlagen: {_mqtt_error_msg(e)}")
|
||||
stop_event.wait(_probe_interval)
|
||||
continue
|
||||
else:
|
||||
@@ -2225,6 +2455,13 @@ function toggleCam(){if(camOn)camStop();else camStart()}
|
||||
# App factory + main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mqtt_error_msg(exc: Exception) -> str:
|
||||
msg = str(exc)
|
||||
if "20020005" in msg:
|
||||
return "Falsche MQTT-Zugangsdaten (falscher Benutzername, Passwort oder Device-ID)"
|
||||
return msg
|
||||
|
||||
|
||||
def build_app(bridge: KobraXBridge) -> web.Application:
|
||||
app = web.Application()
|
||||
r = app.router
|
||||
@@ -2255,6 +2492,9 @@ def build_app(bridge: KobraXBridge) -> web.Application:
|
||||
# New API endpoints
|
||||
r.add_post("/api/light", bridge.handle_api_light)
|
||||
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/axis", bridge.handle_api_axis)
|
||||
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):
|
||||
log.info(f"Verbinde mit Drucker {args.printer_ip}:{args.mqtt_port} …")
|
||||
client = KobraXClient(
|
||||
host=args.printer_ip,
|
||||
port=args.mqtt_port,
|
||||
@@ -2294,11 +2533,18 @@ async def run_bridge(args):
|
||||
client_id="kobrax_bridge",
|
||||
)
|
||||
|
||||
bridge = KobraXBridge(client, args=args)
|
||||
|
||||
# Verbindungsversuch beim Start – bei Fehler im Offline-Modus weiterlaufen
|
||||
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)
|
||||
log.info("MQTT verbunden")
|
||||
|
||||
bridge = KobraXBridge(client, args=args)
|
||||
except Exception as e:
|
||||
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)
|
||||
|
||||
stop_event = threading.Event()
|
||||
@@ -2342,6 +2588,12 @@ def main():
|
||||
parser.add_argument("--port", type=int, default=7125,
|
||||
help="HTTP/WS-Port (Moonraker-Standard: 7125)")
|
||||
args = parser.parse_args()
|
||||
if args.printer_ip and ":" in args.printer_ip:
|
||||
args.printer_ip = args.printer_ip.split(":")[0]
|
||||
|
||||
# Windows braucht ProactorEventLoop für asyncio.create_subprocess_exec
|
||||
if sys.platform == "win32":
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
|
||||
asyncio.run(run_bridge(args))
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
aiohttp>=3.9
|
||||
imageio-ffmpeg>=0.4.9
|
||||
|
||||
67
start.sh
Executable file
67
start.sh
Executable 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"
|
||||
Reference in New Issue
Block a user