Compare commits

..

1 Commits

Author SHA1 Message Date
Phil Merricks
9a15762705 feat: improve English UI and ACE filament mapping
Complete English-mode coverage for browser-visible UI strings and console messages.

Extract uploaded G-code/3MF filament metadata and use it to build smarter ACE/AMS slot mappings while preserving existing fallback behavior.

Ignore local config and Codex workspace files generated during development.
2026-05-08 20:56:00 +01:00
11 changed files with 392 additions and 607 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,7 @@
.env .env
__pycache__/ __pycache__/
.agents/
.codex/
*.pyc *.pyc
build/ build/
dist/ dist/
@@ -7,3 +9,4 @@ dist/
releases/*/kx-bridge releases/*/kx-bridge
releases/*/extract_credentials releases/*/extract_credentials
releases/*/extract_credentials.exe releases/*/extract_credentials.exe
config/config.ini

View File

@@ -1,28 +1,21 @@
# Changelog # Changelog
## [0.9.7] 2026-05-08 ## [0.9.6.1] 2026-05-02
### Neu
- **fetch_credentials-Tool:** Ruft MQTT-Credentials direkt vom Drucker per HTTP ab — kein laufender Anycubic Slicer nötig, nur die Drucker-IP. Linux-Binary und Windows-EXE im Release enthalten. (Beitrag von bebu, PR #19)
### Fixes ### Fixes
- **Upload großer GCode-Dateien:** Dateien >1 MB wurden mit HTTP 413 abgelehnt — aiohttp `client_max_size` auf 256 MB erhöht - **Upload-Banner:** Banner wird nach Stopp/Abbruch nicht mehr erneut angezeigt — `file_ready` und Thumbnail werden jetzt gecleared wenn der Drucker `stoped` oder `canceled` meldet
- **Upload-Timeout:** Socket-Timeout nach GCode-Upload von 10s auf 120s erhöht — große Dateien führten zu einem Absturz der Bridge mit leerer Antwort während der Drucker noch verarbeitete
--- ---
## [0.9.6] 2026-05-02 ## [0.9.6] 2026-05-02
### Neu ### Neu
- **Licht-Status-Synchronisierung:** Ein/Aus-Zustand und Helligkeit des Druckerlichts werden jetzt live über `light/report` MQTT gelesen — der Licht-Toggle in der UI spiegelt den echten Druckerstatus wider - **Fortschritts-Karte:** Verstrichen / Slicer-Schätzung / Restzeit als Mini-Cards (gleicher Stil wie Temperaturkarten)
- **Zeit-Minicards:** Fortschritts-Panel zeigt jetzt drei Karten — Verstrichen, Restzeit und Slicer-Schätzung — sowie einen Layer-Badge neben dem Fortschrittsbalken - **Layer-Mini-Card:** Layerzahl als Mini-Card neben der Fortschrittsleiste
- **Slicer-Schätzzeit aus GCode:** Geschätzte Druckzeit wird direkt aus der hochgeladenen GCode-Datei gelesen (OrcaSlicer: `; total estimated time:` am Dateiende, PrusaSlicer: `; estimated printing time` im Header)
- **Erweiterte Druckerstatus-Strings:** `pausing`, `paused`, `resuming`, `resumed`, `stopping`, `stopped` hinzugefügt — fehlten bisher und ließen die UI rohe Status-Codes bei Pause/Fortsetzen/Stopp anzeigen
### Fixes ### Fixes
- **file_ready-Banner:** Upload-Banner wird nach Stopp oder Abbruch eines Drucks nicht mehr angezeigt - **Slicer-Schätzzeit:** OrcaSlicer schreibt die geschätzte Zeit ans Ende der GCode-Datei — Bridge liest jetzt auch die letzten 64 KB (vorher nur die ersten 16 KB)
- **Zeitanzeige bei Stopp/Abbruch:** Verstrichen-, Restzeit- und Slicer-Schätzung werden auf null zurückgesetzt wenn ein Druck gestoppt oder abgebrochen wird - **start.sh:** `config/`-Verzeichnis wird jetzt automatisch erstellt und `config.ini.example` wird beim ersten Start hineinkopiert (Issue #15)
- **start.sh:** `config/`-Verzeichnis und `config.ini.example` werden beim ersten Start automatisch angelegt wenn sie fehlen (Issue #15)
--- ---

View File

@@ -1,28 +1,21 @@
# Changelog # Changelog
## [0.9.7] 2026-05-08 ## [0.9.6.1] 2026-05-02
### New
- **fetch_credentials tool:** Fetches and decrypts MQTT credentials directly from the printer via HTTP — no running Anycubic Slicer required, only the printer IP needed. Linux binary and Windows EXE included in release. (Contributed by bebu, PR #19)
### Fixes ### Fixes
- **Large GCode upload:** Files >1 MB were rejected with HTTP 413 — aiohttp `client_max_size` raised to 256 MB - **Upload banner:** Banner is no longer shown again after print stop/cancel — `file_ready` and thumbnail are now cleared when the printer reports `stoped` or `canceled`
- **Upload timeout:** Socket timeout after GCode upload raised from 10s to 120s — large files caused the bridge to crash with an empty response while the printer was still processing
--- ---
## [0.9.6] 2026-05-02 ## [0.9.6] 2026-05-02
### New ### New
- **Light status sync:** Light on/off state and brightness are now read live from the printer via `light/report` MQTT message — the light toggle in the UI reflects the actual printer state - **Progress card:** Elapsed / Slicer estimate / Remaining time shown as mini-cards (same style as temperature cards)
- **Time mini-cards:** Progress panel now shows three cards — Elapsed, Remaining and Slicer estimate — with a layer counter badge next to the progress bar - **Layer mini-card:** Layer count displayed as mini-card next to the progress bar
- **Slicer estimate from GCode:** Estimated print time is parsed directly from the uploaded GCode file (OrcaSlicer: `; total estimated time:` at end of file, PrusaSlicer: `; estimated printing time` in header)
- **Extended printer status strings:** Added `pausing`, `paused`, `resuming`, `resumed`, `stopping`, `stopped` states — previously missing, causing the UI to show raw status codes during pause/resume/stop transitions
### Fixes ### Fixes
- **file_ready banner:** Upload banner is no longer shown after print stop or cancel - **Slicer estimate time:** OrcaSlicer writes the estimated time at the end of the GCode file — bridge now also scans the last 64 KB (previously only the first 16 KB were checked)
- **Timers on stop/cancel:** Elapsed, remaining and slicer estimate times are reset to zero when a print is stopped or cancelled - **start.sh:** `config/` directory is now created automatically and `config.ini.example` is copied into it on first run (Issue #15)
- **start.sh:** `config/` directory and `config.ini.example` are now created automatically on first run if missing (Issue #15)
--- ---

View File

@@ -2,7 +2,7 @@
# KX-Bridge Anycubic Kobra X # KX-Bridge Anycubic Kobra X
**Version:** 0.9.7 **Version:** 0.9.6.1
Steuere deinen **Anycubic Kobra X** mit OrcaSlicer — ohne Klipper, ohne Raspberry Pi. Steuere deinen **Anycubic Kobra X** mit OrcaSlicer — ohne Klipper, ohne Raspberry Pi.
KX-Bridge ist eine Moonraker-kompatible Bridge die direkt mit dem Drucker kommuniziert. KX-Bridge ist eine Moonraker-kompatible Bridge die direkt mit dem Drucker kommuniziert.
@@ -18,23 +18,13 @@ Den Kobra X in den LAN-Modus versetzen:
### Schritt 2 Credentials holen ### Schritt 2 Credentials holen
Die MQTT-Zugangsdaten sind druckerspezifisch und an die Hardware gebunden. Die MQTT-Zugangsdaten sind druckerspezifisch. So holst du sie:
**Option A fetch_credentials (empfohlen):**
```bash
fetch_credentials --ip 192.168.x.x --write-config
```
Holt die Credentials direkt per HTTP vom Drucker und schreibt sie automatisch in `config/config.ini`. Benötigt nur die Drucker-IP — kein Slicer nötig.
**Option B extract_credentials (wenn Drucker-IP unbekannt):**
1. **AnycubicSlicerNext** öffnen und Drucker verbinden (bis Status angezeigt wird) 1. **AnycubicSlicerNext** öffnen und Drucker verbinden (bis Status angezeigt wird)
2. **`extract_credentials`** ausführen — gibt Username, Password, Device-ID und Drucker-IP aus 2. **`extract_credentials.exe`** (Windows) oder **`extract_credentials`** (Linux) ausführen — gibt Username, Password, Device-ID und Drucker-IP aus
3. Werte im Web-UI eintragen (⚙-Menü) 3. Werte merken / kopieren
> **Download:** [gitea.it-drui.de/viewit/KX-Bridge-Release/releases](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) → `fetch_credentials` / `extract_credentials` (Linux & Windows) im jeweiligen Release-Asset > **Download:** [gitea.it-drui.de/viewit/KX-Bridge-Release/releases](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) → `extract_credentials.exe` (Windows) / `extract_credentials` (Linux) im jeweiligen Release-Asset
### Schritt 3 Bridge starten ### Schritt 3 Bridge starten
@@ -46,7 +36,7 @@ Das Skript baut das Docker-Image automatisch beim ersten Aufruf.
**Web-UI öffnen:** `http://BRIDGE-IP:7125` **Web-UI öffnen:** `http://BRIDGE-IP:7125`
→ Das ⚙-Menü öffnet sich beim ersten Start automatisch → Das ⚙-Menü öffnet sich beim ersten Start automatisch
Bei Option B: Credentials aus Schritt 2 eintragen → **Speichern & Neustart** → Credentials aus Schritt 2 eintragen → **Speichern & Neustart**
**OrcaSlicer verbinden:** **OrcaSlicer verbinden:**
Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125` Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125`
@@ -126,8 +116,7 @@ docker-compose down
## Fehlerbehebung ## Fehlerbehebung
**„Falsche MQTT-Zugangsdaten"** beim Start: **„Falsche MQTT-Zugangsdaten"** beim Start:
- `fetch_credentials --ip <Drucker-IP> --write-config` erneut ausführen und Bridge neu starten - AnycubicSlicerNext neu starten, Drucker verbinden, `extract_credentials` erneut ausführen
- Wenn IP unbekannt: AnycubicSlicerNext neu starten, Drucker verbinden, `extract_credentials` erneut ausführen
- Nur die IP-Adresse ins Feld eintragen, keinen Port (✗ `192.168.1.102:9883` → ✓ `192.168.1.102`) - Nur die IP-Adresse ins Feld eintragen, keinen Port (✗ `192.168.1.102:9883` → ✓ `192.168.1.102`)
**Drucker nicht gefunden / kein LAN-Modus:** **Drucker nicht gefunden / kein LAN-Modus:**
@@ -152,9 +141,3 @@ sudo usermod -aG docker $USER # dann neu einloggen
## Lizenz & Rechtliches ## Lizenz & Rechtliches
Interoperabilitätsforschung gem. §69e UrhG — ausschließlich private, nicht-kommerzielle Nutzung. Interoperabilitätsforschung gem. §69e UrhG — ausschließlich private, nicht-kommerzielle Nutzung.
<p align="center">
<a href="https://ko-fi.com/viewitde">
<img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Ko-fi Support"/>
</a>
</p>

View File

@@ -2,7 +2,7 @@
# KX-Bridge Anycubic Kobra X # KX-Bridge Anycubic Kobra X
**Version:** 0.9.7 **Version:** 0.9.6.1
Control your **Anycubic Kobra X** with OrcaSlicer — no Klipper, no Raspberry Pi. Control your **Anycubic Kobra X** with OrcaSlicer — no Klipper, no Raspberry Pi.
KX-Bridge is a Moonraker-compatible bridge that communicates directly with the printer. KX-Bridge is a Moonraker-compatible bridge that communicates directly with the printer.
@@ -141,9 +141,3 @@ sudo usermod -aG docker $USER # then log out and back in
## License ## License
Interoperability research under §69e UrhG — private, non-commercial use only. Interoperability research under §69e UrhG — private, non-commercial use only.
<p align="center">
<a href="https://ko-fi.com/viewitde">
<img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Ko-fi Support"/>
</a>
</p>

View File

@@ -1 +1 @@
0.9.7 0.9.6.1

View File

@@ -2,7 +2,7 @@
# Bridge-Manager: start | stop | restart | status | log # Bridge-Manager: start | stop | restart | status | log
SCRIPT="$(dirname "$0")/kobrax_moonraker_bridge.py" SCRIPT="$(dirname "$0")/kobrax_moonraker_bridge.py"
LOGFILE="/tmp/bridge.log" LOGFILE="/tmp/bridge.log"
PRINTER_IP="192.168.178.94" PRINTER_IP="${PRINTER_IP:-}"
case "${1:-restart}" in case "${1:-restart}" in
start) start)
@@ -11,7 +11,11 @@ case "${1:-restart}" in
fuser -k 7125/tcp 2>/dev/null || pkill -f kobrax_moonraker_bridge 2>/dev/null fuser -k 7125/tcp 2>/dev/null || pkill -f kobrax_moonraker_bridge 2>/dev/null
sleep 1 sleep 1
fi fi
nohup python3 "$SCRIPT" --printer-ip "$PRINTER_IP" > "$LOGFILE" 2>&1 & CMD=(python3 "$SCRIPT")
if [[ -n "$PRINTER_IP" ]]; then
CMD+=(--printer-ip "$PRINTER_IP")
fi
nohup "${CMD[@]}" > "$LOGFILE" 2>&1 &
echo "Bridge gestartet PID=$!" echo "Bridge gestartet PID=$!"
sleep 2; tail -3 "$LOGFILE" sleep 2; tail -3 "$LOGFILE"
;; ;;

View File

@@ -1,397 +0,0 @@
#!/usr/bin/env python3
"""
fetch_credentials.py Fetches and decrypts Anycubic Kobra X credentials via HTTP API.
Original approach by bebu (PR #19, KX-Bridge-Release).
Reverse engineered from the Vue project embedded in libWorkbench.so (Anycubic Slicer Next).
No running slicer required — only the printer IP in LAN.
Algorithm: AES-256-CBC
Key: token[16:32] from /info response
IV: response token from /ctrl response
Usage:
python3 fetch_credentials.py --ip 192.168.x.x
python3 fetch_credentials.py --ip 192.168.x.x --write-config
python3 fetch_credentials.py --ip 192.168.x.x --write-config --config-file ../config/config.ini
python3 fetch_credentials.py --ctrl ctrl.json --info info.json
"""
import json
import sys
import base64
import hashlib
import argparse
import os
import time
import random
import string
import requests
from pathlib import Path
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
def evp_bytes_to_key(password, salt, key_len, iv_len):
"""
Derive key and IV from password and salt using OpenSSL EVP_BytesToKey
This mimics the CryptoJS default key derivation
"""
m = []
i = 0
while len(b''.join(m)) < (key_len + iv_len):
md5 = hashlib.md5()
data = password + salt
if i > 0:
data = m[i - 1] + password + salt
md5.update(data)
m.append(md5.digest())
i += 1
ms = b''.join(m)
return ms[:key_len], ms[key_len:key_len + iv_len]
def generate_signature(token, ts, nonce):
"""
Generate MD5 signature for /ctrl endpoint
Signature = md5(md5(token[0:16]) + ts + nonce)
"""
# First MD5: token.slice(0, 16)
first_md5 = hashlib.md5(token[:16].encode('utf-8')).hexdigest()
# Second MD5: first_md5 + ts + nonce
signature_data = first_md5 + str(ts) + nonce
signature = hashlib.md5(signature_data.encode('utf-8')).hexdigest()
return signature
def generate_nonce(length=6):
"""Generate a random alphanumeric nonce"""
chars = string.ascii_letters + string.digits
return ''.join(random.choice(chars) for _ in range(length))
def fetch_from_http(ip, port, endpoint, token=None, did="random", verbose=False):
"""
Fetch data from HTTP endpoint on the printer
Args:
ip (str): IP address of the printer
port (int): Port number (default 18910)
endpoint (str): Either 'info' or 'ctrl'
token (str): Device token (required for /ctrl endpoint)
did (str): Device ID (required for /ctrl endpoint)
verbose (bool): Print debug information
Returns:
dict: JSON response data
"""
try:
if endpoint == 'info':
url = f"http://{ip}:{port}/info"
if verbose:
print(f"Fetching: {url}")
response = requests.get(url, timeout=10)
response.raise_for_status()
return response.json()
elif endpoint == 'ctrl':
if not token:
raise ValueError("Token is required for /ctrl endpoint")
# Generate signature parameters
ts = int(time.time() * 1000) # Current timestamp in ms
nonce = generate_nonce(6)
signature = generate_signature(token, ts, nonce)
url = f"http://{ip}:{port}/ctrl"
params = {
'ts': ts,
'nonce': nonce,
'sign': signature,
'did': did
}
if verbose:
print(f"Fetching: {url}")
print(f" Parameters:")
print(f" ts: {ts}")
print(f" nonce: {nonce}")
print(f" sign: {signature}")
print(f" did: {did}")
response = requests.post(url, params=params, timeout=10)
response.raise_for_status()
return response.json()
else:
raise ValueError(f"Unknown endpoint: {endpoint}")
except requests.exceptions.RequestException as e:
raise Exception(f"HTTP request failed for {endpoint}: {e}")
except json.JSONDecodeError as e:
raise Exception(f"Invalid JSON response from {endpoint}: {e}")
def decrypt_text(encrypted_data, key, iv):
"""
Decrypt data using AES-256-CBC
Handles CryptoJS-style encrypted data (OpenSSL format with salt)
Args:
encrypted_data (str): Encrypted data string (CryptoJS format)
key (str): Decryption key string
iv (str): Initialization vector string
Returns:
dict: Decrypted JSON data
"""
try:
# Convert key and IV to bytes
key_bytes = key.encode('utf-8')
iv_bytes = iv.encode('utf-8')
# Decrypt using direct key and IV (as per the original code)
cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes)
# The encrypted_data might be base64 or hex encoded
# Try base64 first
try:
encrypted_bytes = base64.b64decode(encrypted_data)
except:
try:
# Try as hex
encrypted_bytes = bytes.fromhex(encrypted_data)
except:
# If all else fails, encode as UTF-8
encrypted_bytes = encrypted_data.encode('utf-8')
# Decrypt
decrypted = cipher.decrypt(encrypted_bytes)
# Try to unpad
try:
unpadded = unpad(decrypted, AES.block_size)
except ValueError:
# If unpadding fails, use as-is
unpadded = decrypted
plaintext = unpadded.decode('utf-8')
# Parse JSON
return json.loads(plaintext)
except Exception as e:
return {"error": str(e), "error_type": type(e).__name__}
def main():
"""Main function to decrypt printer data"""
# Parse command-line arguments
parser = argparse.ArgumentParser(
description='Fetch and decrypt Anycubic Kobra X credentials via HTTP API',
)
# HTTP mode
parser.add_argument('--ip', help='IP address of the printer')
parser.add_argument('--port', type=int, default=18910, help='Printer HTTP port (default: 18910)')
# File mode
parser.add_argument('--ctrl', default='ctrl.json', help='Path to ctrl.json (default: ctrl.json)')
parser.add_argument('--info', default='info.json', help='Path to info.json (default: info.json)')
# Output
parser.add_argument('--output', default=None, help='Save raw decrypted JSON to file (optional)')
parser.add_argument('--write-config', action='store_true',
help='Write credentials to config.ini')
parser.add_argument('--config-file', default=None,
help='Path to config.ini (default: ../config/config.ini relative to this script)')
parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output')
args = parser.parse_args()
# Determine mode: HTTP or file
if args.ip:
# HTTP mode: fetch from printer
if args.verbose:
print("=" * 70)
print("Fetching configuration from printer via HTTP")
print("=" * 70)
print(f"Printer IP: {args.ip}:{args.port}")
print()
try:
# Fetch info.json
if args.verbose:
print("Step 1: Fetching device info...")
info = fetch_from_http(args.ip, args.port, 'info', verbose=args.verbose)
# Get token from info
token = info.get('token')
if not token:
print("Error: No token found in /info response", file=sys.stderr)
return 1
# Fetch data.json (encrypted config) from /ctrl endpoint
if args.verbose:
print("\nStep 2: Fetching encrypted configuration from /ctrl...")
data = fetch_from_http(args.ip, args.port, 'ctrl', token=token, verbose=args.verbose)
if args.verbose:
print("\nData fetched successfully!")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
else:
# File mode: load from disk
if args.verbose:
print("=" * 70)
print("Loading configuration from files")
print("=" * 70)
# Check if input files exist
if not Path(args.ctrl).exists():
print(f"Error: {args.ctrl} not found", file=sys.stderr)
return 1
if not Path(args.info).exists():
print(f"Error: {args.info} not found", file=sys.stderr)
return 1
# Read ctrl.json
try:
with open(args.ctrl, 'r') as f:
data = json.load(f)
except json.JSONDecodeError as e:
print(f"Error reading {args.ctrl}: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error reading {args.ctrl}: {e}", file=sys.stderr)
return 1
# Read info.json
try:
with open(args.info, 'r') as f:
info = json.load(f)
except json.JSONDecodeError as e:
print(f"Error reading {args.info}: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error reading {args.info}: {e}", file=sys.stderr)
return 1
# Extract values
try:
encrypted_info = data['data']['info']
response_token = data['data']['token']
full_token = info['token']
except KeyError as e:
print(f"Error: Missing required key {e}", file=sys.stderr)
return 1
# Generate decryption key and IV
key_part = full_token[16:32]
if args.verbose:
print("=" * 70)
print("Printer Configuration Decryption")
print("=" * 70)
print(f"Input data file: {args.ctrl}")
print(f"Input info file: {args.info}")
print(f"Output file: {args.output}")
print()
print("Decryption Parameters:")
print(f" Encrypted data length: {len(encrypted_info)} bytes")
print(f" Full token: {full_token}")
print(f" Full token length: {len(full_token)} characters")
print(f" Response token (IV): {response_token}")
print(f" Decryption key: {key_part}")
print(f" Key length: {len(key_part)} characters")
print(f" IV length: {len(response_token)} characters")
print()
# Decrypt
if args.verbose:
print("Decrypting...")
result = decrypt_text(encrypted_info, key_part, response_token)
if 'error' in result:
print(f"Error during decryption: {result.get('error')}", file=sys.stderr)
return 1
# Show result
print()
print("=" * 55)
print(" CREDENTIALS")
print("=" * 55)
print(f" {'Printer IP':12s} {result.get('ip', 'n/a')}")
print(f" {'Username':12s} {result.get('username', 'n/a')}")
print(f" {'Password':12s} {result.get('password', 'n/a')}")
print(f" {'Device-ID':12s} {result.get('deviceId', 'n/a')}")
print(f" {'Mode-ID':12s} {result.get('modeId', 'n/a')}")
print(f" {'Model':12s} {result.get('modelName', 'n/a')}")
print(f" {'Broker':12s} {result.get('broker', 'n/a')}")
print("=" * 55)
if args.verbose:
print()
print("Full decrypted config:")
# Strip certs/keys from verbose output to avoid cluttering terminal
display = {k: v for k, v in result.items() if k not in ('devicecrt', 'devicepk')}
print(json.dumps(display, indent=2))
# Optionally save raw JSON
if args.output:
try:
with open(args.output, 'w') as f:
json.dump(result, f, indent=2)
print(f"\nRaw config saved to: {args.output}")
except Exception as e:
print(f"Error writing to {args.output}: {e}", file=sys.stderr)
return 1
# Write config.ini
if args.write_config:
if args.config_file:
config_path = args.config_file
else:
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'..', 'config', 'config.ini')
config_path = os.path.normpath(config_path)
_write_config_ini(result, config_path)
else:
print(f"\nTip: pass --write-config to write credentials directly to config.ini")
return 0
def _write_config_ini(result: dict, config_path: str):
"""Write fetched credentials into config.ini, preserving existing non-credential keys."""
import configparser
cfg = configparser.ConfigParser()
if os.path.isfile(config_path):
cfg.read(config_path)
if not cfg.has_section('connection'):
cfg.add_section('connection')
cfg.set('connection', 'printer_ip', result.get('ip', ''))
cfg.set('connection', 'mqtt_port', '9883')
cfg.set('connection', 'username', result.get('username', ''))
cfg.set('connection', 'password', result.get('password', ''))
cfg.set('connection', 'device_id', result.get('deviceId', ''))
cfg.set('connection', 'mode_id', result.get('modeId', '20030'))
os.makedirs(os.path.dirname(config_path), exist_ok=True)
with open(config_path, 'w') as f:
cfg.write(f)
print(f"\n✓ Credentials written to '{config_path}'.")
if __name__ == '__main__':
sys.exit(main())

View File

@@ -523,7 +523,7 @@ class KobraXClient:
sock = socket.create_connection((self.host, 18910), timeout=30) sock = socket.create_connection((self.host, 18910), timeout=30)
sock.sendall(headers + body) sock.sendall(headers + body)
sock.settimeout(120) # große GCode-Dateien brauchen Zeit bis der Drucker antwortet sock.settimeout(10)
response = b"" response = b""
try: try:
while True: while True:

View File

@@ -26,6 +26,8 @@ import sys
import tempfile import tempfile
import time import time
import threading import threading
import io
import zipfile
# Bei PyInstaller-Binary liegt alles neben sys.executable, sonst neben __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__)) _BASE = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os.path.dirname(os.path.abspath(__file__))
@@ -128,10 +130,139 @@ def _parse_gcode_estimated_time(data: bytes) -> int:
elif unit == "m": secs += int(val) * 60 elif unit == "m": secs += int(val) * 60
elif unit == "s": secs += int(val) elif unit == "s": secs += int(val)
if secs: if secs:
log.info(f"Slicer-Schätzzeit: {secs}s ({m.group(1).strip()})") log.info(f"Slicer estimate: {secs}s ({m.group(1).strip()})")
return secs return secs
_FILAMENT_COLOR_KEYS = (
"filament_colour", "filament_color", "filament_colours", "filament_colors",
"extruder_colour", "extruder_color",
)
_FILAMENT_MATERIAL_KEYS = (
"filament_type", "filament_types", "filament_settings_id", "filament_preset",
)
_KNOWN_MATERIALS = (
"PLA-CF", "PETG-CF", "PA-CF", "PLA SILK", "PETG", "PLA", "ABS", "ASA",
"TPU", "PA", "PC", "HIPS", "PVA",
)
def _normalize_material(value) -> str:
text = str(value or "").upper().replace("_", " ").replace("-", "-").strip()
for material in _KNOWN_MATERIALS:
if re.search(rf"(^|[^A-Z0-9]){re.escape(material)}([^A-Z0-9]|$)", text):
return material
return text.split()[0] if text else "PLA"
def _color_to_rgb(value, default=None):
if default is None:
default = [255, 255, 255]
if isinstance(value, list) and len(value) >= 3:
try:
return [max(0, min(255, int(value[0]))),
max(0, min(255, int(value[1]))),
max(0, min(255, int(value[2])))]
except Exception:
return default
if isinstance(value, str):
m = re.search(r"#?([0-9a-fA-F]{6})(?:[0-9a-fA-F]{2})?", value)
if m:
raw = m.group(1)
return [int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16)]
return default
def _rgba(color) -> list[int]:
rgb = _color_to_rgb(color)
return [rgb[0], rgb[1], rgb[2], 255]
def _rgb_distance(a, b) -> int:
ar, ag, ab = _color_to_rgb(a)
br, bg, bb = _color_to_rgb(b)
return (ar - br) ** 2 + (ag - bg) ** 2 + (ab - bb) ** 2
def _parse_colors_from_text(text: str) -> list[list[int]]:
colors = []
for match in re.finditer(r"#?([0-9a-fA-F]{6})(?:[0-9a-fA-F]{2})?", text or ""):
raw = match.group(1)
colors.append([int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16)])
return colors
def _split_config_values(value: str) -> list[str]:
value = (value or "").strip().strip("[]")
parts = re.split(r"[;,]", value)
return [p.strip().strip("\"' ") for p in parts if p.strip().strip("\"' ")]
def _extract_config_value(line: str) -> str:
attr = re.search(r"value=[\"']([^\"']+)[\"']", line)
if attr:
return attr.group(1)
if "=" in line:
return line.split("=", 1)[1].strip()
if ":" in line:
return line.split(":", 1)[1].strip()
return line
def _parse_filament_metadata_text(text: str) -> dict:
colors: list[list[int]] = []
materials: list[str] = []
for line in (text or "").splitlines():
low = line.lower()
if any(k in low for k in _FILAMENT_COLOR_KEYS):
value = _extract_config_value(line)
for color in _parse_colors_from_text(value):
if color not in colors:
colors.append(color)
if any(k in low for k in _FILAMENT_MATERIAL_KEYS):
value = _extract_config_value(line)
for part in _split_config_values(value):
mat = _normalize_material(part)
if mat and mat not in materials:
materials.append(mat)
return {"colors": colors, "materials": materials}
def _merge_filament_metadata(target: dict, source: dict):
for key in ("colors", "materials"):
for value in source.get(key, []):
if value not in target[key]:
target[key].append(value)
def _parse_uploaded_filament_metadata(data: bytes, filename: str) -> dict:
"""Best-effort extraction of slicer filament colors/materials from G-code or 3MF."""
meta = {"colors": [], "materials": [], "source": ""}
suffix = pathlib.Path(filename or "").suffix.lower()
if suffix == ".3mf" or data[:4] == b"PK\x03\x04":
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
for name in zf.namelist():
lname = name.lower()
if not lname.endswith((".config", ".json", ".model", ".xml", ".gcode")):
continue
if zf.getinfo(name).file_size > 2_000_000:
continue
text = zf.read(name).decode("utf-8", errors="ignore")
before = len(meta["colors"]) + len(meta["materials"])
_merge_filament_metadata(meta, _parse_filament_metadata_text(text))
if len(meta["colors"]) + len(meta["materials"]) > before and not meta["source"]:
meta["source"] = name
except Exception as e:
log.warning(f"3MF metadata could not be parsed: {e}")
else:
search = (data[:131072] + data[-262144:]).decode("utf-8", errors="ignore")
_merge_filament_metadata(meta, _parse_filament_metadata_text(search))
if meta["colors"] or meta["materials"]:
meta["source"] = "gcode"
return meta
class KobraXBridge: class KobraXBridge:
def __init__(self, client: KobraXClient, args=None): def __init__(self, client: KobraXClient, args=None):
self.client = client self.client = client
@@ -167,6 +298,7 @@ class KobraXBridge:
self._ams_slots: list[dict] = [] self._ams_slots: list[dict] = []
self._ams_loaded_slot: int = -1 self._ams_loaded_slot: int = -1
self._last_uploaded_file: str = "" self._last_uploaded_file: str = ""
self._uploaded_filament_metadata: dict[str, dict] = {}
self._serve_dir = tempfile.TemporaryDirectory(prefix="kobrax_serve_") self._serve_dir = tempfile.TemporaryDirectory(prefix="kobrax_serve_")
self._serve_dir_path: str = self._serve_dir.name self._serve_dir_path: str = self._serve_dir.name
@@ -178,7 +310,6 @@ class KobraXBridge:
client.callbacks["info/report"] = self._on_info client.callbacks["info/report"] = self._on_info
client.callbacks["file/report"] = self._on_file client.callbacks["file/report"] = self._on_file
client.callbacks["multiColorBox/report"] = self._on_multicolor_box client.callbacks["multiColorBox/report"] = self._on_multicolor_box
client.callbacks["light/report"] = self._on_light
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# MQTT callbacks (called from reader thread) # MQTT callbacks (called from reader thread)
@@ -257,7 +388,7 @@ class KobraXBridge:
thumb = details.get("thumbnail") or details.get("png_image") or "" thumb = details.get("thumbnail") or details.get("png_image") or ""
if thumb: if thumb:
self._thumbnail_b64 = thumb self._thumbnail_b64 = thumb
log.info(f"Vorschaubild empfangen: {len(thumb)} Zeichen base64") log.info(f"Thumbnail received: {len(thumb)} base64 chars")
self._push_status_update() self._push_status_update()
def _on_multicolor_box(self, payload: dict): def _on_multicolor_box(self, payload: dict):
@@ -287,13 +418,7 @@ class KobraXBridge:
threading.Thread(target=_tip_form, daemon=True).start() threading.Thread(target=_tip_form, daemon=True).start()
if slots: if slots:
self._ams_slots = slots self._ams_slots = slots
log.info(f"AMS-Slots empfangen: {len(slots)}, loaded_slot={self._ams_loaded_slot}") log.info(f"AMS slots received: {len(slots)}, loaded_slot={self._ams_loaded_slot}")
self._push_status_update()
def _on_light(self, payload: dict):
d = payload.get("data") or {}
self._state["light_on"] = bool(d.get("status", 0))
self._state["light_brightness"] = int(d.get("brightness", 80))
self._push_status_update() self._push_status_update()
# OrcaSlicer filament preset IDs (MoonrakerPrinterAgent.cpp mapping) # OrcaSlicer filament preset IDs (MoonrakerPrinterAgent.cpp mapping)
@@ -306,7 +431,7 @@ class KobraXBridge:
} }
def _build_lane_data(self) -> dict: def _build_lane_data(self) -> dict:
"""Baut BBL-AMS-JSON für OrcaSlicer DevFilaSystemParser::ParseV1_0.""" """Build BBL-AMS JSON for OrcaSlicer DevFilaSystemParser::ParseV1_0."""
slots = self._ams_slots slots = self._ams_slots
total = len(slots) total = len(slots)
if total == 0: if total == 0:
@@ -337,7 +462,7 @@ class KobraXBridge:
color_hex = color_raw[:6].upper() + "FF" color_hex = color_raw[:6].upper() + "FF"
else: else:
color_hex = "FFFFFFFF" color_hex = "FFFFFFFF"
material = slot.get("type", "PLA").upper() material = _normalize_material(slot.get("type", "PLA"))
tray_info_idx = self._TRAY_INFO_IDX.get(material, "OGFL99") tray_info_idx = self._TRAY_INFO_IDX.get(material, "OGFL99")
tray_array.append({ tray_array.append({
"id": str(slot_id), "id": str(slot_id),
@@ -364,6 +489,118 @@ class KobraXBridge:
"tray_exist_bits": format(tray_exist_bits, "X"), "tray_exist_bits": format(tray_exist_bits, "X"),
} }
def _uploaded_metadata_for(self, filename: str) -> dict:
if not filename:
return {"colors": [], "materials": [], "source": ""}
return (self._uploaded_filament_metadata.get(filename)
or self._uploaded_filament_metadata.get(os.path.basename(filename))
or {"colors": [], "materials": [], "source": ""})
def _loaded_ams_slots(self) -> list[tuple[int, dict]]:
return [(i, s) for i, s in enumerate(self._ams_slots) if s.get("status") == 5]
def _filtered_loaded_ams_slots(self) -> list[tuple[int, dict]]:
loaded = self._loaded_ams_slots()
default_slot = getattr(self._args, "default_ams_slot", "auto")
if default_slot == "auto":
return loaded
try:
slot_idx = int(default_slot)
except ValueError:
return loaded
selected = [(i, s) for i, s in loaded if i == slot_idx]
if selected:
return selected
log.warning(f"Default slot {slot_idx} is empty - falling back to Auto")
return loaded
def _build_ams_assignments(self, filename: str) -> list[dict]:
loaded = self._filtered_loaded_ams_slots()
if not loaded:
return []
metadata = self._uploaded_metadata_for(filename)
colors = metadata.get("colors") or []
materials = metadata.get("materials") or []
target_count = max(len(colors), len(materials))
# Without slicer metadata, preserve the previous behavior: advertise all
# occupied slots and let the printer/firmware use its normal fallback.
if target_count == 0:
return [{
"paint_index": i,
"slot_index": i,
"paint_color": _rgba(slot.get("color", [255, 255, 255])),
"ams_color": _rgba(slot.get("color", [255, 255, 255])),
"material_type": _normalize_material(slot.get("type", "PLA")),
"reason": "loaded",
} for i, slot in loaded]
assignments = []
used_slots: set[int] = set()
for paint_index in range(target_count):
target_color = colors[paint_index] if paint_index < len(colors) else None
target_material = (_normalize_material(materials[paint_index])
if paint_index < len(materials) else "")
candidates = loaded
if target_material:
material_matches = [
(i, s) for i, s in candidates
if _normalize_material(s.get("type", "PLA")) == target_material
]
if material_matches:
candidates = material_matches
unused = [(i, s) for i, s in candidates if i not in used_slots]
if unused:
candidates = unused
def _score(item):
slot_index, slot = item
score = 0
if target_color is not None:
score += _rgb_distance(target_color, slot.get("color", [255, 255, 255]))
if target_material and _normalize_material(slot.get("type", "PLA")) != target_material:
score += 200000
return score, slot_index
slot_index, slot = min(candidates, key=_score)
used_slots.add(slot_index)
assignments.append({
"paint_index": paint_index,
"slot_index": slot_index,
"paint_color": _rgba(target_color or slot.get("color", [255, 255, 255])),
"ams_color": _rgba(slot.get("color", [255, 255, 255])),
"material_type": _normalize_material(slot.get("type", target_material or "PLA")),
"target_material": target_material,
"reason": "metadata",
})
summary = [
f"T{a['paint_index']}→S{a['slot_index']} {a['material_type']}"
for a in assignments
]
log.info(f"AMS metadata mapping for {filename}: {', '.join(summary)}")
return assignments
def _build_anycubic_ams_mapping(self, filename: str) -> list[dict]:
return [{
"paint_index": a["paint_index"],
"ams_index": a["slot_index"],
"paint_color": a["paint_color"],
"ams_color": a["ams_color"],
"material_type": a["material_type"],
} for a in self._build_ams_assignments(filename)]
def _build_simple_ams_mapping(self, filename: str) -> list[dict]:
return [{
"slot_index": a["slot_index"],
"material_type": a["material_type"],
"color": a["ams_color"][:3],
"paint_index": a["paint_index"],
"paint_color": a["paint_color"][:3],
} for a in self._build_ams_assignments(filename)]
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# WebSocket push # WebSocket push
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -552,6 +789,16 @@ class KobraXBridge:
# Slicer-Zeitschätzung aus GCode-Header auslesen # Slicer-Zeitschätzung aus GCode-Header auslesen
self._state["slicer_time"] = _parse_gcode_estimated_time(file_data) self._state["slicer_time"] = _parse_gcode_estimated_time(file_data)
filament_meta = _parse_uploaded_filament_metadata(file_data, remote_filename)
self._uploaded_filament_metadata[remote_filename] = filament_meta
self._uploaded_filament_metadata[os.path.basename(remote_filename)] = filament_meta
if filament_meta.get("colors") or filament_meta.get("materials"):
log.info(
"Upload filament metadata: "
f"colors={filament_meta.get('colors', [])} "
f"materials={filament_meta.get('materials', [])} "
f"source={filament_meta.get('source', '')}"
)
# Datei auf Disk ablegen (temp-Verzeichnis) damit Drucker sie per HTTP abrufen kann # Datei auf Disk ablegen (temp-Verzeichnis) damit Drucker sie per HTTP abrufen kann
safe_name = os.path.basename(remote_filename) # keine Pfad-Traversal safe_name = os.path.basename(remote_filename) # keine Pfad-Traversal
@@ -561,7 +808,7 @@ class KobraXBridge:
del file_data # RAM freigeben del file_data # RAM freigeben
self._last_uploaded_file = remote_filename self._last_uploaded_file = remote_filename
log.info(f"Upload: {remote_filename} ({file_size} bytes) md5={file_md5} → Drucker") log.info(f"Upload: {remote_filename} ({file_size} bytes) md5={file_md5} -> printer")
# Datei per HTTP auf den Drucker hochladen (serve_path liegt bereits auf Disk) # Datei per HTTP auf den Drucker hochladen (serve_path liegt bereits auf Disk)
upload_url = self._state.get("upload_url") or None upload_url = self._state.get("upload_url") or None
@@ -571,10 +818,10 @@ class KobraXBridge:
None, self.client.upload_gcode, serve_path, remote_filename, upload_url None, self.client.upload_gcode, serve_path, remote_filename, upload_url
) )
except Exception as e: except Exception as e:
log.error(f"Upload fehlgeschlagen: {e}") log.error(f"Upload failed: {e}")
return web.json_response({"error": str(e)}, status=500) return web.json_response({"error": str(e)}, status=500)
log.info(f"Upload erfolgreich: {result}") log.info(f"Upload succeeded: {result}")
# Druck starten mit vollständigem Payload (inkl. serve-URL + md5 + size) # Druck starten mit vollständigem Payload (inkl. serve-URL + md5 + size)
serve_url = f"http://{request.host}/serve/{remote_filename}" serve_url = f"http://{request.host}/serve/{remote_filename}"
@@ -594,7 +841,7 @@ class KobraXBridge:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
loop.run_in_executor(None, lambda: self._start_print(remote_filename, serve_url, file_md5, file_size)) loop.run_in_executor(None, lambda: self._start_print(remote_filename, serve_url, file_md5, file_size))
else: else:
log.info(f"Nur hochgeladen (print=false): {remote_filename}") log.info(f"Uploaded only (print=false): {remote_filename}")
self._state["file_ready"] = remote_filename self._state["file_ready"] = remote_filename
# OctoPrint-kompatibler Response (OrcaSlicer wertet refs aus) # OctoPrint-kompatibler Response (OrcaSlicer wertet refs aus)
@@ -619,31 +866,12 @@ class KobraXBridge:
def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0): def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0):
self._state["file_ready"] = "" self._state["file_ready"] = ""
default_slot = getattr(self._args, "default_ams_slot", "auto") ams_box_mapping = self._build_anycubic_ams_mapping(filename)
all_loaded = [(i, s) for i, s in enumerate(self._ams_slots) if s.get("status") == 5] use_ams = len(ams_box_mapping) > 0
if default_slot != "auto": log.info(
try: f"AMS-Slots: {len(self._loaded_ams_slots())}/{len(self._ams_slots)} belegt "
slot_idx = int(default_slot) f"{[m['ams_index'] for m in ams_box_mapping]}"
loaded = [(i, s) for i, s in all_loaded if i == slot_idx] )
if not loaded:
log.warning(f"Standard-Slot {slot_idx} ist leer fallback auf Auto")
loaded = all_loaded
except ValueError:
loaded = all_loaded
else:
loaded = all_loaded
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("type", "PLA"),
}
for i, s in loaded
]
log.info(f"AMS-Slots: {len(loaded)}/{len(self._ams_slots)} belegt → {[i for i,_ in loaded]}")
auto_leveling = getattr(self._args, "auto_leveling", 1) auto_leveling = getattr(self._args, "auto_leveling", 1)
payload = { payload = {
"taskid": "-1", "taskid": "-1",
@@ -672,9 +900,9 @@ class KobraXBridge:
log.info(f"print/start → {filename} url={url} ams={len(self._ams_slots)} slots") log.info(f"print/start → {filename} url={url} ams={len(self._ams_slots)} slots")
result = self.client.publish("print", "start", payload, timeout=15.0) result = self.client.publish("print", "start", payload, timeout=15.0)
if result: if result:
log.info(f"Druckstart bestätigt: state={result.get('state')}") log.info(f"Print start confirmed: state={result.get('state')}")
else: else:
log.warning("Druckstart: keine Antwort vom Drucker") log.warning("Print start: no response from printer")
async def handle_print_start(self, request): async def handle_print_start(self, request):
try: try:
@@ -687,38 +915,9 @@ class KobraXBridge:
if not filename: if not filename:
return web.json_response({"error": "no filename"}, status=400) return web.json_response({"error": "no filename"}, status=400)
log.info(f"Druck starten: {filename}") log.info(f"Starting print: {filename}")
# AMS-Mapping aus gecachtem State — leere Slots (status != 5) überspringen
default_slot = getattr(self._args, "default_ams_slot", "auto")
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
if default_slot != "auto":
try:
if i != int(default_slot):
continue
except ValueError:
pass
ams_box_mapping.append({
"slot_index": i,
"material_type": slot.get("type", "PLA"),
"color": slot.get("color", [255, 255, 255]),
})
# Fallback auf alle belegten Slots wenn gewählter Slot leer war
if default_slot != "auto" and not ams_box_mapping:
log.warning(f"Standard-Slot {default_slot} leer fallback auf alle belegten Slots")
for i, slot in enumerate(self._ams_slots):
if slot.get("status") != 5:
continue
ams_box_mapping.append({
"slot_index": i,
"material_type": slot.get("type", "PLA"),
"color": slot.get("color", [255, 255, 255]),
})
ams_box_mapping = self._build_simple_ams_mapping(filename)
use_ams = len(ams_box_mapping) > 0 use_ams = len(ams_box_mapping) > 0
payload = { payload = {
@@ -734,7 +933,7 @@ class KobraXBridge:
None, lambda: self.client.publish("print", "start", payload, timeout=15.0) None, lambda: self.client.publish("print", "start", payload, timeout=15.0)
) )
if result is None: if result is None:
return web.json_response({"error": "Keine Antwort vom Drucker"}, status=504) return web.json_response({"error": "No response from printer"}, status=504)
return web.json_response({"result": "ok"}) return web.json_response({"result": "ok"})
@@ -1365,7 +1564,7 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<span class="slider-val" id="d-fan-val">0</span> <span class="slider-val" id="d-fan-val">0</span>
</div> </div>
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap"> <div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(0)">Aus</button> <button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(0)"><span class="lbl-off">Aus</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(25)">25%</button> <button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(25)">25%</button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(50)">50%</button> <button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(50)">50%</button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(75)">75%</button> <button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(75)">75%</button>
@@ -1402,23 +1601,23 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center"> <div class="card-title" style="display:flex;justify-content:space-between;align-items:center">
<span><span>≡</span> <span id="ptitle-console">Ereignis-Log</span></span> <span><span>≡</span> <span id="ptitle-console">Ereignis-Log</span></span>
<a id="btn-log-dl" href="/api/log/download" download="kx-bridge.log" <a id="btn-log-dl" href="/api/log/download" download="kx-bridge.log"
style="font-size:12px;padding:4px 10px;background:var(--raised);border-radius:6px;color:var(--txt2);text-decoration:none">⬇ Download</a> style="font-size:12px;padding:4px 10px;background:var(--raised);border-radius:6px;color:var(--txt2);text-decoration:none">⬇ <span id="lbl-log-download">Download</span></a>
</div> </div>
<div style="display:flex;gap:6px;margin-bottom:6px;flex-wrap:wrap;align-items:center"> <div style="display:flex;gap:6px;margin-bottom:6px;flex-wrap:wrap;align-items:center">
<input id="log-filter" type="text" placeholder="Filter…" <input id="log-filter" type="text" placeholder="Filter…"
oninput="renderLog()" oninput="renderLog()"
style="flex:1;min-width:120px;padding:5px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:12px;font-family:var(--mono)"> style="flex:1;min-width:120px;padding:5px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:12px;font-family:var(--mono)">
<button id="btn-autoscroll" onclick="toggleAutoScroll()" <button id="btn-autoscroll" onclick="toggleAutoScroll()"
style="font-size:12px;padding:5px 10px;border-radius:6px;border:1px solid var(--border);background:var(--accent);color:#fff;cursor:pointer;white-space:nowrap">⬇ Auto</button> style="font-size:12px;padding:5px 10px;border-radius:6px;border:1px solid var(--border);background:var(--accent);color:#fff;cursor:pointer;white-space:nowrap">⬇ <span id="lbl-log-auto">Auto</span></button>
<button onclick="consoleLogs=[];renderLog()" <button onclick="consoleLogs=[];renderLog()"
style="font-size:12px;padding:5px 10px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt2);cursor:pointer">✕ Clear</button> style="font-size:12px;padding:5px 10px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt2);cursor:pointer">✕ <span id="lbl-log-clear">Clear</span></button>
</div> </div>
<div style="display:flex;gap:5px;margin-bottom:8px;flex-wrap:wrap"> <div style="display:flex;gap:5px;margin-bottom:8px;flex-wrap:wrap">
<span style="font-size:11px;color:var(--txt2);align-self:center;margin-right:2px">Dir:</span> <span id="lbl-log-dir" style="font-size:11px;color:var(--txt2);align-self:center;margin-right:2px">Dir:</span>
<button class="log-dir-btn active" id="logdir-all" onclick="setLogDir('all')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer"></button> <button class="log-dir-btn active" id="logdir-all" onclick="setLogDir('all')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer"></button>
<button class="log-dir-btn" id="logdir-rx" onclick="setLogDir('rx')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">RX</button> <button class="log-dir-btn" id="logdir-rx" onclick="setLogDir('rx')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">RX</button>
<button class="log-dir-btn" id="logdir-tx" onclick="setLogDir('tx')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">TX</button> <button class="log-dir-btn" id="logdir-tx" onclick="setLogDir('tx')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">TX</button>
<span style="font-size:11px;color:var(--txt2);align-self:center;margin-left:6px;margin-right:2px">Topic:</span> <span id="lbl-log-topic" style="font-size:11px;color:var(--txt2);align-self:center;margin-left:6px;margin-right:2px">Topic:</span>
<button class="log-topic-btn" data-topic="multiColorBox" onclick="setLogTopic('multiColorBox')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">AMS</button> <button class="log-topic-btn" data-topic="multiColorBox" onclick="setLogTopic('multiColorBox')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">AMS</button>
<button class="log-topic-btn" data-topic="print" onclick="setLogTopic('print')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">print</button> <button class="log-topic-btn" data-topic="print" onclick="setLogTopic('print')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">print</button>
<button class="log-topic-btn" data-topic="info" onclick="setLogTopic('info')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">info</button> <button class="log-topic-btn" data-topic="info" onclick="setLogTopic('info')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">info</button>
@@ -1476,16 +1675,20 @@ var LANG_DE={
confirm_cancel:'Druck wirklich abbrechen?', confirm_cancel:'Druck wirklich abbrechen?',
settings_title:'Einstellungen',settings_connection:'Verbindung',settings_print:'Druckeinstellungen',settings_poll:'Poll-Intervall',settings_version:'Version', settings_title:'Einstellungen',settings_connection:'Verbindung',settings_print:'Druckeinstellungen',settings_poll:'Poll-Intervall',settings_version:'Version',
settings_save:'Speichern & Neustart',settings_printer_ip:'Drucker-IP',settings_mqtt_port:'MQTT-Port', settings_save:'Speichern & Neustart',settings_printer_ip:'Drucker-IP',settings_mqtt_port:'MQTT-Port',
settings_username:'MQTT-Benutzername',settings_password:'MQTT-Passwort',settings_device_id:'Device-ID',settings_mode_id:'Mode-ID',hint_ip_no_port:'Nur IP-Adresse, kein Port (z.B. 192.168.1.102)', settings_username:'MQTT-Benutzername',settings_password:'MQTT-Passwort',settings_device_id:'Device-ID',settings_mode_id:'Mode-ID',settings_device_placeholder:'32 Hex-Zeichen',hint_ip_no_port:'Nur IP-Adresse, kein Port (z.B. 192.168.1.102)',
settings_default_slot:'Standard-Slot (Einfarbdruck)',settings_slot_auto:'Auto (alle belegten Slots)',settings_auto_leveling:'Auto-Leveling vor Druck', settings_default_slot:'Standard-Slot (Einfarbdruck)',settings_slot_auto:'Auto (alle belegten Slots)',settings_auto_leveling:'Auto-Leveling vor Druck',
update_check:'Auf Updates prüfen',update_checking:'Prüfe...',update_available:'verfügbar',update_none:'Bereits aktuell', update_check:'Auf Updates prüfen',update_checking:'Prüfe...',update_available:'verfügbar',update_none:'Bereits aktuell',
update_apply:'Jetzt installieren',update_applying:'Lade herunter...',update_restarting:'Starte neu...',update_error:'Fehler', update_apply:'Jetzt installieren',update_applying:'Lade herunter...',update_restarting:'Starte neu...',update_error:'Fehler',
btn_connect:'⚡ Verbinden',btn_disconnect:'✕ Trennen', btn_connect:'⚡ Verbinden',btn_disconnect:'✕ Trennen',
lbl_conn_error:'Verbindungsfehler:', lbl_conn_error:'Verbindungsfehler:',
settings_button_title:'Einstellungen',
slot_edit_title:'Slot bearbeiten',slot_edit_color:'Farbe',slot_edit_material:'Material', slot_edit_title:'Slot bearbeiten',slot_edit_color:'Farbe',slot_edit_material:'Material',
slot_edit_save:'💾 Speichern',slot_edit_custom:'z.B. PLA, PETG, ABS…', slot_edit_save:'💾 Speichern',slot_edit_custom:'z.B. PLA, PETG, ABS…',
slot_edit_ok:'AMS Slot', slot_edit_ok:'AMS Slot',
log_dir_all:'Alle', ams_slot_select:'Slot auswählen',
log_dir_all:'Alle',log_download:'Download',log_auto:'Auto',log_clear:'Leeren',log_dir:'Richtung:',log_topic:'Topic:',
log_settings_error:'Settings-Fehler:',log_axis_error:'Achsen-Fehler:',log_home_error:'Home-Fehler:',log_motors_error:'Motoren-Fehler:',log_temp_error:'Temp-Fehler:',log_light_error:'Licht-Fehler:',log_speed_error:'Speed-Fehler:',log_fan_error:'Lüfter-Fehler:',log_ams_error:'AMS-Fehler:',log_stream_unavailable:'Stream nicht verfügbar',
print_action_pause:'Pause',print_action_resume:'Fortsetzen',print_action_cancel:'Abbrechen',
file_ready_btn:'▶ Druck starten', file_ready_btn:'▶ Druck starten',
file_cancel_btn:'✕ Abbrechen' file_cancel_btn:'✕ Abbrechen'
}; };
@@ -1510,16 +1713,20 @@ var LANG_EN={
confirm_cancel:'Really cancel the print?', confirm_cancel:'Really cancel the print?',
settings_title:'Settings',settings_connection:'Connection',settings_print:'Print Settings',settings_poll:'Poll Interval',settings_version:'Version', settings_title:'Settings',settings_connection:'Connection',settings_print:'Print Settings',settings_poll:'Poll Interval',settings_version:'Version',
settings_save:'Save & Restart',settings_printer_ip:'Printer IP',settings_mqtt_port:'MQTT Port', settings_save:'Save & Restart',settings_printer_ip:'Printer IP',settings_mqtt_port:'MQTT Port',
settings_username:'MQTT Username',settings_password:'MQTT Password',settings_device_id:'Device ID',settings_mode_id:'Mode ID',hint_ip_no_port:'IP address only, no port (e.g. 192.168.1.102)', settings_username:'MQTT Username',settings_password:'MQTT Password',settings_device_id:'Device ID',settings_mode_id:'Mode ID',settings_device_placeholder:'32 hex characters',hint_ip_no_port:'IP address only, no port (e.g. 192.168.1.102)',
settings_default_slot:'Default Slot (single color)',settings_slot_auto:'Auto (all loaded slots)',settings_auto_leveling:'Auto-Leveling before print', settings_default_slot:'Default Slot (single color)',settings_slot_auto:'Auto (all loaded slots)',settings_auto_leveling:'Auto-Leveling before print',
update_check:'Check for Updates',update_checking:'Checking...',update_available:'available',update_none:'Already up to date', update_check:'Check for Updates',update_checking:'Checking...',update_available:'available',update_none:'Already up to date',
update_apply:'Install Now',update_applying:'Downloading...',update_restarting:'Restarting...',update_error:'Error', update_apply:'Install Now',update_applying:'Downloading...',update_restarting:'Restarting...',update_error:'Error',
btn_connect:'⚡ Connect',btn_disconnect:'✕ Disconnect', btn_connect:'⚡ Connect',btn_disconnect:'✕ Disconnect',
lbl_conn_error:'Connection error:', lbl_conn_error:'Connection error:',
settings_button_title:'Settings',
slot_edit_title:'Edit Slot',slot_edit_color:'Color',slot_edit_material:'Material', slot_edit_title:'Edit Slot',slot_edit_color:'Color',slot_edit_material:'Material',
slot_edit_save:'💾 Save',slot_edit_custom:'e.g. PLA, PETG, ABS…', slot_edit_save:'💾 Save',slot_edit_custom:'e.g. PLA, PETG, ABS…',
slot_edit_ok:'AMS Slot', slot_edit_ok:'AMS Slot',
log_dir_all:'All', ams_slot_select:'Select slot',
log_dir_all:'All',log_download:'Download',log_auto:'Auto',log_clear:'Clear',log_dir:'Dir:',log_topic:'Topic:',
log_settings_error:'Settings error:',log_axis_error:'Axis error:',log_home_error:'Home error:',log_motors_error:'Motor error:',log_temp_error:'Temperature error:',log_light_error:'Light error:',log_speed_error:'Speed error:',log_fan_error:'Fan error:',log_ams_error:'AMS error:',log_stream_unavailable:'Stream unavailable',
print_action_pause:'Pause',print_action_resume:'Resume',print_action_cancel:'Cancel',
file_ready_btn:'▶ Start Print', file_ready_btn:'▶ Start Print',
file_cancel_btn:'✕ Cancel' file_cancel_btn:'✕ Cancel'
}; };
@@ -1587,12 +1794,14 @@ function applyLang(){
setText('lbl-password',T.settings_password); setText('lbl-password',T.settings_password);
setText('lbl-device-id',T.settings_device_id); setText('lbl-device-id',T.settings_device_id);
setText('lbl-mode-id',T.settings_mode_id); setText('lbl-mode-id',T.settings_mode_id);
var did=document.getElementById('s-device-id');if(did)did.setAttribute('placeholder',T.settings_device_placeholder);
setText('lbl-default-slot',T.settings_default_slot); setText('lbl-default-slot',T.settings_default_slot);
setText('opt-slot-auto',T.settings_slot_auto); setText('opt-slot-auto',T.settings_slot_auto);
setText('lbl-auto-leveling',T.settings_auto_leveling); setText('lbl-auto-leveling',T.settings_auto_leveling);
setText('lbl-update-check',T.update_check); setText('lbl-update-check',T.update_check);
setText('lbl-update-apply',T.update_apply); setText('lbl-update-apply',T.update_apply);
var sb=document.getElementById('settings-btn');if(sb)sb.setAttribute('title',T.settings_button_title);
// Speed buttons // Speed buttons
setText('d-spd-lbl-1',T.speed_silent.replace(/^\S+\s/,'')); 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-2',T.speed_normal.replace(/^\S+\s/,''));
@@ -1600,6 +1809,8 @@ function applyLang(){
// AMS feed/unload // AMS feed/unload
document.querySelectorAll('.lbl-feed').forEach(e=>e.textContent=T.lbl_feed); document.querySelectorAll('.lbl-feed').forEach(e=>e.textContent=T.lbl_feed);
document.querySelectorAll('.lbl-unload').forEach(e=>e.textContent=T.lbl_unload); document.querySelectorAll('.lbl-unload').forEach(e=>e.textContent=T.lbl_unload);
setText('ams-no-data',T.ams_no_data);
setText('ams-slot-lbl',T.ams_slot_select);
// conn-btn text (nur wenn nicht im Übergangszustand) // conn-btn text (nur wenn nicht im Übergangszustand)
updateConnBtn(); updateConnBtn();
// Slot-Edit-Dialog // Slot-Edit-Dialog
@@ -1608,6 +1819,11 @@ function applyLang(){
setText('btn-slot-edit-save',T.slot_edit_save); setText('btn-slot-edit-save',T.slot_edit_save);
var mi=document.getElementById('slot-edit-mat');if(mi)mi.setAttribute('placeholder',T.slot_edit_custom); var mi=document.getElementById('slot-edit-mat');if(mi)mi.setAttribute('placeholder',T.slot_edit_custom);
setText('logdir-all',T.log_dir_all); setText('logdir-all',T.log_dir_all);
setText('lbl-log-download',T.log_download);
setText('lbl-log-auto',T.log_auto);
setText('lbl-log-clear',T.log_clear);
setText('lbl-log-dir',T.log_dir);
setText('lbl-log-topic',T.log_topic);
setText('file-ready-btn',T.file_ready_btn); setText('file-ready-btn',T.file_ready_btn);
setText('file-cancel-btn',T.file_cancel_btn); setText('file-cancel-btn',T.file_cancel_btn);
} }
@@ -1646,7 +1862,7 @@ var logTopicFilter=''; // '' = no topic filter
function clog(msg,cls){ function clog(msg,cls){
cls=cls||'msg-info'; cls=cls||'msg-info';
var ts=new Date().toLocaleTimeString('de',{hour:'2-digit',minute:'2-digit',second:'2-digit'}); var ts=new Date().toLocaleTimeString(currentLang==='de'?'de':'en',{hour:'2-digit',minute:'2-digit',second:'2-digit'});
_appendLog({ts:ts,lvl:'',name:'ui',msg:msg},cls); _appendLog({ts:ts,lvl:'',name:'ui',msg:msg},cls);
} }
function _lvlCls(lvl){ function _lvlCls(lvl){
@@ -1858,10 +2074,10 @@ function updateConnBtn(){
var offline=S.kobra_state==='offline'; var offline=S.kobra_state==='offline';
if(offline){ if(offline){
btn.className='conn-btn disconnected'; btn.className='conn-btn disconnected';
btn.textContent=T.btn_connect||'Verbinden'; btn.textContent=T.btn_connect||'Connect';
} else { } else {
btn.className='conn-btn connected'; btn.className='conn-btn connected';
btn.textContent=T.btn_disconnect||'Trennen'; btn.textContent=T.btn_disconnect||'Disconnect';
} }
} }
@@ -2003,7 +2219,7 @@ function saveSlotEdit(){
closeSlotEdit(); closeSlotEdit();
clog((T.slot_edit_ok||'AMS Slot')+' '+(_slotEditIndex+1)+': '+mat+' '+hex,'msg-ok'); clog((T.slot_edit_ok||'AMS Slot')+' '+(_slotEditIndex+1)+': '+mat+' '+hex,'msg-ok');
}) })
.catch(function(e){clog('Fehler: '+e,'msg-err');}); .catch(function(e){clog((T.log_error||'Error:')+' '+e,'msg-err');});
} }
document.addEventListener('DOMContentLoaded',function(){ document.addEventListener('DOMContentLoaded',function(){
document.getElementById('s-printer-ip').addEventListener('input',function(){ document.getElementById('s-printer-ip').addEventListener('input',function(){
@@ -2042,7 +2258,7 @@ function saveSettings(){
},4000); },4000);
}).catch(function(e){ }).catch(function(e){
btn.disabled=false;setText('btn-save-settings',T.settings_save); btn.disabled=false;setText('btn-save-settings',T.settings_save);
clog('Settings-Fehler: '+e,'msg-err'); clog((T.log_settings_error||'Settings error:')+' '+e,'msg-err');
}); });
} }
function checkUpdate(){ function checkUpdate(){
@@ -2089,7 +2305,7 @@ async function poll(){
Object.assign(S,d); Object.assign(S,d);
applyState(); applyState();
updateHistory(); updateHistory();
}catch(e){clog('Poll-Fehler: '+e,'msg-err')} }catch(e){clog((T.log_poll_error||'Poll error:')+' '+e,'msg-err')}
} }
var pollTimer; var pollTimer;
(function(){ (function(){
@@ -2099,10 +2315,10 @@ var pollTimer;
// ── Print actions ── // ── Print actions ──
function printAction(a){ function printAction(a){
post('/printer/print/'+a,{}).then(function(){clog('Druck: '+a,'msg-ok');poll()}) post('/printer/print/'+a,{}).then(function(){clog((T.nav_print||'Print')+': '+(T['print_action_'+a]||a),'msg-ok');poll()})
.catch(function(e){clog('Fehler: '+e,'msg-err')}); .catch(function(e){clog((T.log_error||'Error:')+' '+e,'msg-err')});
} }
function confirmCancel(){if(confirm('Druck wirklich abbrechen?'))printAction('cancel')} function confirmCancel(){if(confirm(T.confirm_cancel||'Really cancel the print?'))printAction('cancel')}
// ── Axis motion ── // ── Axis motion ──
// axis codes: 0=X, 1=Y, 2=Z // axis codes: 0=X, 1=Y, 2=Z
@@ -2118,28 +2334,28 @@ function move(axis,dir,dist){
// axis: 0=X,1=Y,2=Z → printer axis codes: 1=X,2=Y,3=Z // axis: 0=X,1=Y,2=Z → printer axis codes: 1=X,2=Y,3=Z
var axisMap={0:1,1:2,2:3}; var axisMap={0:1,1:2,2:3};
post('/api/axis',{axis:axisMap[axis],move_type:1,distance:dir*dist}) post('/api/axis',{axis:axisMap[axis],move_type:1,distance:dir*dist})
.then(function(){clog('Achse '+(axis===0?'X':axis===1?'Y':'Z')+' '+(dir>0?'+':'')+dir*dist+'mm','msg-ok')}) .then(function(){clog((T.log_axis||'Axis')+' '+(axis===0?'X':axis===1?'Y':'Z')+' '+(dir>0?'+':'')+dir*dist+'mm','msg-ok')})
.catch(function(e){clog('Achse-Fehler: '+e,'msg-err')}); .catch(function(e){clog((T.log_axis_error||'Axis error:')+' '+e,'msg-err')});
} }
function homeAll(){ function homeAll(){
post('/api/axis',{axis:5,move_type:2,distance:0}) post('/api/axis',{axis:5,move_type:2,distance:0})
.then(function(){clog('Home All','msg-ok')}) .then(function(){clog('Home All','msg-ok')})
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')}); .catch(function(e){clog((T.log_home_error||'Home error:')+' '+e,'msg-err')});
} }
function homeXY(){ function homeXY(){
post('/api/axis',{axis:4,move_type:2,distance:0}) post('/api/axis',{axis:4,move_type:2,distance:0})
.then(function(){clog('Home XY','msg-ok')}) .then(function(){clog('Home XY','msg-ok')})
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')}); .catch(function(e){clog((T.log_home_error||'Home error:')+' '+e,'msg-err')});
} }
function homeZ(){ function homeZ(){
post('/api/axis',{axis:3,move_type:2,distance:0}) post('/api/axis',{axis:3,move_type:2,distance:0})
.then(function(){clog('Home Z','msg-ok')}) .then(function(){clog('Home Z','msg-ok')})
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')}); .catch(function(e){clog((T.log_home_error||'Home error:')+' '+e,'msg-err')});
} }
function disableMotors(){ function disableMotors(){
post('/api/axis',{action:'turnOff'}) post('/api/axis',{action:'turnOff'})
.then(function(){clog('Motors Off','msg-ok')}) .then(function(){clog('Motors Off','msg-ok')})
.catch(function(e){clog('Motors-Fehler: '+e,'msg-err')}); .catch(function(e){clog((T.log_motors_error||'Motor error:')+' '+e,'msg-err')});
} }
// ── Temperature ── // ── Temperature ──
@@ -2147,21 +2363,21 @@ function setNozzle(){
var v=parseFloat(document.getElementById('p-nozzle-inp').value||0); var v=parseFloat(document.getElementById('p-nozzle-inp').value||0);
post('/api/temperature',{nozzle:v,bed:S.bed_target}) post('/api/temperature',{nozzle:v,bed:S.bed_target})
.then(function(){clog('Nozzle → '+v+'°C','msg-ok')}) .then(function(){clog('Nozzle → '+v+'°C','msg-ok')})
.catch(function(e){clog('Temp-Fehler: '+e,'msg-err')}); .catch(function(e){clog((T.log_temp_error||'Temperature error:')+' '+e,'msg-err')});
} }
function setBed(){ function setBed(){
var v=parseFloat(document.getElementById('p-bed-inp').value||0); var v=parseFloat(document.getElementById('p-bed-inp').value||0);
post('/api/temperature',{nozzle:S.nozzle_target,bed:v}) post('/api/temperature',{nozzle:S.nozzle_target,bed:v})
.then(function(){clog(T.label_bed+''+v+'°C','msg-ok')}) .then(function(){clog(T.label_bed+''+v+'°C','msg-ok')})
.catch(function(e){clog('Temp-Fehler: '+e,'msg-err')}); .catch(function(e){clog((T.log_temp_error||'Temperature error:')+' '+e,'msg-err')});
} }
// ── Light ── // ── Light ──
function setLight(){ function setLight(){
var on=document.getElementById('d-light-toggle').checked; var on=document.getElementById('d-light-toggle').checked;
post('/api/light',{on:on,brightness:80}) post('/api/light',{on:on,brightness:80})
.then(function(){clog('Licht '+(on?'an, '+br+'%':'aus'),'msg-ok')}) .then(function(){clog(on?(T.log_light_on||'Light on'):(T.log_light_off||'Light off'),'msg-ok')})
.catch(function(e){clog('Licht-Fehler: '+e,'msg-err')}); .catch(function(e){clog((T.log_light_error||'Light error:')+' '+e,'msg-err')});
} }
// ── Print Speed ── // ── Print Speed ──
@@ -2172,7 +2388,7 @@ function setSpeed(mode){
if(b) b.classList.toggle('spd-active',m===mode); if(b) b.classList.toggle('spd-active',m===mode);
}); });
post('/api/speed',{mode:mode}) post('/api/speed',{mode:mode})
.catch(function(e){clog('Speed-Fehler: '+e,'msg-err')}); .catch(function(e){clog((T.log_speed_error||'Speed error:')+' '+e,'msg-err')});
} }
// ── Fan ── // ── Fan ──
@@ -2180,15 +2396,15 @@ function setFan(){
var v=parseInt(document.getElementById('d-fan').value); var v=parseInt(document.getElementById('d-fan').value);
document.getElementById('d-fan-val').textContent=v; document.getElementById('d-fan-val').textContent=v;
post('/api/fan',{speed:v}) post('/api/fan',{speed:v})
.then(function(){clog('Lüfter → '+v+'%','msg-ok')}) .then(function(){clog((T.log_fan||'Fan →')+' '+v+'%','msg-ok')})
.catch(function(e){clog('Lüfter-Fehler: '+e,'msg-err')}); .catch(function(e){clog((T.log_fan_error||'Fan error:')+' '+e,'msg-err')});
} }
function quickFan(v){ function quickFan(v){
document.getElementById('d-fan').value=v; document.getElementById('d-fan').value=v;
document.getElementById('d-fan-val').textContent=v; document.getElementById('d-fan-val').textContent=v;
post('/api/fan',{speed:v}) post('/api/fan',{speed:v})
.then(function(){clog('Lüfter → '+v+'%','msg-ok')}) .then(function(){clog((T.log_fan||'Fan →')+' '+v+'%','msg-ok')})
.catch(function(e){clog('Lüfter-Fehler: '+e,'msg-err')}); .catch(function(e){clog((T.log_fan_error||'Fan error:')+' '+e,'msg-err')});
} }
// ── AMS ── // ── AMS ──
@@ -2196,7 +2412,7 @@ function amsFeed(type){
var slot=parseInt(document.getElementById('ams-slot-sel').value); var slot=parseInt(document.getElementById('ams-slot-sel').value);
post('/api/ams/feed',{slot_index:slot,type:type}) post('/api/ams/feed',{slot_index:slot,type:type})
.then(function(){clog((type===1?T.lbl_feed:T.lbl_unload)+' Slot '+(slot+1),'msg-ok')}) .then(function(){clog((type===1?T.lbl_feed:T.lbl_unload)+' Slot '+(slot+1),'msg-ok')})
.catch(function(e){clog('AMS-Fehler: '+e,'msg-err')}); .catch(function(e){clog((T.log_ams_error||'AMS error:')+' '+e,'msg-err')});
} }
// ── Camera ── // ── Camera ──
@@ -2213,13 +2429,13 @@ function camStart(){
img.style.display='none'; img.style.display='none';
ph.style.display='flex'; ph.style.display='flex';
camOn=false; camOn=false;
document.getElementById('cam-toggle-btn').textContent=T.btn_cam_start||'Kamera'; document.getElementById('cam-toggle-btn').textContent=T.btn_cam_start||'Camera';
clog((T.log_error||'Fehler:')+' Stream nicht verfügbar','msg-err'); clog((T.log_error||'Error:')+' '+(T.log_stream_unavailable||'Stream unavailable'),'msg-err');
}; };
img.src='/api/camera/stream?t='+Date.now(); img.src='/api/camera/stream?t='+Date.now();
camOn=true; camOn=true;
document.getElementById('cam-toggle-btn').textContent=T.btn_cam_stop||'Kamera'; document.getElementById('cam-toggle-btn').textContent=T.btn_cam_stop||'Camera';
clog((T.log_cam_start||'Kamera gestartet'),'msg-ok'); clog((T.log_cam_start||'Camera started'),'msg-ok');
// MJPEG liefert kein onload Spinner nach kurzem Timeout ausblenden // MJPEG liefert kein onload Spinner nach kurzem Timeout ausblenden
setTimeout(function(){ setTimeout(function(){
sp.style.display='none'; sp.style.display='none';
@@ -2228,7 +2444,7 @@ function camStart(){
}).catch(function(e){ }).catch(function(e){
sp.style.display='none'; sp.style.display='none';
ph.style.display='flex'; ph.style.display='flex';
clog((T.log_error||'Fehler:')+' '+e,'msg-err'); clog((T.log_error||'Error:')+' '+e,'msg-err');
}); });
} }
function camStop(){ function camStop(){
@@ -2239,9 +2455,9 @@ function camStop(){
document.getElementById('cam-spinner').style.display='none'; document.getElementById('cam-spinner').style.display='none';
document.getElementById('cam-placeholder').style.display='flex'; document.getElementById('cam-placeholder').style.display='flex';
camOn=false; camOn=false;
document.getElementById('cam-toggle-btn').textContent=T.btn_cam_start||'Kamera'; document.getElementById('cam-toggle-btn').textContent=T.btn_cam_start||'Camera';
clog(T.log_cam_stop||'Kamera gestoppt','msg-ok'); clog(T.log_cam_stop||'Camera stopped','msg-ok');
}).catch(function(e){clog((T.log_error||'Fehler:')+' '+e,'msg-err')}); }).catch(function(e){clog((T.log_error||'Error:')+' '+e,'msg-err')});
} }
function toggleCam(){if(camOn)camStop();else camStart()} function toggleCam(){if(camOn)camStop();else camStart()}
</script> </script>
@@ -2304,7 +2520,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
pass pass
self._state["print_state"] = "error" self._state["print_state"] = "error"
self._state["kobra_state"] = "offline" self._state["kobra_state"] = "offline"
log.info("Manuell getrennt") log.info("Disconnected manually")
return web.json_response({"result": "disconnected"}) return web.json_response({"result": "disconnected"})
async def handle_api_speed(self, request): async def handle_api_speed(self, request):
@@ -2439,7 +2655,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
"video", "startCapture", None, timeout=8.0 "video", "startCapture", None, timeout=8.0
)) ))
state = (result or {}).get("state", "") state = (result or {}).get("state", "")
log.info(f"Kamera startCapture: state={state}") log.info(f"Camera startCapture: state={state}")
return web.json_response({"result": "ok", "state": state}) return web.json_response({"result": "ok", "state": state})
async def handle_api_camera_stop(self, request): async def handle_api_camera_stop(self, request):
@@ -2453,7 +2669,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
"""Einzelner JPEG-Frame aus dem Kamera-Stream für Obico und andere Snapshot-Clients.""" """Einzelner JPEG-Frame aus dem Kamera-Stream für Obico und andere Snapshot-Clients."""
url = self._state.get("camera_url", "") url = self._state.get("camera_url", "")
if not url: if not url:
return web.Response(status=503, text="Keine Kamera-URL bekannt") return web.Response(status=503, text="No camera URL known")
is_rtsp = url.lower().startswith("rtsp://") is_rtsp = url.lower().startswith("rtsp://")
input_args = ["-fflags", "nobuffer", "-flags", "low_delay"] input_args = ["-fflags", "nobuffer", "-flags", "low_delay"]
if is_rtsp: if is_rtsp:
@@ -2475,7 +2691,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
except Exception as e: except Exception as e:
return web.Response(status=503, text=str(e)) return web.Response(status=503, text=str(e))
if not jpeg: if not jpeg:
return web.Response(status=503, text="Kein Frame empfangen") return web.Response(status=503, text="No frame received")
return web.Response(body=jpeg, content_type="image/jpeg", return web.Response(body=jpeg, content_type="image/jpeg",
headers={"Cache-Control": "no-cache"}) headers={"Cache-Control": "no-cache"})
@@ -2483,7 +2699,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
"""MJPEG proxy: FLV → MJPEG via ffmpeg, served as multipart/x-mixed-replace.""" """MJPEG proxy: FLV → MJPEG via ffmpeg, served as multipart/x-mixed-replace."""
url = self._state.get("camera_url", "") url = self._state.get("camera_url", "")
if not url: if not url:
return web.Response(status=503, text="Keine Kamera-URL bekannt") return web.Response(status=503, text="No camera URL known")
is_rtsp = url.lower().startswith("rtsp://") is_rtsp = url.lower().startswith("rtsp://")
ffmpeg_input_args = [ ffmpeg_input_args = [
@@ -2512,10 +2728,10 @@ function toggleCam(){if(camOn)camStop();else camStart()}
stderr=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
) )
except (FileNotFoundError, OSError) as e: except (FileNotFoundError, OSError) as e:
log.warning("Kamera: ffmpeg nicht gefunden Kamerastream nicht verfügbar") log.warning("Camera: ffmpeg not found - camera stream unavailable")
return web.Response(status=503, text="ffmpeg not found") return web.Response(status=503, text="ffmpeg not found")
except Exception as e: except Exception as e:
log.warning(f"Kamera: ffmpeg konnte nicht gestartet werden: {e}") log.warning(f"Camera: ffmpeg could not be started: {e}")
return web.Response(status=503, text=str(e)) return web.Response(status=503, text=str(e))
boundary = "kobraxframe" boundary = "kobraxframe"
@@ -2555,7 +2771,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
except Exception: except Exception:
return resp return resp
except Exception as e: except Exception as e:
log.warning(f"Kamera-Stream unterbrochen: {e}") log.warning(f"Camera stream interrupted: {e}")
finally: finally:
try: try:
proc.kill() proc.kill()
@@ -2571,7 +2787,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
if not os.path.isfile(serve_path): if not os.path.isfile(serve_path):
return web.Response(status=404, text="not found") return web.Response(status=404, text="not found")
size = os.path.getsize(serve_path) size = os.path.getsize(serve_path)
log.info(f"Drucker lädt Datei ab: {filename} ({size} bytes)") log.info(f"Printer downloading file: {filename} ({size} bytes)")
return web.FileResponse(serve_path, headers={ return web.FileResponse(serve_path, headers={
"Content-Disposition": f'attachment; filename="{filename}"' "Content-Disposition": f'attachment; filename="{filename}"'
}) })
@@ -2604,6 +2820,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
"thumbnail": self._thumbnail_b64, "thumbnail": self._thumbnail_b64,
"connection_error": s["connection_error"], "connection_error": s["connection_error"],
"file_ready": s["file_ready"], "file_ready": s["file_ready"],
"uploaded_filaments": self._uploaded_metadata_for(s["file_ready"] or self._last_uploaded_file),
"version": self._read_version(), "version": self._read_version(),
}) })
@@ -2615,7 +2832,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
if namespace == "lane_data": if namespace == "lane_data":
await asyncio.get_event_loop().run_in_executor(None, self._get_ams_slots_fresh) await asyncio.get_event_loop().run_in_executor(None, self._get_ams_slots_fresh)
lanes = self._build_lane_data() lanes = self._build_lane_data()
log.info(f"AMS-Sync: {len(lanes)} Lanes an OrcaSlicer") log.info(f"AMS sync: {len(lanes)} lanes sent to OrcaSlicer")
return web.json_response({ return web.json_response({
"result": { "result": {
"namespace": "lane_data", "namespace": "lane_data",
@@ -2713,7 +2930,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
return response return response
def _restart_bridge(self): def _restart_bridge(self):
log.info("Bridge wird neu gestartet …") log.info("Restarting bridge ...")
exe = sys.executable exe = sys.executable
# PyInstaller frozen binary: sys.argv[0] == sys.executable → nicht doppelt übergeben # PyInstaller frozen binary: sys.argv[0] == sys.executable → nicht doppelt übergeben
if getattr(sys, "frozen", False): if getattr(sys, "frozen", False):
@@ -2804,12 +3021,12 @@ function toggleCam(){if(camOn)camStop();else camStart()}
return web.json_response({"error": f"Gitea HTTP {resp.status}"}, status=502) return web.json_response({"error": f"Gitea HTTP {resp.status}"}, status=502)
releases = await resp.json(content_type=None) releases = await resp.json(content_type=None)
if not releases: if not releases:
return web.json_response({"error": "Keine Releases gefunden"}, status=404) return web.json_response({"error": "No releases found"}, status=404)
# Dev: neuestes Release mit "-dev+" im Tag suchen # Dev: neuestes Release mit "-dev+" im Tag suchen
if is_dev: if is_dev:
dev_releases = [r for r in releases if "-dev+" in r.get("tag_name", "")] dev_releases = [r for r in releases if "-dev+" in r.get("tag_name", "")]
if not dev_releases: if not dev_releases:
return web.json_response({"error": "Keine Dev-Releases gefunden"}, status=404) return web.json_response({"error": "No dev releases found"}, status=404)
data = dev_releases[0] data = dev_releases[0]
else: else:
data = releases[0] data = releases[0]
@@ -2902,7 +3119,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
break break
self.ws_clients.discard(ws) self.ws_clients.discard(ws)
log.info(f"WS client getrennt ({len(self.ws_clients)} verbleibend)") log.info(f"WS client disconnected ({len(self.ws_clients)} remaining)")
return ws return ws
async def _handle_ws_rpc(self, ws: web.WebSocketResponse, raw: str): async def _handle_ws_rpc(self, ws: web.WebSocketResponse, raw: str):
@@ -2962,11 +3179,8 @@ function toggleCam(){if(camOn)camStop();else camStart()}
elif method == "printer.print.start": elif method == "printer.print.start":
filename = params.get("filename", self._last_uploaded_file) filename = params.get("filename", self._last_uploaded_file)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
resp = await loop.run_in_executor( await loop.run_in_executor(None, lambda: self._start_print(filename))
None, lambda: self.client.publish("print", "start", result = "ok"
{"filename": filename, "use_ams": False}, timeout=15.0)
)
result = "ok" if resp else "timeout"
elif method == "printer.print.pause": elif method == "printer.print.pause":
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self.client.pause_print) await loop.run_in_executor(None, self.client.pause_print)
@@ -2987,7 +3201,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
log.debug(f"Unbekannte RPC-Methode: {method}") log.debug(f"Unbekannte RPC-Methode: {method}")
result = {} result = {}
except Exception as e: except Exception as e:
log.error(f"RPC-Fehler für {method}: {e}") log.error(f"RPC error for {method}: {e}")
error = {"code": -32603, "message": str(e)} error = {"code": -32603, "message": str(e)}
if rpc_id is not None: if rpc_id is not None:
@@ -3021,7 +3235,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
# ── Offline-Modus: warten bis Drucker wieder erreichbar ────────── # ── Offline-Modus: warten bis Drucker wieder erreichbar ──────────
if _offline: if _offline:
if self._printer_reachable(): if self._printer_reachable():
log.info("Drucker erreichbar stelle MQTT-Verbindung her …") log.info("Printer reachable - connecting MQTT ...")
try: try:
self.client.connect() self.client.connect()
_offline = False _offline = False
@@ -3032,7 +3246,7 @@ function toggleCam(){if(camOn)camStop();else camStart()}
except Exception as e: except Exception as e:
err = _mqtt_error_msg(e) err = _mqtt_error_msg(e)
self._state["connection_error"] = err self._state["connection_error"] = err
log.warning(f"Verbindungsaufbau fehlgeschlagen: {err}") log.warning(f"Connection attempt failed: {err}")
stop_event.wait(_probe_interval) stop_event.wait(_probe_interval)
continue continue
else: else:
@@ -3057,10 +3271,10 @@ function toggleCam(){if(camOn)camStop();else camStart()}
if slots: if slots:
self._ams_slots = slots self._ams_slots = slots
except Exception as e: except Exception as e:
log.warning(f"Poll-Fehler: {e}") log.warning(f"Poll error: {e}")
# Prüfen ob Drucker wirklich weg ist # Prüfen ob Drucker wirklich weg ist
if not self._printer_reachable(): if not self._printer_reachable():
log.info("Drucker nicht erreichbar wechsle in Offline-Modus") log.info("Printer unreachable - switching to offline mode")
self._state["print_state"] = "error" self._state["print_state"] = "error"
self._state["kobra_state"] = "offline" self._state["kobra_state"] = "offline"
self._state["connection_error"] = f"Printer unreachable ({self._args.printer_ip})" self._state["connection_error"] = f"Printer unreachable ({self._args.printer_ip})"
@@ -3084,7 +3298,7 @@ def _mqtt_error_msg(exc: Exception) -> str:
def build_app(bridge: KobraXBridge) -> web.Application: def build_app(bridge: KobraXBridge) -> web.Application:
app = web.Application(client_max_size=256 * 1024 * 1024) # 256 MB für große GCode-Dateien app = web.Application()
r = app.router r = app.router
# Moonraker API # Moonraker API
@@ -3164,13 +3378,13 @@ async def run_bridge(args):
# Verbindungsversuch beim Start bei Fehler im Offline-Modus weiterlaufen # Verbindungsversuch beim Start bei Fehler im Offline-Modus weiterlaufen
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
log.info(f"Verbinde mit Drucker {args.printer_ip}:{args.mqtt_port} ") log.info(f"Connecting to printer {args.printer_ip}:{args.mqtt_port} ...")
try: try:
await loop.run_in_executor(None, client.connect) await loop.run_in_executor(None, client.connect)
log.info("MQTT verbunden") log.info("MQTT verbunden")
except Exception as e: except Exception as e:
err = _mqtt_error_msg(e) err = _mqtt_error_msg(e)
log.warning(f"Verbindung fehlgeschlagen: {err} starte im Offline-Modus") log.warning(f"Connection failed: {err} - starting in offline mode")
bridge._state["print_state"] = "error" bridge._state["print_state"] = "error"
bridge._state["kobra_state"] = "offline" bridge._state["kobra_state"] = "offline"
bridge._state["connection_error"] = err bridge._state["connection_error"] = err
@@ -3194,7 +3408,7 @@ async def run_bridge(args):
_local_ip = _s.getsockname()[0] _local_ip = _s.getsockname()[0]
except Exception: except Exception:
_local_ip = args.host _local_ip = args.host
log.info(f"Bridge läuft auf http://{_local_ip}:{args.port}") log.info(f"Bridge running at http://{_local_ip}:{args.port}")
log.info(f"OrcaSlicer → Klipper → Host: {_local_ip} Port: {args.port}") log.info(f"OrcaSlicer → Klipper → Host: {_local_ip} Port: {args.port}")
log.info("Ctrl-C zum Beenden") log.info("Ctrl-C zum Beenden")
@@ -3208,7 +3422,7 @@ async def run_bridge(args):
stop_event.set() stop_event.set()
await runner.cleanup() await runner.cleanup()
client.disconnect() client.disconnect()
log.info("Bridge beendet") log.info("Bridge stopped")
def main(): def main():

View File

@@ -1,4 +1,2 @@
aiohttp>=3.9 aiohttp>=3.9
imageio-ffmpeg>=0.4.9 imageio-ffmpeg>=0.4.9
requests>=2.30.0
pycryptodome>=3.20.0