Compare commits

..

10 Commits

Author SHA1 Message Date
98303f1197 release: v0.9.1-beta9 2026-04-25 14:02:51 +02:00
808ac13ce0 release: v0.9.1-beta8 2026-04-25 13:55:45 +02:00
14400dd799 release: v0.9.1-beta7 2026-04-22 00:27:37 +02:00
0fb9a71390 release: v0.9.1-beta6 2026-04-22 00:13:31 +02:00
e81cdefd71 docs: Version auf 0.9.1-beta5 aktualisiert 2026-04-22 00:02:16 +02:00
a7469cce9a release: v0.9.1-beta5 2026-04-21 23:59:04 +02:00
8394f77767 docs: README aktualisiert + englische Version hinzugefügt 2026-04-21 23:57:31 +02:00
c97253597b release: v0.9.1-beta4 2026-04-21 22:31:39 +02:00
3ceee973c5 release: v0.9.1-beta3 2026-04-21 22:27:01 +02:00
0c07f70fee release: v0.9.1-beta2 2026-04-21 21:43:28 +02:00
9 changed files with 642 additions and 73 deletions

View File

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

250
README.en.md Normal file
View File

@@ -0,0 +1,250 @@
# KX-Bridge Anycubic Kobra X Moonraker Bridge
**Version:** 0.9.1-beta5
**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 (LAN, no Wi-Fi client isolation)
- 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. Create .env
cp .env.example .env
# Fill in your printer data (→ extract_credentials)
# 2. Start the bridge
docker compose up -d
# 3. In OrcaSlicer: add printer → "Moonraker" → http://BRIDGE-IP:7125
```
Check logs:
```bash
docker compose logs -f
```
Update:
```bash
docker compose pull && docker compose up -d
```
---
## Quick start Binary (Linux)
```bash
chmod +x kx-bridge
./kx-bridge --printer-ip 192.168.x.x --username userXXXX --password XXXXX \
--device-id XXXXX --mode-id 20030
```
Or place a `.env` file in the same directory — the bridge reads it automatically.
---
## Quick start Python directly
```bash
pip install aiohttp
python kobrax_moonraker_bridge.py --printer-ip 192.168.x.x ...
# Or fill in .env and start without arguments
python kobrax_moonraker_bridge.py
```
---
## 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.
### Windows
```
extract_credentials.exe --write-env
```
Writes the found credentials directly to `.env`.
### Linux
```bash
chmod +x extract_credentials
./extract_credentials --write-env
```
### 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)
=======================================================
Hint: pass --write-env to save credentials to '.env'.
```
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 binds to `0.0.0.0:7125` by default — use on your local network only
- `.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.

View File

@@ -2,7 +2,9 @@
Verbindet den Anycubic Kobra X mit OrcaSlicer ohne Klipper, ohne Raspberry Pi.
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.
KX-Bridge läuft auf deinem PC oder NAS und stellt eine Moonraker-kompatible Schnittstelle bereit, über die OrcaSlicer den Drucker direkt steuern kann: Druckstart, Temperatur, Fortschritt, Pause/Fortsetzen/Abbrechen, AMS-Farbwechsel, Druckgeschwindigkeit und mehr.
**Version:** 0.9.1-beta5
---
@@ -11,6 +13,7 @@ KX-Bridge läuft auf deinem PC oder NAS und stellt eine Schnittstelle bereit, ü
| Datei | Beschreibung |
|-------|-------------|
| `kobrax_moonraker_bridge.py` | Bridge-Hauptprogramm |
| `kx-bridge` | Vorkompilierte Linux-Binary |
| `extract_credentials.exe` | Zugangsdaten aus AnycubicSlicerNext auslesen (Windows) |
| `extract_credentials` | Zugangsdaten aus AnycubicSlicerNext auslesen (Linux) |
| `kobra_x_orcaslicer_preset.zip` | OrcaSlicer-Druckerprofil für den Kobra X |
@@ -20,6 +23,21 @@ KX-Bridge läuft auf deinem PC oder NAS und stellt eine Schnittstelle bereit, ü
---
## Was wird unterstützt?
- 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)
@@ -35,18 +53,16 @@ KX-Bridge läuft auf deinem PC oder NAS und stellt eine Schnittstelle bereit, ü
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.
> **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.
AnycubicSlicerNext starten und mit dem Drucker verbinden (bis der Drucker-Status angezeigt wird), dann:
Vorbereitung: AnycubicSlicerNext starten und mit dem Drucker verbinden (bis der Drucker-Status angezeigt wird).
Windows:
**Windows:**
```
extract_credentials.exe --write-env
```
Linux:
**Linux:**
```bash
chmod +x extract_credentials
./extract_credentials --write-env
@@ -69,13 +85,13 @@ cp .env.example .env
### Schritt 3: Bridge starten
Option A Docker (empfohlen):
**Option A Docker (empfohlen):**
```bash
docker compose up -d
```
Läuft im Hintergrund, startet automatisch nach Systemneustart.
Option B Linux Binary:
**Option B Linux Binary:**
```bash
chmod +x kx-bridge
./kx-bridge
@@ -83,7 +99,7 @@ chmod +x kx-bridge
./bridge.sh start
```
Option C Python direkt:
**Option C Python direkt:**
```bash
pip install aiohttp
python kobrax_moonraker_bridge.py
@@ -102,9 +118,30 @@ python kobrax_moonraker_bridge.py
### Schritt 5: OrcaSlicer verbinden
1. Drucker-Einstellungen öffnen
2. Verbindungstyp: Moonraker
2. Verbindungstyp: **Moonraker**
3. Adresse: `http://IP-DES-BRIDGE-PC:7125` eintragen
4. Auf "Test" klicken bei erfolgreicher Verbindung erscheint eine Bestätigungsmeldung
4. Auf Test" klicken bei erfolgreicher Verbindung erscheint eine Bestätigungsmeldung
---
## Web-UI
Die Bridge stellt unter `http://BRIDGE-IP: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 vom Drucker unterstützt) |
| ⚙ Einstellungen | MQTT-Zugangsdaten, Poll-Intervall, Self-Update |
### Self-Update
Über das ⚙-Menü in der Web-UI kann die Bridge auf neue Versionen prüfen und sich selbst aktualisieren — ohne Neuinstallation. Nach dem Download startet sie automatisch neu.
---
@@ -133,23 +170,26 @@ docker compose pull && docker compose up -d # Update
## Fehlerbehebung
Port 7125 belegt:
**Port 7125 belegt:**
```bash
./bridge.sh stop
./bridge.sh start
```
Verbindungstest in OrcaSlicer schlägt fehl:
**Verbindungstest in OrcaSlicer schlägt fehl:**
- Firewall prüfen: Port 7125 muss erreichbar sein
- Bridge-Log prüfen: `./bridge.sh log` oder `docker compose logs`
- Drucker-IP in `.env` korrekt?
Zugangsdaten werden abgelehnt:
**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
Docker: Permission denied:
**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:**
```bash
sudo usermod -aG docker $USER
# Neu einloggen, dann erneut versuchen
@@ -172,7 +212,9 @@ sudo usermod -aG docker $USER
## Sicherheitshinweise
- Alle Zugangsdaten werden ausschließlich lokal verarbeitet
- Die Bridge bindet standardmäßig auf `0.0.0.0:7125` — nur im lokalen Netzwerk nutzen
- `.env` enthält Drucker-Credentials — nicht öffentlich teilen
- Alle Zugangsdaten werden ausschließlich lokal verarbeitet — keine Übertragung an externe Server
---
@@ -180,6 +222,6 @@ sudo usermod -aG docker $USER
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.
`extract_credentials` liest ausschließlich den Arbeitsspeicher des auf deinem eigenen PC laufenden AnycubicSlicerNext-Prozesses. Es werden keine Daten übertragen oder gespeichert, außer in die lokale `.env`-Datei.
Das Projekt steht in keiner Verbindung zu Anycubic und wird nicht kommerziell betrieben.

View File

@@ -1 +1 @@
0.9.1-beta1
0.9.1-beta9

24
anycubic_slicer.crt Normal file
View File

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

28
anycubic_slicer.key Normal file
View File

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

View File

@@ -3,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

View File

@@ -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")
@@ -124,6 +125,10 @@ class KobraXClient:
return (f"anycubic/anycubicCloud/v1/slicer/printer/"
f"{self.mode_id}/{self.device_id}/{msg_type}")
def _web_topic(self, msg_type: str) -> str:
return (f"anycubic/anycubicCloud/v1/web/printer/"
f"{self.mode_id}/{self.device_id}/{msg_type}")
def _sub_topic(self) -> str:
return (f"anycubic/anycubicCloud/v1/printer/public/"
f"{self.mode_id}/{self.device_id}/#")
@@ -345,6 +350,23 @@ class KobraXClient:
return None
return entry["result"]
def publish_web(self, msg_type: str, action: str, data=None) -> None:
"""Fire-and-forget publish on the web/printer topic (used for runtime updates during print)."""
msgid = str(uuid.uuid4())
payload = json.dumps({
"type": msg_type,
"action": action,
"msgid": msgid,
"timestamp": int(time.time() * 1000),
"data": data,
}, separators=(",", ":"))
topic = self._web_topic(msg_type)
try:
with self._lock:
self._sock.sendall(_build_publish(topic, payload))
except Exception as e:
print(f"[kobrax] web send error: {e}")
# -- High-level commands -------------------------------------------------
def query_info(self) -> dict | None:
@@ -373,14 +395,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 -------------------------------------------------------

View File

@@ -24,8 +24,9 @@ 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:
@@ -48,6 +49,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 +80,7 @@ class KobraXBridge:
"filename": "",
"progress": 0.0,
"print_duration": 0,
"remain_time": 0,
"curr_layer": 0,
"total_layers": 0,
"printer_name": "Anycubic Kobra X",
@@ -82,6 +90,8 @@ class KobraXBridge:
"fan_speed": 0,
"light_on": False,
"light_brightness": 80,
"taskid": "-1",
"print_speed_mode": 2,
}
self._ams_slots: list[dict] = []
self._ams_loaded_slot: int = -1
@@ -116,15 +126,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):
@@ -149,6 +169,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):
@@ -229,6 +252,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"],
@@ -509,17 +533,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):
@@ -570,6 +597,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}
@@ -634,6 +667,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}
@@ -806,6 +847,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 ═══ -->
@@ -884,9 +926,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>
@@ -913,6 +955,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>
@@ -944,7 +987,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>
@@ -999,7 +1042,29 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<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)">Schrittweite: <span id="step-display">1</span> mm</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>
<!-- Lüfter -->
@@ -1033,8 +1098,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>
@@ -1059,9 +1124,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;
@@ -1078,9 +1143,11 @@ 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',
@@ -1097,13 +1164,16 @@ var LANG_DE={
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',
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',
@@ -1120,7 +1190,8 @@ var LANG_EN={
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',
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;
@@ -1143,7 +1214,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);
@@ -1162,6 +1237,7 @@ function applyLang(){
document.querySelectorAll('.lbl-home-z').forEach(e=>e.textContent=T.btn_home_z);
document.querySelectorAll('.lbl-home-all').forEach(e=>e.textContent=T.btn_home_all);
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
@@ -1178,6 +1254,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(){
@@ -1186,7 +1271,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 ──
@@ -1245,6 +1336,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};
@@ -1268,6 +1361,15 @@ 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='';
@@ -1294,6 +1396,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 ──
@@ -1475,7 +1604,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')});
}
@@ -1487,6 +1616,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);
@@ -1507,7 +1647,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')});
}
@@ -1597,6 +1737,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()
@@ -1642,18 +1819,29 @@ function toggleCam(){if(camOn)camStop();else camStart()}
nozzle = body.get("nozzle")
bed = body.get("bed")
loop = asyncio.get_event_loop()
printing = self._state.get("print_state") == "printing"
if printing:
# During print: runtime update via web/printer topic, one setting at a time
taskid = self._state.get("taskid", "-1")
if nozzle is not None:
n = int(float(nozzle))
await loop.run_in_executor(None, lambda: self.client.publish(
"tempature", "set",
{"type": 0, "target_nozzle_temp": n, "target_hotbed_temp": 0},
timeout=0
await loop.run_in_executor(None, lambda: self.client.publish_web(
"print", "update",
{"taskid": taskid, "settings": {"target_nozzle_temp": n}},
))
if bed is not None:
b = int(float(bed))
await loop.run_in_executor(None, lambda: self.client.publish_web(
"print", "update",
{"taskid": taskid, "settings": {"target_hotbed_temp": b}},
))
else:
# Idle: standard tempature/set with both values
n = int(float(nozzle)) if nozzle is not None else int(self._state["nozzle_target"])
b = int(float(bed)) if bed is not None else int(self._state["bed_target"])
await loop.run_in_executor(None, lambda: self.client.publish(
"tempature", "set",
{"type": 1, "target_hotbed_temp": b, "target_nozzle_temp": 0},
{"target_nozzle_temp": n, "target_hotbed_temp": b},
timeout=0
))
return web.json_response({"result": "ok"})
@@ -1780,11 +1968,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,
@@ -1846,7 +2036,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():
@@ -1917,20 +2107,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, ...]":
@@ -1976,7 +2165,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:
@@ -2153,7 +2342,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():
@@ -2241,6 +2430,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)
@@ -2269,7 +2461,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,
@@ -2280,11 +2471,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"Drucker nicht erreichbar ({e}) starte im Offline-Modus")
bridge._state["print_state"] = "error"
bridge._state["kobra_state"] = "offline"
app = build_app(bridge)
stop_event = threading.Event()