3236 lines
150 KiB
Python
3236 lines
150 KiB
Python
"""
|
||
kobrax_moonraker_bridge.py – Moonraker-kompatibler HTTP/WebSocket-Bridge für Anycubic Kobra X
|
||
|
||
Emuliert die Moonraker/Klipper-API damit OrcaSlicer den Kobra X direkt ansteuern kann.
|
||
|
||
Verwendung:
|
||
python kobrax_moonraker_bridge.py --printer-ip 192.168.178.94
|
||
|
||
OrcaSlicer-Konfiguration:
|
||
Drucker-Typ: Klipper | Host: 127.0.0.1 | Port: 7125
|
||
"""
|
||
|
||
import argparse
|
||
try:
|
||
import config_loader as env_loader
|
||
except ImportError:
|
||
import env_loader
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import os
|
||
import pathlib
|
||
import re
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import threading
|
||
|
||
# Bei PyInstaller-Binary liegt alles neben sys.executable, sonst neben __file__
|
||
_BASE = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, _BASE)
|
||
from kobrax_client import KobraXClient
|
||
|
||
|
||
try:
|
||
import imageio_ffmpeg
|
||
def _find_ffmpeg() -> str:
|
||
return imageio_ffmpeg.get_ffmpeg_exe()
|
||
except ImportError:
|
||
def _find_ffmpeg() -> str:
|
||
exe_name = "ffmpeg.exe" if sys.platform == "win32" else "ffmpeg"
|
||
local = os.path.join(_BASE, exe_name)
|
||
if os.path.isfile(local):
|
||
return local
|
||
return "ffmpeg"
|
||
|
||
try:
|
||
from aiohttp import web
|
||
import aiohttp
|
||
except ImportError:
|
||
print("Fehler: aiohttp nicht installiert. Bitte: pip install aiohttp")
|
||
sys.exit(1)
|
||
|
||
logging.basicConfig(level=logging.INFO,
|
||
format="[%(asctime)s] %(levelname)-5s %(name)s: %(message)s",
|
||
datefmt="%H:%M:%S")
|
||
log = logging.getLogger("bridge")
|
||
|
||
# Ring-Buffer für Browser-Log-Stream (letzte 200 Einträge)
|
||
import collections as _collections
|
||
_log_buffer: "_collections.deque[dict]" = _collections.deque(maxlen=500)
|
||
_log_sse_queues: "list[asyncio.Queue]" = []
|
||
|
||
class _BrowserLogHandler(logging.Handler):
|
||
"""Sendet Log-Records in den Ring-Buffer und alle offenen SSE-Queues."""
|
||
_fmt = logging.Formatter(datefmt="%H:%M:%S")
|
||
|
||
def emit(self, record: logging.LogRecord):
|
||
entry = {
|
||
"ts": self._fmt.formatTime(record, "%H:%M:%S"),
|
||
"lvl": record.levelname,
|
||
"name": record.name,
|
||
"msg": record.getMessage(),
|
||
}
|
||
_log_buffer.append(entry)
|
||
for q in list(_log_sse_queues):
|
||
try:
|
||
q.put_nowait(entry)
|
||
except Exception:
|
||
pass
|
||
|
||
_browser_handler = _BrowserLogHandler()
|
||
logging.getLogger().addHandler(_browser_handler)
|
||
|
||
KOBRA_TO_KLIPPER_STATE = {
|
||
"free": "standby",
|
||
"busy": "printing",
|
||
"printing": "printing",
|
||
"preheating": "printing",
|
||
"auto_leveling": "printing",
|
||
"checking": "printing",
|
||
"updated": "printing",
|
||
"init": "printing",
|
||
"pausing": "paused",
|
||
"paused": "paused",
|
||
"resuming": "printing",
|
||
"resumed": "printing",
|
||
"stopping": "printing",
|
||
"stoped": "standby",
|
||
"finished": "complete",
|
||
"failed": "error",
|
||
"canceled": "standby",
|
||
}
|
||
|
||
MOONRAKER_VERSION = "v0.9.3-1"
|
||
KLIPPER_VERSION = "v0.12.0-1"
|
||
|
||
|
||
def _parse_gcode_estimated_time(data: bytes) -> int:
|
||
"""Liest geschätzte Druckzeit aus GCode (OrcaSlicer + PrusaSlicer).
|
||
Gibt Sekunden zurück, 0 wenn nicht gefunden.
|
||
PrusaSlicer schreibt die Zeit ins Header (erste 16KB),
|
||
OrcaSlicer schreibt sie ans Ende der Datei (letzte 16KB)."""
|
||
import re
|
||
# Anfang + Ende der Datei durchsuchen (OrcaSlicer schreibt Zeit am Ende)
|
||
search_text = (data[:16384] + data[-65536:]).decode("utf-8", errors="ignore")
|
||
# OrcaSlicer: ; total estimated time: 9m 20s
|
||
# PrusaSlicer: ; estimated printing time (normal mode) = 1h 9m 20s
|
||
m = (re.search(r";\s*total estimated time:\s*(.*)", search_text) or
|
||
re.search(r";\s*estimated printing time \(normal mode\)\s*=\s*(.*)", search_text))
|
||
if not m:
|
||
return 0
|
||
parts = re.findall(r"(\d+)\s*([hms])", m.group(1))
|
||
secs = 0
|
||
for val, unit in parts:
|
||
if unit == "h": secs += int(val) * 3600
|
||
elif unit == "m": secs += int(val) * 60
|
||
elif unit == "s": secs += int(val)
|
||
if secs:
|
||
log.info(f"Slicer-Schätzzeit: {secs}s ({m.group(1).strip()})")
|
||
return secs
|
||
|
||
|
||
class KobraXBridge:
|
||
def __init__(self, client: KobraXClient, args=None):
|
||
self.client = client
|
||
self._args = args
|
||
self.ws_clients: set[web.WebSocketResponse] = set()
|
||
self._last_state: dict = {}
|
||
self._state = {
|
||
"nozzle_temp": 0.0,
|
||
"nozzle_target": 0.0,
|
||
"bed_temp": 0.0,
|
||
"bed_target": 0.0,
|
||
"print_state": "standby",
|
||
"kobra_state": "free",
|
||
"filename": "",
|
||
"slicer_time": 0,
|
||
"progress": 0.0,
|
||
"print_duration": 0,
|
||
"remain_time": 0,
|
||
"curr_layer": 0,
|
||
"total_layers": 0,
|
||
"printer_name": "Anycubic Kobra X",
|
||
"firmware_version": "unknown",
|
||
"upload_url": "",
|
||
"camera_url": "",
|
||
"fan_speed": 0,
|
||
"light_on": False,
|
||
"light_brightness": 80,
|
||
"taskid": "-1",
|
||
"print_speed_mode": 2,
|
||
"connection_error": "",
|
||
"file_ready": "",
|
||
}
|
||
self._ams_slots: list[dict] = []
|
||
self._ams_loaded_slot: int = -1
|
||
self._last_uploaded_file: str = ""
|
||
self._serve_dir = tempfile.TemporaryDirectory(prefix="kobrax_serve_")
|
||
self._serve_dir_path: str = self._serve_dir.name
|
||
|
||
self._thumbnail_b64: str = ""
|
||
|
||
# Register MQTT push callbacks
|
||
client.callbacks["tempature/report"] = self._on_temp
|
||
client.callbacks["print/report"] = self._on_print
|
||
client.callbacks["info/report"] = self._on_info
|
||
client.callbacks["file/report"] = self._on_file
|
||
client.callbacks["multiColorBox/report"] = self._on_multicolor_box
|
||
|
||
# -------------------------------------------------------------------------
|
||
# MQTT callbacks (called from reader thread)
|
||
# -------------------------------------------------------------------------
|
||
|
||
def _on_temp(self, payload: dict):
|
||
d = payload.get("data") or {}
|
||
self._state["nozzle_temp"] = float(d.get("curr_nozzle_temp", 0))
|
||
self._state["nozzle_target"] = float(d.get("target_nozzle_temp", 0))
|
||
self._state["bed_temp"] = float(d.get("curr_hotbed_temp", 0))
|
||
self._state["bed_target"] = float(d.get("target_hotbed_temp", 0))
|
||
self._push_status_update()
|
||
|
||
def _on_print(self, payload: dict):
|
||
d = payload.get("data") or {}
|
||
kobra_state = payload.get("state", "")
|
||
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["file_ready"] = ""
|
||
self._state["print_duration"] = 0
|
||
self._state["remain_time"] = 0
|
||
self._state["slicer_time"] = 0
|
||
self._thumbnail_b64 = ""
|
||
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):
|
||
d = payload.get("data") or {}
|
||
self._state["printer_name"] = d.get("printerName", self._state["printer_name"])
|
||
self._state["firmware_version"] = d.get("version", self._state["firmware_version"])
|
||
kobra_state = d.get("state", "")
|
||
if kobra_state:
|
||
self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "standby")
|
||
self._state["kobra_state"] = kobra_state
|
||
t = d.get("temp") or {}
|
||
if t:
|
||
self._state["nozzle_temp"] = float(t.get("curr_nozzle_temp", 0))
|
||
self._state["nozzle_target"] = float(t.get("target_nozzle_temp", 0))
|
||
self._state["bed_temp"] = float(t.get("curr_hotbed_temp", 0))
|
||
self._state["bed_target"] = float(t.get("target_hotbed_temp", 0))
|
||
urls = d.get("urls") or {}
|
||
if urls.get("fileUploadurl"):
|
||
self._state["upload_url"] = urls["fileUploadurl"]
|
||
if urls.get("rtspUrl"):
|
||
self._state["camera_url"] = urls["rtspUrl"]
|
||
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):
|
||
d = payload.get("data") or {}
|
||
details = d.get("file_details") or {}
|
||
thumb = details.get("thumbnail") or details.get("png_image") or ""
|
||
if thumb:
|
||
self._thumbnail_b64 = thumb
|
||
log.info(f"Vorschaubild empfangen: {len(thumb)} Zeichen base64")
|
||
self._push_status_update()
|
||
|
||
def _on_multicolor_box(self, payload: dict):
|
||
boxes = (payload.get("data") or {}).get("multi_color_box") or []
|
||
if not boxes:
|
||
return
|
||
box = boxes[0]
|
||
slots = box.get("slots") or []
|
||
loaded = box.get("loaded_slot", -1)
|
||
if loaded != -1:
|
||
self._ams_loaded_slot = loaded
|
||
# Tip-Forming: nach Einziehen (status=10) oder Ausziehen (status=11)
|
||
# schickt der originale Slicer automatisch type=3 (Extruder-Rückzug)
|
||
fs = box.get("feed_status") or {}
|
||
current_status = fs.get("current_status")
|
||
slot_index = fs.get("slot_index", 0)
|
||
if current_status in (10, 11):
|
||
import threading
|
||
def _tip_form():
|
||
import time; time.sleep(2)
|
||
self.client.publish(
|
||
"multiColorBox", "feedFilament",
|
||
{"multi_color_box": [{"id": -1, "feed_status": {"slot_index": slot_index, "type": 3}}]},
|
||
timeout=0
|
||
)
|
||
log.info(f"Tip-Forming (type=3) nach status={current_status} slot={slot_index}")
|
||
threading.Thread(target=_tip_form, daemon=True).start()
|
||
if slots:
|
||
self._ams_slots = slots
|
||
log.info(f"AMS-Slots empfangen: {len(slots)}, loaded_slot={self._ams_loaded_slot}")
|
||
self._push_status_update()
|
||
|
||
# OrcaSlicer filament preset IDs (MoonrakerPrinterAgent.cpp mapping)
|
||
_TRAY_INFO_IDX = {
|
||
"PLA": "OGFL99", "PLA-CF": "OGFL98", "PLA SILK": "OGFL96",
|
||
"PETG": "OGFG99", "PETG-CF": "OGFG98",
|
||
"ABS": "OGFB99", "ASA": "OGFB98",
|
||
"TPU": "OGFT99", "PA": "OGFP99", "PA-CF": "OGFP98",
|
||
"PC": "OGFC99", "HIPS": "OGFH99", "PVA": "OGFV99",
|
||
}
|
||
|
||
def _build_lane_data(self) -> dict:
|
||
"""Baut BBL-AMS-JSON für OrcaSlicer DevFilaSystemParser::ParseV1_0."""
|
||
slots = self._ams_slots
|
||
total = len(slots)
|
||
if total == 0:
|
||
return {"ams": [], "ams_exist_bits": "0", "tray_exist_bits": "0"}
|
||
|
||
ams_count = (total + 3) // 4
|
||
ams_exist_bits = 0
|
||
tray_exist_bits = 0
|
||
ams_array = []
|
||
|
||
for ams_id in range(ams_count):
|
||
ams_exist_bits |= (1 << ams_id)
|
||
tray_array = []
|
||
max_slot = min(3, total - ams_id * 4 - 1)
|
||
for slot_id in range(max_slot + 1):
|
||
slot_index = ams_id * 4 + slot_id
|
||
slot = slots[slot_index] if slot_index < total else {}
|
||
occupied = slot.get("status") == 5
|
||
|
||
if occupied:
|
||
tray_exist_bits |= (1 << slot_index)
|
||
color_raw = slot.get("color", [255, 255, 255])
|
||
if isinstance(color_raw, list) and len(color_raw) >= 3:
|
||
color_hex = "{:02X}{:02X}{:02X}FF".format(
|
||
int(color_raw[0]), int(color_raw[1]), int(color_raw[2])
|
||
)
|
||
elif isinstance(color_raw, str) and len(color_raw) >= 6:
|
||
color_hex = color_raw[:6].upper() + "FF"
|
||
else:
|
||
color_hex = "FFFFFFFF"
|
||
material = slot.get("type", "PLA").upper()
|
||
tray_info_idx = self._TRAY_INFO_IDX.get(material, "OGFL99")
|
||
tray_array.append({
|
||
"id": str(slot_id),
|
||
"tag_uid": "0000000000000000",
|
||
"tray_info_idx": tray_info_idx,
|
||
"tray_type": material,
|
||
"tray_color": color_hex,
|
||
})
|
||
else:
|
||
tray_array.append({
|
||
"id": str(slot_id),
|
||
"tag_uid": "0000000000000000",
|
||
"tray_info_idx": "",
|
||
"tray_type": "",
|
||
"tray_color": "00000000",
|
||
"tray_slot_placeholder": "1",
|
||
})
|
||
|
||
ams_array.append({"id": str(ams_id), "info": "0002", "tray": tray_array})
|
||
|
||
return {
|
||
"ams": ams_array,
|
||
"ams_exist_bits": format(ams_exist_bits, "X"),
|
||
"tray_exist_bits": format(tray_exist_bits, "X"),
|
||
}
|
||
|
||
# -------------------------------------------------------------------------
|
||
# WebSocket push
|
||
# -------------------------------------------------------------------------
|
||
|
||
def _push_status_update(self):
|
||
if not self.ws_clients:
|
||
return
|
||
msg = {
|
||
"jsonrpc": "2.0",
|
||
"method": "notify_status_update",
|
||
"params": [self._build_printer_objects(), time.time()],
|
||
}
|
||
text = json.dumps(msg)
|
||
dead = set()
|
||
for ws in self.ws_clients:
|
||
try:
|
||
asyncio.run_coroutine_threadsafe(ws.send_str(text), ws._loop)
|
||
except Exception:
|
||
dead.add(ws)
|
||
self.ws_clients -= dead
|
||
|
||
def _build_printer_objects(self) -> dict:
|
||
s = self._state
|
||
return {
|
||
"extruder": {
|
||
"temperature": s["nozzle_temp"],
|
||
"target": s["nozzle_target"],
|
||
"power": 0.0,
|
||
},
|
||
"heater_bed": {
|
||
"temperature": s["bed_temp"],
|
||
"target": s["bed_target"],
|
||
"power": 0.0,
|
||
},
|
||
"print_stats": {
|
||
"state": s["print_state"],
|
||
"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"],
|
||
},
|
||
},
|
||
"display_status": {
|
||
"progress": s["progress"],
|
||
"message": "",
|
||
},
|
||
"virtual_sdcard": {
|
||
"progress": s["progress"],
|
||
"is_active": s["print_state"] == "printing",
|
||
"file_path": s["filename"],
|
||
},
|
||
"toolhead": {
|
||
"position": [0, 0, 0, 0],
|
||
"homed_axes": "xyz",
|
||
"print_time": s["print_duration"],
|
||
"estimated_print_time": s["print_duration"],
|
||
},
|
||
}
|
||
|
||
# -------------------------------------------------------------------------
|
||
# HTTP handlers
|
||
# -------------------------------------------------------------------------
|
||
|
||
async def handle_server_info(self, request):
|
||
return web.json_response({
|
||
"result": {
|
||
"klippy_connected": True,
|
||
"klippy_state": "ready",
|
||
"components": ["file_manager", "job_state", "virtual_sdcard"],
|
||
"failed_components":[],
|
||
"registered_directories": ["gcodes"],
|
||
"warnings": [],
|
||
"websocket_count": len(self.ws_clients),
|
||
"moonraker_version": MOONRAKER_VERSION,
|
||
"api_version": [1, 3, 0],
|
||
"api_version_string": "1.3.0",
|
||
}
|
||
})
|
||
|
||
async def handle_printer_info(self, request):
|
||
s = self._state
|
||
return web.json_response({
|
||
"result": {
|
||
"state": "ready",
|
||
"state_message": "Printer is ready",
|
||
"hostname": "kobrax-bridge",
|
||
"klipper_path": "/home/pi/klipper",
|
||
"python_path": "/home/pi/klippy-env/bin/python",
|
||
"log_file": "/tmp/klippy.log",
|
||
"config_file": "/home/pi/printer.cfg",
|
||
"software_version": KLIPPER_VERSION,
|
||
"cpu_info": s["printer_name"],
|
||
}
|
||
})
|
||
|
||
async def handle_machine_system_info(self, request):
|
||
return web.json_response({
|
||
"result": {
|
||
"system_info": {
|
||
"cpu_info": {"cpu_count": 4, "bits": "64bit", "processor": "armv7l",
|
||
"cpu_desc": "Anycubic Kobra X Bridge", "serial_number": "",
|
||
"hardware_desc": "", "model": "Kobra X Bridge",
|
||
"total_memory": 524288, "memory_units": "kB"},
|
||
"sd_info": {},
|
||
"distribution": {"name": "Linux", "id": "linux", "version": "1.0",
|
||
"version_parts": {}, "like": "", "codename": ""},
|
||
"available_services": [],
|
||
"service_state": {},
|
||
"python": {"version": list(sys.version_info[:3]), "version_string": sys.version},
|
||
"network": {},
|
||
"canbus": {},
|
||
}
|
||
}
|
||
})
|
||
|
||
async def handle_objects_query(self, request):
|
||
objects = self._build_printer_objects()
|
||
# filter by requested objects if specified
|
||
requested = dict(request.rel_url.query)
|
||
if requested:
|
||
filtered = {k: objects[k] for k in requested if k in objects}
|
||
else:
|
||
filtered = objects
|
||
return web.json_response({"result": {"status": filtered, "eventtime": time.time()}})
|
||
|
||
async def handle_objects_list(self, request):
|
||
return web.json_response({
|
||
"result": {
|
||
"objects": list(self._build_printer_objects().keys())
|
||
}
|
||
})
|
||
|
||
async def handle_objects_subscribe(self, request):
|
||
return web.json_response({
|
||
"result": {
|
||
"status": self._build_printer_objects(),
|
||
"eventtime": time.time(),
|
||
}
|
||
})
|
||
|
||
async def handle_files_list(self, request):
|
||
filename = self._state.get("filename", "")
|
||
files = []
|
||
if filename:
|
||
files.append({
|
||
"path": filename,
|
||
"modified": time.time(),
|
||
"size": 0,
|
||
"permissions": "rw",
|
||
})
|
||
return web.json_response({"result": files})
|
||
|
||
async def handle_file_upload(self, request):
|
||
log.info(f"Upload-Request: {request.method} {request.path_qs} CT={request.headers.get('Content-Type','')[:60]}")
|
||
ct = request.headers.get("Content-Type", "")
|
||
if "multipart" not in ct:
|
||
return web.json_response({"error": "expected multipart"}, status=400)
|
||
auto_print = False
|
||
reader = await request.multipart()
|
||
file_data = None
|
||
remote_filename = self._last_uploaded_file or "upload.gcode"
|
||
|
||
async for part in reader:
|
||
if part.name in ("file", "gcode", "upload_file"):
|
||
remote_filename = part.filename or remote_filename
|
||
file_data = await part.read()
|
||
log.info(f"Multipart-Feld '{part.name}': {remote_filename} ({len(file_data)} bytes)")
|
||
elif part.name == "path":
|
||
val = (await part.read()).decode("utf-8", errors="replace").strip()
|
||
if val:
|
||
remote_filename = val
|
||
elif part.name == "print":
|
||
val = (await part.read()).decode("utf-8", errors="replace").strip().lower()
|
||
auto_print = val == "true"
|
||
else:
|
||
log.debug(f"Unbekanntes Multipart-Feld: {part.name}")
|
||
|
||
if not file_data:
|
||
return web.json_response({"error": "no file received"}, status=400)
|
||
|
||
file_md5 = hashlib.md5(file_data).hexdigest()
|
||
file_size = len(file_data)
|
||
|
||
# Slicer-Zeitschätzung aus GCode-Header auslesen
|
||
self._state["slicer_time"] = _parse_gcode_estimated_time(file_data)
|
||
|
||
# Datei auf Disk ablegen (temp-Verzeichnis) damit Drucker sie per HTTP abrufen kann
|
||
safe_name = os.path.basename(remote_filename) # keine Pfad-Traversal
|
||
serve_path = os.path.join(self._serve_dir_path, safe_name)
|
||
with open(serve_path, "wb") as f:
|
||
f.write(file_data)
|
||
del file_data # RAM freigeben
|
||
|
||
self._last_uploaded_file = remote_filename
|
||
log.info(f"Upload: {remote_filename} ({file_size} bytes) md5={file_md5} → Drucker")
|
||
|
||
# Datei per HTTP auf den Drucker hochladen (serve_path liegt bereits auf Disk)
|
||
upload_url = self._state.get("upload_url") or None
|
||
loop = asyncio.get_event_loop()
|
||
try:
|
||
result = await loop.run_in_executor(
|
||
None, self.client.upload_gcode, serve_path, remote_filename, upload_url
|
||
)
|
||
except Exception as e:
|
||
log.error(f"Upload fehlgeschlagen: {e}")
|
||
return web.json_response({"error": str(e)}, status=500)
|
||
|
||
log.info(f"Upload erfolgreich: {result}")
|
||
|
||
# Druck starten mit vollständigem Payload (inkl. serve-URL + md5 + size)
|
||
serve_url = f"http://{request.host}/serve/{remote_filename}"
|
||
|
||
# print=true im Multipart-Formular (Moonraker) oder Query-String → Druck starten
|
||
# print=false oder fehlt → nur hochladen
|
||
if not auto_print:
|
||
auto_print = request.rel_url.query.get("print", "false").lower() == "true"
|
||
|
||
# Thumbnail immer anfordern (Drucker antwortet async mit file/report)
|
||
self._thumbnail_b64 = ""
|
||
self.client.publish("file", "fileDetails", {"root": "local", "filename": remote_filename}, timeout=0)
|
||
|
||
if auto_print:
|
||
log.info(f"Upload+Print (print=true): {remote_filename}")
|
||
self._state["file_ready"] = ""
|
||
loop = asyncio.get_event_loop()
|
||
loop.run_in_executor(None, lambda: self._start_print(remote_filename, serve_url, file_md5, file_size))
|
||
else:
|
||
log.info(f"Nur hochgeladen (print=false): {remote_filename}")
|
||
self._state["file_ready"] = remote_filename
|
||
|
||
# OctoPrint-kompatibler Response (OrcaSlicer wertet refs aus)
|
||
return web.json_response({
|
||
"done": True,
|
||
"files": {
|
||
"local": {
|
||
"name": remote_filename,
|
||
"origin": "local",
|
||
"path": remote_filename,
|
||
"refs": {
|
||
"download": f"http://{request.host}/api/files/local/{remote_filename}",
|
||
"resource": f"http://{request.host}/api/files/local/{remote_filename}",
|
||
}
|
||
}
|
||
},
|
||
"result": {
|
||
"item": {"path": remote_filename, "root": "gcodes"},
|
||
"action": "create_file",
|
||
}
|
||
}, status=201)
|
||
|
||
def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0):
|
||
self._state["file_ready"] = ""
|
||
default_slot = getattr(self._args, "default_ams_slot", "auto")
|
||
all_loaded = [(i, s) for i, s in enumerate(self._ams_slots) if s.get("status") == 5]
|
||
if default_slot != "auto":
|
||
try:
|
||
slot_idx = int(default_slot)
|
||
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)
|
||
payload = {
|
||
"taskid": "-1",
|
||
"url": url,
|
||
"filename": filename,
|
||
"md5": md5,
|
||
"filepath": None,
|
||
"filetype": 1,
|
||
"project_type": 1,
|
||
"filesize": filesize,
|
||
"ams_settings": {
|
||
"use_ams": use_ams,
|
||
"ams_box_mapping": ams_box_mapping,
|
||
},
|
||
"task_settings": {
|
||
"auto_leveling": auto_leveling,
|
||
"vibration_compensation": 0,
|
||
"flow_calibration": 0,
|
||
"dry_mode": 0,
|
||
"ai_settings": {"status": 0, "count": 0, "type": 1},
|
||
"timelapse": {"status": 0, "count": 0, "type": 64},
|
||
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
|
||
"model_objects_skip_parts": [],
|
||
},
|
||
}
|
||
log.info(f"print/start → {filename} url={url} ams={len(self._ams_slots)} slots")
|
||
result = self.client.publish("print", "start", payload, timeout=15.0)
|
||
if result:
|
||
log.info(f"Druckstart bestätigt: state={result.get('state')}")
|
||
else:
|
||
log.warning("Druckstart: keine Antwort vom Drucker")
|
||
|
||
async def handle_print_start(self, request):
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
filename = (request.rel_url.query.get("filename")
|
||
or body.get("filename")
|
||
or self._last_uploaded_file)
|
||
if not filename:
|
||
return web.json_response({"error": "no filename"}, status=400)
|
||
|
||
log.info(f"Druck starten: {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]),
|
||
})
|
||
|
||
use_ams = len(ams_box_mapping) > 0
|
||
|
||
payload = {
|
||
"filename": filename,
|
||
"taskid": str(int(time.time())),
|
||
"use_ams": use_ams,
|
||
}
|
||
if ams_box_mapping:
|
||
payload["ams_box_mapping"] = ams_box_mapping
|
||
|
||
loop = asyncio.get_event_loop()
|
||
result = await loop.run_in_executor(
|
||
None, lambda: self.client.publish("print", "start", payload, timeout=15.0)
|
||
)
|
||
if result is None:
|
||
return web.json_response({"error": "Keine Antwort vom Drucker"}, status=504)
|
||
|
||
return web.json_response({"result": "ok"})
|
||
|
||
async def handle_print_pause(self, request):
|
||
loop = asyncio.get_event_loop()
|
||
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()
|
||
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()
|
||
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_api_file_ready_clear(self, request):
|
||
self._state["file_ready"] = ""
|
||
self._thumbnail_b64 = ""
|
||
self._push_status_update()
|
||
return web.json_response({"result": "ok"})
|
||
|
||
async def handle_octoprint_version(self, request):
|
||
return web.json_response({
|
||
"api": "0.1",
|
||
"server": "1.9.0",
|
||
"text": "OctoPrint (Kobra X Bridge)",
|
||
})
|
||
|
||
async def handle_index(self, request):
|
||
html = r"""<!DOCTYPE html>
|
||
<html lang="de" data-theme="dark">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>KX-Bridge</title>
|
||
<style>
|
||
:root{
|
||
--bg:#1a1a1f;--card:#24242c;--raised:#2e2e3a;--border:#3a3a4a;
|
||
--txt:#f0f0f5;--txt2:#8888aa;--accent:#00c8ff;--accent2:#ff6b35;
|
||
--ok:#4cde80;--err:#ff4d6d;--warn:#ffb020;
|
||
--font:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||
--mono:"JetBrains Mono","Fira Code",monospace;
|
||
}
|
||
[data-theme=light]{
|
||
--bg:#f0f0f5;--card:#fff;--raised:#e8e8f0;--border:#d0d0e0;
|
||
--txt:#1a1a2e;--txt2:#666680;
|
||
}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{background:var(--bg);color:var(--txt);font-family:var(--font);font-size:14px;min-height:100vh;display:flex;flex-direction:column}
|
||
a{color:var(--accent);text-decoration:none}
|
||
|
||
/* ── HEADER ── */
|
||
header{background:var(--card);border-bottom:1px solid var(--border);
|
||
display:flex;align-items:center;gap:12px;padding:0 20px;height:52px;
|
||
position:sticky;top:0;z-index:100}
|
||
.logo{font-size:18px;font-weight:700;color:var(--accent);letter-spacing:-.02em}
|
||
.hname{font-size:13px;color:var(--txt2);flex:1}
|
||
.hbadge{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600;
|
||
padding:4px 10px;border-radius:20px;background:var(--raised);color:var(--txt2);
|
||
text-transform:uppercase;letter-spacing:.04em}
|
||
.hbadge.printing{background:#0d2d1a;color:var(--ok)}
|
||
.hbadge.complete{background:#0d1f38;color:#60b0ff}
|
||
.hbadge.error{background:#2d0d0d;color:var(--err)}
|
||
.hbadge .dot{width:7px;height:7px;border-radius:50%;background:currentColor}
|
||
.hbadge.printing .dot{animation:pulse 1.4s infinite}
|
||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
|
||
.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}
|
||
nav.sidebar{width:200px;background:var(--card);border-right:1px solid var(--border);
|
||
display:flex;flex-direction:column;padding:12px 8px;gap:2px;flex-shrink:0}
|
||
.nav-btn{background:none;border:none;color:var(--txt2);text-align:left;
|
||
padding:9px 12px;border-radius:8px;cursor:pointer;font-size:13px;
|
||
display:flex;align-items:center;gap:10px;transition:.12s;width:100%}
|
||
.nav-btn:hover{background:var(--raised);color:var(--txt)}
|
||
.nav-btn.active{background:var(--raised);color:var(--accent)}
|
||
.nav-icon{font-size:16px;width:20px;text-align:center}
|
||
main{flex:1;overflow-y:auto;padding:20px}
|
||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px}
|
||
|
||
/* ── CARD ── */
|
||
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;
|
||
padding:18px;transition:box-shadow .15s,transform .15s}
|
||
.card:hover{box-shadow:0 4px 20px rgba(0,0,0,.3);transform:translateY(-1px)}
|
||
.card-title{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--txt2);
|
||
margin-bottom:14px;display:flex;align-items:center;gap:8px}
|
||
.card-title span{font-size:14px}
|
||
|
||
/* ── HERO ── */
|
||
.hero{grid-column:1/-1;display:grid;grid-template-columns:1fr 320px;gap:16px}
|
||
@media(max-width:900px){.hero{grid-template-columns:1fr}}
|
||
.cam-wrap{background:#0a0a0e;border-radius:10px;overflow:hidden;
|
||
min-height:180px;max-height:320px;display:flex;align-items:center;justify-content:center;position:relative}
|
||
.cam-wrap img,.cam-wrap video{width:100%;max-height:320px;height:auto;display:block;object-fit:contain}
|
||
.cam-placeholder{color:var(--txt2);font-size:13px;text-align:center;padding:20px}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
.cam-spinner{width:40px;height:40px;border:3px solid rgba(255,255,255,.15);
|
||
border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;display:none}
|
||
.cam-overlay{position:absolute;bottom:0;left:0;right:0;
|
||
background:linear-gradient(transparent,rgba(0,0,0,.75));padding:14px}
|
||
.cam-toggle{position:absolute;top:10px;right:10px;background:rgba(0,0,0,.5);
|
||
border:1px solid rgba(255,255,255,.2);color:#fff;border-radius:8px;
|
||
padding:6px 10px;cursor:pointer;font-size:12px;backdrop-filter:blur(4px)}
|
||
.cam-toggle:hover{background:rgba(0,0,0,.7)}
|
||
|
||
/* ── PROGRESS ── */
|
||
.hero-info{display:flex;flex-direction:column;gap:12px}
|
||
.pct-big{font-size:52px;font-weight:700;line-height:1;color:var(--txt)}
|
||
.pct-big small{font-size:20px;font-weight:400;color:var(--txt2)}
|
||
.progress-bar{height:8px;background:var(--raised);border-radius:4px;overflow:hidden;margin:4px 0}
|
||
.progress-fill{height:100%;background:linear-gradient(90deg,var(--accent),#0080cc);
|
||
border-radius:4px;transition:width .6s ease}
|
||
.meta-row{display:flex;justify-content:space-between;font-size:12px;color:var(--txt2)}
|
||
.layer-badge{background:var(--raised);border-radius:6px;padding:4px 8px;
|
||
font-family:var(--mono);font-size:12px;color:var(--txt)}
|
||
.fname{font-size:12px;color:var(--txt2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||
background:var(--raised);border-radius:6px;padding:6px 8px}
|
||
|
||
/* ── PRINT CONTROLS ── */
|
||
.ctrl-btns{display:flex;gap:8px;flex-wrap:wrap}
|
||
.btn{border:none;border-radius:8px;padding:10px 16px;font-size:13px;font-weight:600;
|
||
cursor:pointer;transition:opacity .15s,transform .1s;white-space:nowrap}
|
||
.btn:hover{opacity:.85;transform:translateY(-1px)}
|
||
.btn:active{transform:translateY(0)}
|
||
.btn-start{background:var(--ok);color:#0d2010}
|
||
.btn-pause{background:var(--raised);color:var(--txt);border:1px solid var(--border)}
|
||
.btn-resume{background:#0d2d1a;color:var(--ok);border:1px solid var(--ok)}
|
||
.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}
|
||
|
||
/* ── TIME CARDS ── */
|
||
.time-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:8px}
|
||
.time-block{background:var(--raised);border-radius:10px;padding:10px 12px}
|
||
.time-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:4px}
|
||
.time-val{font-size:20px;font-weight:700;font-family:var(--mono);color:var(--txt)}
|
||
/* ── TEMPS ── */
|
||
.temp-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||
.temp-card-inner{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||
.temp-block{background:var(--raised);border-radius:10px;padding:14px;position:relative}
|
||
.temp-label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:6px}
|
||
.temp-row{display:flex;align-items:baseline;gap:6px}
|
||
.temp-val{font-size:30px;font-weight:700;font-family:var(--mono)}
|
||
.temp-unit{font-size:14px;color:var(--txt2)}
|
||
.temp-target{font-size:11px;color:var(--txt2);margin-top:2px}
|
||
.temp-arc{position:absolute;top:12px;right:12px}
|
||
.temp-edit{display:flex;gap:6px;margin-top:10px}
|
||
.temp-input{background:var(--bg);border:1px solid var(--border);color:var(--txt);
|
||
border-radius:6px;padding:5px 8px;font-size:13px;font-family:var(--mono);width:70px}
|
||
.temp-input:focus{outline:none;border-color:var(--accent)}
|
||
.chart-wrap{margin-top:12px}
|
||
canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:var(--raised)}
|
||
|
||
/* ── MOTION ── */
|
||
.joypad{display:grid;grid-template-columns:repeat(3,52px);
|
||
grid-template-rows:repeat(3,52px);gap:6px;justify-content:center;margin:8px auto}
|
||
.joy{background:var(--raised);border:1px solid var(--border);color:var(--txt);
|
||
border-radius:10px;font-size:18px;cursor:pointer;transition:.12s;
|
||
display:flex;align-items:center;justify-content:center}
|
||
.joy:hover{background:var(--accent);color:#001a24;border-color:var(--accent)}
|
||
.joy:active{transform:scale(.93)}
|
||
.joy.home{font-size:14px;background:var(--bg)}
|
||
.step-btns{display:flex;gap:6px;justify-content:center;flex-wrap:wrap;margin-top:10px}
|
||
.step-btn{background:var(--raised);border:1px solid var(--border);color:var(--txt2);
|
||
border-radius:6px;padding:5px 10px;font-size:12px;cursor:pointer;transition:.12s}
|
||
.step-btn.active,.step-btn:hover{background:var(--accent);color:#001a24;border-color:var(--accent)}
|
||
.home-btns{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;justify-content:center}
|
||
|
||
/* ── AMS ── */
|
||
.ams-slots{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
||
.ams-slot{background:var(--raised);border-radius:10px;padding:10px;text-align:center;
|
||
border:2px solid transparent;transition:.2s;position:relative}
|
||
.ams-slot.active{border-color:var(--slot-color,var(--accent));
|
||
box-shadow:0 0 12px rgba(var(--slot-rgb,0,200,255),.3)}
|
||
.slot-circle{width:36px;height:36px;border-radius:50%;margin:0 auto 6px;border:2px solid rgba(255,255,255,.15)}
|
||
.slot-label{font-size:11px;color:var(--txt2);font-family:var(--mono)}
|
||
.slot-material{font-size:12px;font-weight:600;margin-bottom:2px}
|
||
|
||
/* ── LIGHT + FAN ── */
|
||
.toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
|
||
.toggle-label{font-size:13px;font-weight:600}
|
||
.toggle{position:relative;width:44px;height:24px;cursor:pointer}
|
||
.toggle input{opacity:0;width:0;height:0;position:absolute}
|
||
.toggle-track{width:44px;height:24px;background:var(--raised);border-radius:12px;
|
||
border:1px solid var(--border);transition:.25s;display:block}
|
||
.toggle input:checked+.toggle-track{background:var(--accent)}
|
||
.toggle-thumb{position:absolute;top:3px;left:3px;width:18px;height:18px;
|
||
background:#fff;border-radius:50%;transition:.25s;pointer-events:none}
|
||
.toggle input:checked~.toggle-thumb{transform:translateX(20px)}
|
||
.slider-row{display:flex;align-items:center;gap:10px;margin-top:8px}
|
||
.slider-label{font-size:12px;color:var(--txt2);width:80px}
|
||
.slider{flex:1;-webkit-appearance:none;height:4px;border-radius:2px;
|
||
background:var(--raised);outline:none;cursor:pointer}
|
||
.slider::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;
|
||
border-radius:50%;background:var(--accent);cursor:pointer;transition:.1s}
|
||
.slider::-webkit-slider-thumb:hover{transform:scale(1.2)}
|
||
.slider-val{font-family:var(--mono);font-size:12px;color:var(--txt);width:30px;text-align:right}
|
||
|
||
/* ── CONSOLE ── */
|
||
.console{background:#0a0a0e;border-radius:8px;padding:10px;font-family:var(--mono);
|
||
font-size:11px;color:#8888aa;overflow-y:auto;line-height:1.6}
|
||
.console .ts{color:#444;margin-right:6px}
|
||
.console .msg-info{color:#8888aa}
|
||
.console .msg-ok{color:var(--ok)}
|
||
.console .msg-err{color:var(--err)}
|
||
.console .msg-warn{color:var(--warn)}
|
||
|
||
/* ── PANELS ── */
|
||
.panel{display:none}
|
||
.panel.active{display:block}
|
||
|
||
/* ── MODAL ── */
|
||
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);
|
||
z-index:200;align-items:center;justify-content:center;padding:16px}
|
||
.modal-overlay.open{display:flex}
|
||
.modal-box{background:var(--card);border:1px solid var(--border);border-radius:14px;
|
||
width:100%;max-width:480px;max-height:90vh;overflow-y:auto;padding:24px;
|
||
display:flex;flex-direction:column;gap:18px}
|
||
.modal-header{display:flex;align-items:center;justify-content:space-between}
|
||
.modal-title{font-size:15px;font-weight:700;color:var(--txt)}
|
||
.modal-close{background:none;border:none;color:var(--txt2);font-size:20px;
|
||
cursor:pointer;padding:4px 8px;border-radius:6px}
|
||
.modal-close:hover{background:var(--raised);color:var(--txt)}
|
||
.modal-section{font-size:10px;text-transform:uppercase;letter-spacing:.1em;
|
||
color:var(--txt2);margin-bottom:6px;margin-top:4px}
|
||
.modal-field{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}
|
||
.modal-field label{font-size:12px;color:var(--txt2)}
|
||
.modal-field input{background:var(--raised);border:1px solid var(--border);
|
||
border-radius:7px;color:var(--txt);padding:7px 10px;font-size:13px;width:100%}
|
||
.modal-field input:focus{outline:none;border-color:var(--accent)}
|
||
.poll-btns{display:flex;gap:8px}
|
||
.poll-btn{flex:1;padding:7px;background:var(--raised);border:1px solid var(--border);
|
||
border-radius:7px;color:var(--txt2);cursor:pointer;font-size:13px;transition:all .15s}
|
||
.poll-btn.active{background:var(--accent);border-color:var(--accent);color:#000;font-weight:600}
|
||
.update-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||
.update-status{font-size:12px;color:var(--txt2);flex:1;min-width:0}
|
||
.modal-save{width:100%;padding:10px;background:var(--accent);border:none;
|
||
border-radius:8px;color:#000;font-weight:700;font-size:14px;cursor:pointer;margin-top:4px}
|
||
.modal-save:hover{opacity:.88}
|
||
|
||
/* ── BOTTOM NAV (mobile) ── */
|
||
nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
|
||
background:var(--card);border-top:1px solid var(--border);
|
||
justify-content:space-around;padding:8px 0 max(8px,env(safe-area-inset-bottom))}
|
||
.bnav-btn{background:none;border:none;color:var(--txt2);display:flex;
|
||
flex-direction:column;align-items:center;gap:3px;cursor:pointer;font-size:10px;padding:4px 8px}
|
||
.bnav-btn.active{color:var(--accent)}
|
||
.bnav-icon{font-size:20px}
|
||
|
||
/* ── Tablet (769–1100px): schmale Sidebar ── */
|
||
@media(min-width:769px) and (max-width:1100px){
|
||
nav.sidebar{width:52px;padding:12px 4px}
|
||
.nav-btn .nav-text{display:none}
|
||
.nav-btn{justify-content:center;padding:10px}
|
||
.nav-icon{width:auto}
|
||
.grid{grid-template-columns:repeat(2,1fr)}
|
||
.hero{grid-template-columns:1fr}
|
||
}
|
||
|
||
/* ── Mobile (≤768px): Bottom-Nav, 1-Spalte ── */
|
||
@media(max-width:768px){
|
||
nav.sidebar{display:none}
|
||
nav.bottom-nav{display:flex}
|
||
main{padding:10px;padding-bottom:72px}
|
||
|
||
/* Header kompakt */
|
||
header{padding:0 12px;gap:8px}
|
||
.hname{display:none}
|
||
|
||
/* 1-Spalten-Grid, full-width spans funktionieren weiterhin */
|
||
.grid{grid-template-columns:1fr;gap:12px}
|
||
|
||
/* Hero: Kamera über Info */
|
||
.hero{grid-template-columns:1fr}
|
||
.cam-wrap{max-height:220px}
|
||
|
||
/* Temp-Pair und Temp-Card übereinander */
|
||
.temp-pair{grid-template-columns:1fr}
|
||
.temp-card-inner{grid-template-columns:1fr}
|
||
|
||
/* AMS: 2 Spalten */
|
||
.ams-slots{grid-template-columns:repeat(2,1fr)}
|
||
|
||
/* Joypad etwas kleiner */
|
||
.joypad{grid-template-columns:repeat(3,44px);grid-template-rows:repeat(3,44px);gap:5px}
|
||
.joy{font-size:16px}
|
||
|
||
/* Buttons größere Touch-Targets */
|
||
.btn{padding:10px 14px;font-size:13px}
|
||
.btn-sm{padding:8px 12px}
|
||
.step-btn{padding:8px 12px;font-size:13px}
|
||
|
||
/* Modal vollbreite auf kleinen Screens */
|
||
.modal-box{padding:16px;border-radius:10px}
|
||
.poll-btns{gap:6px}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="conn-error-banner" style="display:none;background:#c0392b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:999;"></div>
|
||
<div id="file-ready-banner" style="display:none;background:#1a6e3c;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:998;display:none;align-items:center;justify-content:center;gap:12px">
|
||
<span>📄 <span id="file-ready-name"></span></span>
|
||
<button id="file-ready-btn" onclick="startReadyFile()"
|
||
style="padding:5px 16px;background:#fff;color:#1a6e3c;border:none;border-radius:6px;font-weight:700;cursor:pointer;font-size:13px"></button>
|
||
<button id="file-cancel-btn" onclick="cancelReadyFile()"
|
||
style="padding:5px 16px;background:rgba(255,255,255,0.15);color:#fff;border:1px solid rgba(255,255,255,0.5);border-radius:6px;font-weight:700;cursor:pointer;font-size:13px"></button>
|
||
</div>
|
||
|
||
<header>
|
||
<div class="logo">⬡ KX-Bridge</div>
|
||
<div class="hname" id="h-pname">Anycubic Kobra X</div><span id="h-version" style="font-size:11px;opacity:.5;margin-left:6px"></span>
|
||
<div class="hbadge" id="h-badge"><span class="dot"></span><span id="h-state">Standby</span></div>
|
||
<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 ═══ -->
|
||
<div class="modal-overlay" id="settings-modal" onclick="if(event.target===this)closeSettings()">
|
||
<div class="modal-box">
|
||
<div class="modal-header">
|
||
<span class="modal-title" id="modal-title-settings">Einstellungen</span>
|
||
<button class="modal-close" onclick="closeSettings()">✕</button>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="modal-section" id="modal-sec-connection">Verbindung</div>
|
||
<div class="modal-field">
|
||
<label id="lbl-printer-ip">Drucker-IP</label>
|
||
<input type="text" id="s-printer-ip" placeholder="192.168.x.x">
|
||
<small id="lbl-ip-hint" style="color:#f80;display:none"></small>
|
||
</div>
|
||
<div class="modal-field">
|
||
<label id="lbl-mqtt-port">MQTT-Port</label>
|
||
<input type="number" id="s-mqtt-port" placeholder="9883">
|
||
</div>
|
||
<div class="modal-field">
|
||
<label id="lbl-username">MQTT-Benutzername</label>
|
||
<input type="text" id="s-username" placeholder="userXXXXXXXX" autocomplete="off">
|
||
</div>
|
||
<div class="modal-field">
|
||
<label id="lbl-password">MQTT-Passwort</label>
|
||
<input type="password" id="s-password" autocomplete="off">
|
||
</div>
|
||
<div class="modal-field">
|
||
<label id="lbl-device-id">Device-ID</label>
|
||
<input type="text" id="s-device-id" placeholder="32 Hex-Zeichen">
|
||
</div>
|
||
<div class="modal-field">
|
||
<label id="lbl-mode-id">Mode-ID</label>
|
||
<input type="text" id="s-mode-id" placeholder="20030">
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="modal-section" id="modal-sec-print">Druckeinstellungen</div>
|
||
<div class="modal-field">
|
||
<label id="lbl-default-slot">Standard-Slot (Einfarbdruck)</label>
|
||
<select id="s-default-slot">
|
||
<option value="auto" id="opt-slot-auto">Auto (alle belegten Slots)</option>
|
||
<option value="0" id="opt-slot-0">Slot 1</option>
|
||
<option value="1" id="opt-slot-1">Slot 2</option>
|
||
<option value="2" id="opt-slot-2">Slot 3</option>
|
||
<option value="3" id="opt-slot-3">Slot 4</option>
|
||
</select>
|
||
</div>
|
||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
|
||
<input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0">
|
||
<label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="modal-section" id="modal-sec-poll">Poll-Intervall</div>
|
||
<div class="poll-btns">
|
||
<button class="poll-btn" onclick="setPoll(1000)" id="poll-1">1s</button>
|
||
<button class="poll-btn active" onclick="setPoll(2000)" id="poll-2">2s</button>
|
||
<button class="poll-btn" onclick="setPoll(5000)" id="poll-5">5s</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="modal-section" id="modal-sec-version">Version</div>
|
||
<div class="update-row">
|
||
<span id="s-version-label" style="font-size:13px;color:var(--txt)">–</span>
|
||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="checkUpdate()" id="btn-update-check">🔄 <span id="lbl-update-check">Auf Updates prüfen</span></button>
|
||
</div>
|
||
<div class="update-status" id="update-status" style="margin-top:6px"></div>
|
||
<button class="btn btn-sm btn-accent" id="btn-update-apply" style="display:none;margin-top:8px" onclick="applyUpdate()">
|
||
<span id="lbl-update-apply">Jetzt installieren</span>
|
||
</button>
|
||
<div id="update-changelog" style="display:none;margin-top:10px;background:var(--raised);border-radius:6px;padding:10px;font-size:11px;font-family:var(--mono);color:var(--txt2);white-space:pre-wrap;max-height:180px;overflow-y:auto;line-height:1.6"></div>
|
||
</div>
|
||
|
||
<button class="modal-save" onclick="saveSettings()" id="btn-save-settings">Speichern & Neustart</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ AMS SLOT EDIT DIALOG ═══ -->
|
||
<div class="modal-overlay" id="slot-edit-modal" onclick="if(event.target===this)closeSlotEdit()">
|
||
<div class="modal-box" style="max-width:340px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||
<span class="modal-title" id="slot-edit-title"></span>
|
||
<button onclick="closeSlotEdit()" style="background:none;border:none;color:var(--txt2);font-size:20px;cursor:pointer;line-height:1">✕</button>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
|
||
<div id="slot-edit-preview" style="width:56px;height:56px;border-radius:50%;border:3px solid rgba(255,255,255,.2);flex-shrink:0"></div>
|
||
<div style="flex:1">
|
||
<div style="font-size:11px;color:var(--txt2);margin-bottom:4px" id="lbl-slot-color"></div>
|
||
<input type="color" id="slot-edit-color"
|
||
oninput="document.getElementById('slot-edit-preview').style.background=this.value"
|
||
style="width:100%;height:36px;border:1px solid var(--border);border-radius:6px;background:var(--raised);cursor:pointer;padding:2px">
|
||
</div>
|
||
</div>
|
||
<div style="margin-bottom:20px">
|
||
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="lbl-slot-material"></div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px" id="slot-mat-btns">
|
||
</div>
|
||
<input type="text" id="slot-edit-mat"
|
||
oninput="highlightMatBtn(this.value)"
|
||
style="margin-top:8px;width:100%;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px;box-sizing:border-box">
|
||
</div>
|
||
<button class="modal-save" id="btn-slot-edit-save" onclick="saveSlotEdit()"></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="layout">
|
||
<nav class="sidebar">
|
||
<button class="nav-btn active" onclick="showPanel('dashboard')" id="nb-dashboard">
|
||
<span class="nav-icon">⊞</span><span class="nav-text">Dashboard</span></button>
|
||
<button class="nav-btn" onclick="showPanel('console');clearLogBadge()" id="nb-console">
|
||
<span class="nav-icon">≡</span><span class="nav-text">Konsole</span><span id="log-badge" style="display:none;margin-left:4px;background:var(--err);color:#fff;border-radius:10px;font-size:10px;padding:1px 5px;font-weight:700"></span></button>
|
||
</nav>
|
||
|
||
<main>
|
||
<!-- ═══ DASHBOARD ═══ -->
|
||
<div class="panel active" id="panel-dashboard">
|
||
<div class="grid">
|
||
<!-- 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> <span id="d-card-cam">Kamera</span></div>
|
||
<div style="display:flex;align-items:center;gap:10px">
|
||
<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>
|
||
<span class="toggle-thumb"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="cam-wrap" id="cam-wrap">
|
||
<div class="cam-placeholder" id="cam-placeholder"><span id="cam-placeholder-txt">📷 Kamera nicht gestartet</span></div>
|
||
<div class="cam-spinner" id="cam-spinner"></div>
|
||
<img id="cam-img" style="display:none;width:100%;height:auto" alt="Kamera">
|
||
<div class="cam-overlay" id="cam-overlay" style="display:none">
|
||
<div style="font-size:12px;color:#fff" id="cam-fname"></div>
|
||
</div>
|
||
<button class="cam-toggle" onclick="toggleCam()" id="cam-toggle-btn">▶ Kamera</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Fortschritt -->
|
||
<div class="card" style="grid-column:1/-1">
|
||
<div class="card-title"><span>◉</span> <span id="d-card-progress">Fortschritt</span></div>
|
||
<img id="d-thumbnail" src="" alt="" style="display:none;width:100%;max-height:160px;object-fit:contain;border-radius:8px;background:#111;margin-bottom:10px">
|
||
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
|
||
<div style="display:flex;align-items:center;gap:10px;margin:8px 0">
|
||
<div class="progress-bar" style="flex:1;margin:0"><div class="progress-fill" id="d-pbar" style="width:0%"></div></div>
|
||
<div class="time-block" style="padding:6px 10px;min-width:72px;text-align:center;flex-shrink:0">
|
||
<div class="time-label" id="d-lbl-layers"></div>
|
||
<div class="time-val" style="font-size:16px" id="d-layers">–</div>
|
||
</div>
|
||
</div>
|
||
<div class="time-grid">
|
||
<div class="time-block">
|
||
<div class="time-label" id="d-lbl-elapsed"></div>
|
||
<div class="time-val" id="d-elapsed">–</div>
|
||
</div>
|
||
<div class="time-block" id="d-slicer-row" style="display:none">
|
||
<div class="time-label" id="d-slicer-label"></div>
|
||
<div class="time-val" id="d-slicer-time">–</div>
|
||
</div>
|
||
<div class="time-block" style="color:var(--acc)">
|
||
<div class="time-label" id="d-lbl-remain"></div>
|
||
<div class="time-val" id="d-remain" style="color:var(--acc)">–</div>
|
||
</div>
|
||
</div>
|
||
<div class="fname" id="d-fname" title="" style="margin-top:6px">–</div>
|
||
<div class="ctrl-btns" id="d-ctrl-btns" style="margin-top:12px">
|
||
<button class="btn btn-pause btn-sm" id="d-btn-pause" onclick="printAction('pause')">⏸ Pause</button>
|
||
<button class="btn btn-resume btn-sm" id="d-btn-resume" onclick="printAction('resume')">▶ Weiter</button>
|
||
<button class="btn btn-cancel btn-sm" id="d-btn-cancel" onclick="confirmCancel()">✕ Stopp</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Temperatursteuerung + Verlauf -->
|
||
<div class="card" style="grid-column:1/-1">
|
||
<div class="card-title"><span>⊙</span> <span id="d-card-temps">Temperaturen</span></div>
|
||
<div class="temp-card-inner">
|
||
<div class="temp-block">
|
||
<div class="temp-label">Nozzle</div>
|
||
<div class="temp-row">
|
||
<div class="temp-val" id="d-nt">–</div>
|
||
<div class="temp-unit">°C</div>
|
||
</div>
|
||
<div class="temp-target">→ <span id="d-nt-t">0</span>°C</div>
|
||
<div class="progress-bar" style="margin:8px 0 0">
|
||
<div class="progress-fill" id="d-ntbar" style="width:0%;background:linear-gradient(90deg,var(--accent2),#ffb020)"></div>
|
||
</div>
|
||
<div class="temp-edit" style="margin-top:10px">
|
||
<input type="number" class="temp-input" id="p-nozzle-inp" placeholder="Ziel" min="0" max="300" style="flex:1">
|
||
<button class="btn btn-sm btn-accent" onclick="setNozzle()"><span class="lbl-set">Set</span></button>
|
||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="document.getElementById('p-nozzle-inp').value=0;setNozzle()"><span class="lbl-off">Aus</span></button>
|
||
</div>
|
||
</div>
|
||
<div class="temp-block">
|
||
<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>
|
||
</div>
|
||
<div class="temp-target">→ <span id="d-bt-t">0</span>°C</div>
|
||
<div class="progress-bar" style="margin:8px 0 0">
|
||
<div class="progress-fill" id="d-btbar" style="width:0%;background:linear-gradient(90deg,#ff6b35,var(--warn))"></div>
|
||
</div>
|
||
<div class="temp-edit" style="margin-top:10px">
|
||
<input type="number" class="temp-input" id="p-bed-inp" placeholder="Ziel" min="0" max="120" style="flex:1">
|
||
<button class="btn btn-sm btn-accent" onclick="setBed()"><span class="lbl-set">Set</span></button>
|
||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="document.getElementById('p-bed-inp').value=0;setBed()"><span class="lbl-off">Aus</span></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:14px">
|
||
<div style="font-size:10px;color:var(--txt2);margin-bottom:4px" id="d-chart-label">Verlauf (letzte 60 Messungen)</div>
|
||
<canvas id="d-chart" width="800" height="120" style="width:100%;height:120px;background:var(--raised);border-radius:8px"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Achsensteuerung -->
|
||
<div class="card">
|
||
<div class="card-title"><span>✛</span> <span id="ptitle-motion-xy">XY-Achsen</span></div>
|
||
<div class="joypad">
|
||
<div></div>
|
||
<button class="joy" onclick="move(1,1,getStep())" title="Y+">▲</button>
|
||
<div></div>
|
||
<button class="joy" onclick="move(0,-1,getStep())" title="X−">◀</button>
|
||
<button class="joy home" onclick="homeAll()" title="Home All">⌂</button>
|
||
<button class="joy" onclick="move(0,1,getStep())" title="X+">▶</button>
|
||
<div></div>
|
||
<button class="joy" onclick="move(1,-1,getStep())" title="Y−">▼</button>
|
||
<div></div>
|
||
</div>
|
||
<div class="step-btns">
|
||
<button class="step-btn" onclick="setStep(this,0.1)">0.1</button>
|
||
<button class="step-btn active" onclick="setStep(this,1)">1</button>
|
||
<button class="step-btn" onclick="setStep(this,5)">5</button>
|
||
<button class="step-btn" onclick="setStep(this,10)">10 mm</button>
|
||
</div>
|
||
<div class="home-btns">
|
||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeZ()"><span class="lbl-home-z">Home Z</span></button>
|
||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeXY()"><span class="lbl-home-xy">Home XY</span></button>
|
||
<button class="btn btn-sm btn-accent" onclick="homeAll()"><span class="lbl-home-all">Home All</span></button>
|
||
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="disableMotors()"><span class="lbl-disable-motors">Motors Off</span></button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-title"><span>↕</span> <span id="ptitle-motion-z">Z-Achse</span></div>
|
||
<div class="joypad" style="grid-template-columns:52px;grid-template-rows:repeat(2,52px)">
|
||
<button class="joy" onclick="move(2,1,getStep())" title="Z+">▲</button>
|
||
<button class="joy" onclick="move(2,-1,getStep())" title="Z−">▼</button>
|
||
</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 -->
|
||
<div class="card">
|
||
<div class="card-title"><span>🌀</span> <span id="d-card-lightfan">Lüfter</span></div>
|
||
<div class="slider-row">
|
||
<input type="range" class="slider" min="0" max="100" value="0" id="d-fan" oninput="document.getElementById('d-fan-val').textContent=this.value" onchange="setFan()">
|
||
<span class="slider-val" id="d-fan-val">0</span>
|
||
</div>
|
||
<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(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(75)">75%</button>
|
||
<button class="btn btn-sm btn-accent" onclick="quickFan(100)">100%</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AMS -->
|
||
<div class="card" style="grid-column:1/-1" id="d-ams-card">
|
||
<div class="card-title"><span>◫</span> <span id="d-card-ams">AMS / Filamentbox</span></div>
|
||
<div class="ams-slots" id="ams-slots">
|
||
<div style="grid-column:1/-1;text-align:center;color:var(--txt2);padding:20px" id="ams-no-data">Keine AMS-Daten empfangen</div>
|
||
</div>
|
||
<div id="ams-controls" style="margin-top:16px;display:flex;flex-direction:column;gap:10px">
|
||
<div style="font-size:12px;color:var(--txt2);margin-bottom:2px" id="ams-slot-lbl">Slot auswählen</div>
|
||
<div style="display:flex;align-items:center;gap:10px">
|
||
<input type="range" id="ams-slot-sel" min="0" max="3" step="1" value="0"
|
||
class="slider" style="flex:1"
|
||
oninput="document.getElementById('ams-slot-label').textContent='Slot '+(parseInt(this.value)+1)">
|
||
<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)">⬇ <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>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══ CONSOLE ═══ -->
|
||
<div class="panel" id="panel-console">
|
||
<div class="card">
|
||
<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>
|
||
<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>
|
||
</div>
|
||
<div style="display:flex;gap:6px;margin-bottom:6px;flex-wrap:wrap;align-items:center">
|
||
<input id="log-filter" type="text" placeholder="Filter…"
|
||
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)">
|
||
<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>
|
||
<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>
|
||
</div>
|
||
<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>
|
||
<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-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>
|
||
<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="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="status" onclick="setLogTopic('status')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">status</button>
|
||
</div>
|
||
<div class="console" id="console-log" style="height:calc(100vh - 260px);min-height:160px" onscroll="onLogScroll()"></div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<nav class="bottom-nav">
|
||
<button class="bnav-btn active" onclick="showPanel('dashboard')" id="bnb-dashboard"><span class="bnav-icon">⊞</span>Dashboard</button>
|
||
<button class="bnav-btn" onclick="showPanel('console');clearLogBadge()" id="bnb-console"><span class="bnav-icon">≡</span>Log<span id="log-badge-bot" style="display:none;margin-left:3px;background:var(--err);color:#fff;border-radius:10px;font-size:10px;padding:1px 4px;font-weight:700"></span></button>
|
||
</nav>
|
||
|
||
<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,remain_time:0,
|
||
curr_layer:0,total_layers:0,printer_name:'Kobra X',firmware_version:'–',
|
||
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;
|
||
var currentPanel='dashboard';
|
||
|
||
// ── Theme ──
|
||
function toggleTheme(){
|
||
var h=document.documentElement;
|
||
h.setAttribute('data-theme',h.getAttribute('data-theme')==='dark'?'light':'dark');
|
||
localStorage.setItem('theme',h.getAttribute('data-theme'));
|
||
}
|
||
(function(){var t=localStorage.getItem('theme');if(t)document.documentElement.setAttribute('data-theme',t)})();
|
||
|
||
// ── 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_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_speed:'Druckgeschwindigkeit',card_cam:'Kamera',lbl_elapsed:'Verstrichen:',lbl_remaining:'Restzeit:',lbl_slicer_time:'Slicer-Schätzung:',lbl_layers:'Layer',
|
||
speed_silent:'🐢 Leise',speed_normal:'⚡ Normal',speed_sport:'🚀 Sport',
|
||
lbl_light:'💡 Licht',lbl_feed:'Einziehen',lbl_unload:'Ausziehen',
|
||
cam_placeholder:'📷 Kamera nicht gestartet',btn_cam_start:'▶ Kamera',btn_cam_stop:'◼ Kamera',
|
||
btn_pause:'⏸ Pause',btn_resume:'▶ Weiter',btn_cancel:'✕ Stopp',
|
||
label_nozzle:'Nozzle',label_bed:'Bett',label_fan:'🌀 Lüfter',label_light:'💡 Licht',label_on_off:'Ein / Aus',label_speed:'Geschwindigkeit',
|
||
panel_print_title:'Drucksteuerung',panel_print_btn_pause:'⏸ Pause',panel_print_btn_resume:'▶ Fortsetzen',panel_print_btn_cancel:'✕ Abbrechen',panel_print_temps_live:'Temperaturen (Live)',
|
||
label_set:'Setzen',label_off:'Aus',
|
||
panel_temps_nozzle:'Nozzle',panel_temps_bed:'Heizbett',panel_temps_chart:'Verlauf (letzte 60 Messungen)',label_target_c:'Ziel:',
|
||
panel_motion_xy:'XY-Achsen',panel_motion_z:'Z-Achse',label_step:'Schrittweite:',btn_home_z:'Home Z',btn_home_xy:'Home XY',btn_home_all:'Home All',btn_disable_motors:'Motoren aus',
|
||
panel_ams_title:'AMS / Filamentbox',ams_no_data:'Keine AMS-Daten empfangen',label_slot:'Slot',ams_empty:'Leer',
|
||
panel_extras_light:'Licht',panel_extras_fan:'Lüfter',panel_extras_camera:'Kamera',btn_cam_start2:'▶ Start',btn_cam_stop2:'◼ Stop',
|
||
panel_console_title:'Ereignis-Log',
|
||
log_light_on:'Licht an',log_light_off:'Licht aus',log_fan:'Lüfter →',log_nozzle:'Nozzle →',log_bed:'Bett →',log_axis:'Achse',log_home:'Home',log_home_all:'Home All',log_cam_start:'Kamera gestartet:',log_cam_stop:'Kamera gestoppt',log_poll_error:'Poll-Fehler:',log_error:'Fehler:',
|
||
confirm_cancel:'Druck wirklich abbrechen?',
|
||
settings_title:'Einstellungen',settings_connection:'Verbindung',settings_print:'Druckeinstellungen',settings_poll:'Poll-Intervall',settings_version:'Version',
|
||
settings_save:'Speichern & Neustart',settings_printer_ip:'Drucker-IP',settings_mqtt_port:'MQTT-Port',
|
||
settings_username:'MQTT-Benutzername',settings_password:'MQTT-Passwort',settings_device_id:'Device-ID',settings_mode_id:'Mode-ID',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',
|
||
update_check:'Auf Updates prüfen',update_checking:'Prüfe...',update_available:'verfügbar',update_none:'Bereits aktuell',
|
||
update_apply:'Jetzt installieren',update_applying:'Lade herunter...',update_restarting:'Starte neu...',update_error:'Fehler',
|
||
btn_connect:'⚡ Verbinden',btn_disconnect:'✕ Trennen',
|
||
lbl_conn_error:'Verbindungsfehler:',
|
||
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_ok:'AMS Slot',
|
||
log_dir_all:'Alle',
|
||
file_ready_btn:'▶ Druck starten',
|
||
file_cancel_btn:'✕ Abbrechen'
|
||
};
|
||
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_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_speed:'Print Speed',card_cam:'Camera',lbl_elapsed:'Elapsed:',lbl_remaining:'Remaining:',lbl_slicer_time:'Slicer estimate:',lbl_layers:'Layer',
|
||
speed_silent:'🐢 Silent',speed_normal:'⚡ Normal',speed_sport:'🚀 Sport',
|
||
lbl_light:'💡 Light',lbl_feed:'Load',lbl_unload:'Unload',
|
||
cam_placeholder:'📷 Camera not started',btn_cam_start:'▶ Camera',btn_cam_stop:'◼ Camera',
|
||
btn_pause:'⏸ Pause',btn_resume:'▶ Resume',btn_cancel:'✕ Stop',
|
||
label_nozzle:'Nozzle',label_bed:'Bed',label_fan:'🌀 Fan',label_light:'💡 Light',label_on_off:'On / Off',label_speed:'Speed',
|
||
panel_print_title:'Print Control',panel_print_btn_pause:'⏸ Pause',panel_print_btn_resume:'▶ Resume',panel_print_btn_cancel:'✕ Cancel',panel_print_temps_live:'Temperatures (Live)',
|
||
label_set:'Set',label_off:'Off',
|
||
panel_temps_nozzle:'Nozzle',panel_temps_bed:'Heated Bed',panel_temps_chart:'History (last 60 readings)',label_target_c:'Target:',
|
||
panel_motion_xy:'XY Axes',panel_motion_z:'Z Axis',label_step:'Step size:',btn_home_z:'Home Z',btn_home_xy:'Home XY',btn_home_all:'Home All',btn_disable_motors:'Motors Off',
|
||
panel_ams_title:'AMS / Filament Box',ams_no_data:'No AMS data received',label_slot:'Slot',ams_empty:'Empty',
|
||
panel_extras_light:'Light',panel_extras_fan:'Fan',panel_extras_camera:'Camera',btn_cam_start2:'▶ Start',btn_cam_stop2:'◼ Stop',
|
||
panel_console_title:'Event Log',
|
||
log_light_on:'Light on',log_light_off:'Light off',log_fan:'Fan →',log_nozzle:'Nozzle →',log_bed:'Bed →',log_axis:'Axis',log_home:'Home',log_home_all:'Home All',log_cam_start:'Camera started:',log_cam_stop:'Camera stopped',log_poll_error:'Poll error:',log_error:'Error:',
|
||
confirm_cancel:'Really cancel the print?',
|
||
settings_title:'Settings',settings_connection:'Connection',settings_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_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_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_apply:'Install Now',update_applying:'Downloading...',update_restarting:'Restarting...',update_error:'Error',
|
||
btn_connect:'⚡ Connect',btn_disconnect:'✕ Disconnect',
|
||
lbl_conn_error:'Connection error:',
|
||
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_ok:'AMS Slot',
|
||
log_dir_all:'All',
|
||
file_ready_btn:'▶ Start Print',
|
||
file_cancel_btn:'✕ Cancel'
|
||
};
|
||
var currentLang='de';
|
||
var T=LANG_DE;
|
||
function toggleLang(){
|
||
currentLang=currentLang==='de'?'en':'de';
|
||
T=currentLang==='de'?LANG_DE:LANG_EN;
|
||
localStorage.setItem('lang',currentLang);
|
||
document.getElementById('lang-btn').textContent=currentLang==='de'?'EN':'DE';
|
||
document.documentElement.setAttribute('lang',currentLang);
|
||
applyLang();
|
||
}
|
||
function applyLang(){
|
||
// Nav
|
||
var nb=document.getElementById('nb-dashboard');if(nb)nb.querySelector('.nav-text').textContent=T.nav_dashboard;
|
||
nb=document.getElementById('nb-console');if(nb)nb.querySelector('.nav-text').textContent=T.nav_console;
|
||
// Bottom nav
|
||
var bnb=document.getElementById('bnb-dashboard');if(bnb)bnb.lastChild.textContent=T.nav_dashboard;
|
||
bnb=document.getElementById('bnb-console');if(bnb)bnb.lastChild.textContent=T.nav_console;
|
||
// Dashboard card titles
|
||
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-elapsed',T.lbl_elapsed);
|
||
setText('d-lbl-remain',T.lbl_remaining);
|
||
setText('d-slicer-label',T.lbl_slicer_time);
|
||
setText('d-lbl-layers',T.lbl_layers);
|
||
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);
|
||
setText('d-btn-cancel',T.btn_cancel);
|
||
setText('cam-toggle-btn',camOn?T.btn_cam_stop:T.btn_cam_start);
|
||
setText('cam-placeholder-txt',T.cam_placeholder);
|
||
// Temp labels
|
||
document.querySelectorAll('.lbl-set').forEach(e=>e.textContent=T.label_set);
|
||
document.querySelectorAll('.lbl-off').forEach(e=>e.textContent=T.label_off);
|
||
setText('d-chart-label',T.panel_temps_chart);
|
||
// Axis labels
|
||
setText('ptitle-motion-xy',T.panel_motion_xy);
|
||
setText('ptitle-motion-z',T.panel_motion_z);
|
||
document.querySelectorAll('.lbl-home-z').forEach(e=>e.textContent=T.btn_home_z);
|
||
document.querySelectorAll('.lbl-home-xy').forEach(e=>e.textContent=T.btn_home_xy);
|
||
document.querySelectorAll('.lbl-home-all').forEach(e=>e.textContent=T.btn_home_all);
|
||
document.querySelectorAll('.lbl-disable-motors').forEach(e=>e.textContent=T.btn_disable_motors);
|
||
document.querySelectorAll('.lbl-step').forEach(e=>e.textContent=T.label_step);
|
||
document.querySelectorAll('.temp-input').forEach(e=>e.setAttribute('placeholder',T.label_target_c.replace(':','')));
|
||
// Console
|
||
setText('ptitle-console',T.panel_console_title);
|
||
// Settings modal
|
||
setText('modal-title-settings',T.settings_title);
|
||
setText('modal-sec-connection',T.settings_connection);
|
||
setText('modal-sec-print',T.settings_print);
|
||
setText('modal-sec-poll',T.settings_poll);
|
||
setText('modal-sec-version',T.settings_version);
|
||
setText('btn-save-settings',T.settings_save);
|
||
setText('lbl-printer-ip',T.settings_printer_ip);
|
||
setText('lbl-mqtt-port',T.settings_mqtt_port);
|
||
setText('lbl-username',T.settings_username);
|
||
setText('lbl-password',T.settings_password);
|
||
setText('lbl-device-id',T.settings_device_id);
|
||
setText('lbl-mode-id',T.settings_mode_id);
|
||
setText('lbl-default-slot',T.settings_default_slot);
|
||
setText('opt-slot-auto',T.settings_slot_auto);
|
||
setText('lbl-auto-leveling',T.settings_auto_leveling);
|
||
|
||
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();
|
||
// Slot-Edit-Dialog
|
||
setText('lbl-slot-color',T.slot_edit_color);
|
||
setText('lbl-slot-material',T.slot_edit_material);
|
||
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);
|
||
setText('logdir-all',T.log_dir_all);
|
||
setText('file-ready-btn',T.file_ready_btn);
|
||
setText('file-cancel-btn',T.file_cancel_btn);
|
||
}
|
||
function setText(id,txt){var el=document.getElementById(id);if(el)el.textContent=txt;}
|
||
(function(){
|
||
var l=localStorage.getItem('lang')||'de';
|
||
currentLang=l;T=l==='de'?LANG_DE:LANG_EN;
|
||
document.getElementById('lang-btn').textContent=l==='de'?'EN':'DE';
|
||
document.documentElement.setAttribute('lang',l);
|
||
// defer until DOM ready
|
||
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 ──
|
||
function showPanel(id){
|
||
document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
|
||
document.getElementById('panel-'+id).classList.add('active');
|
||
document.querySelectorAll('.nav-btn,.bnav-btn').forEach(b=>b.classList.remove('active'));
|
||
var nb=document.getElementById('nb-'+id);if(nb)nb.classList.add('active');
|
||
var bnb=document.getElementById('bnb-'+id);if(bnb)bnb.classList.add('active');
|
||
currentPanel=id;
|
||
}
|
||
|
||
// ── Console log ──
|
||
var consoleLogs=[];
|
||
var logAutoScroll=true;
|
||
var logBadgeCount=0;
|
||
var logDirFilter='all'; // 'all'|'rx'|'tx'
|
||
var logTopicFilter=''; // '' = no topic filter
|
||
|
||
function clog(msg,cls){
|
||
cls=cls||'msg-info';
|
||
var ts=new Date().toLocaleTimeString('de',{hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||
_appendLog({ts:ts,lvl:'',name:'ui',msg:msg},cls);
|
||
}
|
||
function _lvlCls(lvl){
|
||
if(lvl==='ERROR'||lvl==='CRITICAL')return'msg-err';
|
||
if(lvl==='WARNING')return'msg-warn';
|
||
if(lvl==='DEBUG')return'msg-info';
|
||
return'msg-ok';
|
||
}
|
||
function _appendLog(entry,forceCls){
|
||
var cls=forceCls||_lvlCls(entry.lvl);
|
||
var label=entry.name?'['+entry.name+'] ':'';
|
||
consoleLogs.push({ts:entry.ts,msg:label+entry.msg,cls:cls});
|
||
if(consoleLogs.length>500)consoleLogs.shift();
|
||
// Badge wenn Tab nicht aktiv und Fehler/Warnungen
|
||
if(currentPanel!=='console'&&(cls==='msg-err'||cls==='msg-warn')){
|
||
logBadgeCount++;
|
||
var bc=logBadgeCount>99?'99+':logBadgeCount;
|
||
['log-badge','log-badge-bot'].forEach(function(id){var b=document.getElementById(id);if(b){b.style.display='inline';b.textContent=bc;}});
|
||
}
|
||
renderLog();
|
||
}
|
||
function setLogDir(dir){
|
||
logDirFilter=dir;
|
||
document.querySelectorAll('.log-dir-btn').forEach(function(b){
|
||
b.style.background=b.id==='logdir-'+dir?'var(--accent)':'var(--raised)';
|
||
b.style.color=b.id==='logdir-'+dir?'#fff':'var(--txt2)';
|
||
});
|
||
renderLog();
|
||
}
|
||
function setLogTopic(topic){
|
||
var inp=document.getElementById('log-filter');
|
||
var active=inp.value===topic;
|
||
inp.value=active?'':topic;
|
||
document.querySelectorAll('.log-topic-btn').forEach(function(b){
|
||
var on=!active&&b.getAttribute('data-topic')===topic;
|
||
b.style.background=on?'var(--accent)':'var(--raised)';
|
||
b.style.color=on?'#fff':'var(--txt2)';
|
||
});
|
||
renderLog();
|
||
}
|
||
function renderLog(){
|
||
var el=document.getElementById('console-log');
|
||
if(!el)return;
|
||
var filter=(document.getElementById('log-filter')||{}).value||'';
|
||
var fl=filter.toLowerCase();
|
||
var rows=consoleLogs.filter(function(l){
|
||
var m=l.msg;
|
||
if(logDirFilter==='rx'&&!/ RX[ (]/.test(m))return false;
|
||
if(logDirFilter==='tx'&&!/ TX[ (]/.test(m))return false;
|
||
if(fl&&!m.toLowerCase().includes(fl))return false;
|
||
return true;
|
||
});
|
||
var savedScroll=logAutoScroll?null:el.scrollTop;
|
||
el.innerHTML=rows.map(l=>`<div><span class="ts">${l.ts}</span><span class="${l.cls}">${escHtml(l.msg)}</span></div>`).join('');
|
||
if(logAutoScroll)el.scrollTop=el.scrollHeight;
|
||
else if(savedScroll!==null)el.scrollTop=savedScroll;
|
||
}
|
||
function onLogScroll(){
|
||
var el=document.getElementById('console-log');
|
||
if(!el)return;
|
||
var atBottom=el.scrollHeight-el.scrollTop-el.clientHeight<30;
|
||
if(!atBottom&&logAutoScroll){setAutoScroll(false);}
|
||
}
|
||
function toggleAutoScroll(){
|
||
setAutoScroll(!logAutoScroll);
|
||
if(logAutoScroll){var el=document.getElementById('console-log');if(el)el.scrollTop=el.scrollHeight;}
|
||
}
|
||
function setAutoScroll(on){
|
||
logAutoScroll=on;
|
||
var btn=document.getElementById('btn-autoscroll');
|
||
if(btn){btn.style.background=on?'var(--accent)':'var(--raised)';btn.style.color=on?'#fff':'var(--txt2)';}
|
||
}
|
||
function clearLogBadge(){
|
||
logBadgeCount=0;
|
||
['log-badge','log-badge-bot'].forEach(function(id){var b=document.getElementById(id);if(b)b.style.display='none';});
|
||
}
|
||
function escHtml(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||
// SSE server-log stream
|
||
(function(){
|
||
function connect(){
|
||
var es=new EventSource('/api/log/stream');
|
||
es.onmessage=function(e){try{_appendLog(JSON.parse(e.data));}catch(_){}};
|
||
es.onerror=function(){es.close();setTimeout(connect,3000);};
|
||
}
|
||
window.addEventListener('DOMContentLoaded',connect);
|
||
})();
|
||
|
||
// ── Helpers ──
|
||
function fmtTime(s){if(!s||s<0)return'–';var m=Math.floor(s/60),h=Math.floor(m/60);m%=60;return h>0?h+'h '+m+'m':m+'m'}
|
||
function post(url,body){return fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})}
|
||
function clamp(v,lo,hi){return Math.min(hi,Math.max(lo,v))}
|
||
|
||
// ── Apply state to DOM ──
|
||
function applyState(){
|
||
var s=S;
|
||
// connection error banner
|
||
var banner=document.getElementById('conn-error-banner');
|
||
if(banner){if(s.connection_error){banner.textContent='⚠ '+(T.lbl_conn_error||'Connection error:')+' '+s.connection_error;banner.style.display='block';}else{banner.style.display='none';}}
|
||
var frb=document.getElementById('file-ready-banner');
|
||
if(frb){
|
||
if(s.file_ready&&s.print_state==='standby'){
|
||
document.getElementById('file-ready-name').textContent=s.file_ready;
|
||
frb.style.display='flex';
|
||
}else{frb.style.display='none';}
|
||
}
|
||
// header
|
||
var b=document.getElementById('h-badge');
|
||
b.className='hbadge '+s.print_state;
|
||
document.getElementById('h-state').textContent=T['kobra_'+s.kobra_state]||s.kobra_state||T.header_status_standby;
|
||
document.getElementById('h-pname').textContent=s.printer_name;
|
||
var hv=document.getElementById('h-version');if(hv&&s.version)hv.textContent='v'+s.version;
|
||
|
||
|
||
// temps
|
||
var nt=document.getElementById('d-nt');if(nt)nt.textContent=s.nozzle_temp.toFixed(1);
|
||
var ntt=document.getElementById('d-nt-t');if(ntt)ntt.textContent=s.nozzle_target.toFixed(0);
|
||
var bt=document.getElementById('d-bt');if(bt)bt.textContent=s.bed_temp.toFixed(1);
|
||
var btt=document.getElementById('d-bt-t');if(btt)btt.textContent=s.bed_target.toFixed(0);
|
||
|
||
// temp bars (dashboard)
|
||
var nb=document.getElementById('d-ntbar');if(nb)nb.style.width=clamp(s.nozzle_temp/300*100,0,100)+'%';
|
||
var bb=document.getElementById('d-btbar');if(bb)bb.style.width=clamp(s.bed_temp/120*100,0,100)+'%';
|
||
|
||
// progress
|
||
var pct=Math.round(s.progress*100);
|
||
var dpct=document.getElementById('d-pct');if(dpct)dpct.textContent=pct;
|
||
var dpbar=document.getElementById('d-pbar');if(dpbar)dpbar.style.width=pct+'%';
|
||
|
||
var layers=s.curr_layer&&s.total_layers?'L '+s.curr_layer+' / '+s.total_layers:'–';
|
||
var dlayers=document.getElementById('d-layers');if(dlayers)dlayers.textContent=layers;
|
||
|
||
var delapsed=document.getElementById('d-elapsed');if(delapsed)delapsed.textContent=fmtTime(s.print_duration);
|
||
var dremain=document.getElementById('d-remain');if(dremain)dremain.textContent=s.remain_time>0?fmtTime(s.remain_time):'–';
|
||
var dslrow=document.getElementById('d-slicer-row');
|
||
var dsltime=document.getElementById('d-slicer-time');
|
||
if(dslrow&&dsltime){
|
||
if(s.slicer_time>0){dslrow.style.display='';dsltime.textContent=fmtTime(s.slicer_time);}
|
||
else{dslrow.style.display='none';}
|
||
}
|
||
|
||
var fn=s.filename||'–';
|
||
var dfname=document.getElementById('d-fname');if(dfname){dfname.textContent=fn;dfname.title=fn};
|
||
var pfname=document.getElementById('p-fname');if(pfname){pfname.textContent=fn;pfname.title=fn};
|
||
var cfo=document.getElementById('cam-fname');if(cfo)cfo.textContent=fn!=='–'?fn:'';
|
||
|
||
// thumbnail
|
||
var thumb=document.getElementById('d-thumbnail');
|
||
if(thumb){
|
||
if(s.thumbnail){
|
||
thumb.src='data:image/png;base64,'+s.thumbnail;
|
||
thumb.style.display='block';
|
||
} else {
|
||
thumb.style.display='none';
|
||
thumb.src='';
|
||
}
|
||
}
|
||
|
||
// light/fan sync
|
||
document.getElementById('d-light-toggle').checked=s.light_on;
|
||
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){
|
||
window._amsSlots=s.ams_slots;
|
||
var html='';
|
||
s.ams_slots.forEach(function(slot,i){
|
||
var empty=slot.status!==5;
|
||
var rgb=empty?[80,80,80]:(Array.isArray(slot.color)?slot.color:[128,128,128]);
|
||
var col='rgb('+rgb[0]+','+rgb[1]+','+rgb[2]+')';
|
||
var active=slot.status===1||slot.active;
|
||
var pct=empty?T.ams_empty:(slot.consumables_percent!=null?slot.consumables_percent+'%':'–');
|
||
var idx=slot.index!=null?slot.index:i;
|
||
html+='<div class="ams-slot'+(active?' active':'')+(empty?' empty':'')+ '" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+';cursor:pointer" onclick="openSlotEdit('+i+')">'
|
||
+'<div class="slot-circle" style="background:'+col+'"></div>'
|
||
+'<div class="slot-material">'+(empty?'–':(slot.type||slot.material_type||'–'))+'</div>'
|
||
+'<div class="slot-label">Slot '+(idx+1)+'</div>'
|
||
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>'
|
||
+'<div style="font-size:9px;color:var(--txt2);margin-top:2px">✏</div>'
|
||
+'</div>';
|
||
});
|
||
document.getElementById('ams-slots').innerHTML=html;
|
||
}
|
||
|
||
// camera overlay
|
||
var co=document.getElementById('cam-overlay');
|
||
if(co)co.style.display=(s.print_state==='printing'&&camOn)?'block':'none';
|
||
|
||
// auto-start camera during print
|
||
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 ──
|
||
function updateHistory(){
|
||
tempHistory.n.push(S.nozzle_temp);
|
||
tempHistory.b.push(S.bed_temp);
|
||
if(tempHistory.n.length>60)tempHistory.n.shift();
|
||
if(tempHistory.b.length>60)tempHistory.b.shift();
|
||
drawChart('d-chart',tempHistory,[{data:tempHistory.n,color:'#00c8ff',max:300},{data:tempHistory.b,color:'#ff6b35',max:120}]);
|
||
}
|
||
function drawChart(id,_,series){
|
||
var canvas=document.getElementById(id);if(!canvas)return;
|
||
var ctx=canvas.getContext('2d');
|
||
var W=canvas.offsetWidth*window.devicePixelRatio||canvas.width;
|
||
var H=canvas.offsetHeight*window.devicePixelRatio||canvas.height;
|
||
canvas.width=W;canvas.height=H;
|
||
ctx.clearRect(0,0,W,H);
|
||
series.forEach(function(s){
|
||
var data=s.data;if(!data.length)return;
|
||
var max=s.max;
|
||
ctx.beginPath();ctx.strokeStyle=s.color;ctx.lineWidth=2;ctx.lineJoin='round';
|
||
data.forEach(function(v,i){
|
||
var x=i/(Math.max(data.length-1,1))*(W-4)+2;
|
||
var y=H-4-(v/max)*(H-8);
|
||
if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);
|
||
});
|
||
ctx.stroke();
|
||
});
|
||
}
|
||
|
||
// ── Settings Modal ──
|
||
var _updateTag='';
|
||
var _updateUrl='';
|
||
function openSettings(){
|
||
fetch('/api/settings').then(function(r){return r.json()}).then(function(d){
|
||
document.getElementById('s-printer-ip').value=d.printer_ip||'';
|
||
document.getElementById('s-mqtt-port').value=d.mqtt_port||9883;
|
||
document.getElementById('s-username').value=d.username||'';
|
||
document.getElementById('s-password').value=d.password||'';
|
||
document.getElementById('s-device-id').value=d.device_id||'';
|
||
document.getElementById('s-mode-id').value=d.mode_id||'';
|
||
document.getElementById('s-default-slot').value=d.default_ams_slot||'auto';
|
||
document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling);
|
||
});
|
||
var v=localStorage.getItem('pollInterval')||'2000';
|
||
document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')});
|
||
var pb=document.getElementById('poll-'+Math.round(parseInt(v)/1000));
|
||
if(pb)pb.classList.add('active');
|
||
document.getElementById('s-version-label').textContent='v'+('__VERSION__'||'?');
|
||
document.getElementById('update-status').textContent='';
|
||
document.getElementById('btn-update-apply').style.display='none';
|
||
var cl=document.getElementById('update-changelog');if(cl)cl.style.display='none';
|
||
_updateTag='';_updateUrl='';
|
||
document.getElementById('settings-modal').classList.add('open');
|
||
}
|
||
function closeSettings(){
|
||
document.getElementById('settings-modal').classList.remove('open');
|
||
}
|
||
|
||
// ── AMS Slot Edit ──
|
||
var _slotEditIndex=-1;
|
||
var _MAT_PRESETS=['PLA','PETG','ABS','ASA','TPU','PA','PC','HIPS'];
|
||
function openSlotEdit(i){
|
||
var slot=(window._amsSlots||[])[i]||{};
|
||
var index=slot.index!=null?slot.index:i;
|
||
_slotEditIndex=index;
|
||
document.getElementById('slot-edit-title').textContent=T.slot_edit_title+' '+(index+1);
|
||
var rgb=Array.isArray(slot.color)?slot.color:[128,128,128];
|
||
var hex='#'+rgb.map(function(v){return('0'+Math.min(255,v).toString(16)).slice(-2)}).join('');
|
||
var ci=document.getElementById('slot-edit-color');
|
||
ci.value=hex;
|
||
document.getElementById('slot-edit-preview').style.background=hex;
|
||
var mat=(slot.type||'PLA').toUpperCase();
|
||
document.getElementById('slot-edit-mat').value=mat;
|
||
var btns=document.getElementById('slot-mat-btns');
|
||
btns.innerHTML=_MAT_PRESETS.map(function(m){
|
||
return '<button class="mat-preset-btn" data-mat="'+m+'" onclick="selectMatPreset(\''+m+'\')" '
|
||
+'style="padding:4px 10px;border-radius:6px;border:1px solid var(--border);cursor:pointer;font-size:12px;'
|
||
+(m===mat?'background:var(--accent);color:#fff':'background:var(--raised);color:var(--txt2)')+'">'+m+'</button>';
|
||
}).join('');
|
||
document.getElementById('slot-edit-modal').classList.add('open');
|
||
}
|
||
function closeSlotEdit(){
|
||
document.getElementById('slot-edit-modal').classList.remove('open');
|
||
}
|
||
function startReadyFile(){
|
||
var btn=document.getElementById('file-ready-btn');
|
||
if(btn){btn.disabled=true;btn.textContent='…';}
|
||
post('/printer/print/start',{filename:S.file_ready})
|
||
.then(function(r){return r.json();})
|
||
.then(function(r){
|
||
document.getElementById('file-ready-banner').style.display='none';
|
||
if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);}
|
||
})
|
||
.catch(function(e){
|
||
clog((T.log_error||'Error:')+' '+e,'msg-err');
|
||
if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);}
|
||
});
|
||
}
|
||
function cancelReadyFile(){
|
||
post('/api/file_ready/clear',{})
|
||
.then(function(){document.getElementById('file-ready-banner').style.display='none';});
|
||
}
|
||
function selectMatPreset(m){
|
||
document.getElementById('slot-edit-mat').value=m;
|
||
highlightMatBtn(m);
|
||
}
|
||
function highlightMatBtn(val){
|
||
document.querySelectorAll('.mat-preset-btn').forEach(function(b){
|
||
var on=b.getAttribute('data-mat')===val.toUpperCase();
|
||
b.style.background=on?'var(--accent)':'var(--raised)';
|
||
b.style.color=on?'#fff':'var(--txt2)';
|
||
});
|
||
}
|
||
function hexToRgb(hex){
|
||
var r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
|
||
return[r,g,b];
|
||
}
|
||
function saveSlotEdit(){
|
||
var hex=document.getElementById('slot-edit-color').value;
|
||
var mat=document.getElementById('slot-edit-mat').value.trim().toUpperCase()||'PLA';
|
||
var color=hexToRgb(hex);
|
||
post('/api/ams/set_slot',{index:_slotEditIndex,type:mat,color:color})
|
||
.then(function(r){return r.json();})
|
||
.then(function(r){
|
||
closeSlotEdit();
|
||
clog((T.slot_edit_ok||'AMS Slot')+' '+(_slotEditIndex+1)+': '+mat+' '+hex,'msg-ok');
|
||
})
|
||
.catch(function(e){clog('Fehler: '+e,'msg-err');});
|
||
}
|
||
document.addEventListener('DOMContentLoaded',function(){
|
||
document.getElementById('s-printer-ip').addEventListener('input',function(){
|
||
var hint=document.getElementById('lbl-ip-hint');
|
||
if(this.value.includes(':')){hint.textContent=T.hint_ip_no_port;hint.style.display='block';}
|
||
else{hint.style.display='none';}
|
||
});
|
||
});
|
||
function setPoll(ms){
|
||
document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')});
|
||
var id='poll-'+Math.round(ms/1000);
|
||
var pb=document.getElementById(id);if(pb)pb.classList.add('active');
|
||
localStorage.setItem('pollInterval',ms);
|
||
clearInterval(pollTimer);
|
||
pollTimer=setInterval(poll,ms);
|
||
}
|
||
function saveSettings(){
|
||
var btn=document.getElementById('btn-save-settings');
|
||
btn.disabled=true;btn.textContent='…';
|
||
post('/api/settings',{
|
||
printer_ip: document.getElementById('s-printer-ip').value,
|
||
mqtt_port: parseInt(document.getElementById('s-mqtt-port').value)||9883,
|
||
username: document.getElementById('s-username').value,
|
||
password: document.getElementById('s-password').value,
|
||
device_id: document.getElementById('s-device-id').value,
|
||
mode_id: document.getElementById('s-mode-id').value,
|
||
default_ams_slot: document.getElementById('s-default-slot').value,
|
||
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
|
||
}).then(function(){
|
||
btn.textContent=T.update_restarting;
|
||
setTimeout(function(){
|
||
btn.disabled=false;
|
||
setText('btn-save-settings',T.settings_save);
|
||
closeSettings();
|
||
poll();
|
||
},4000);
|
||
}).catch(function(e){
|
||
btn.disabled=false;setText('btn-save-settings',T.settings_save);
|
||
clog('Settings-Fehler: '+e,'msg-err');
|
||
});
|
||
}
|
||
function checkUpdate(){
|
||
var sb=document.getElementById('update-status');
|
||
sb.textContent=T.update_checking;
|
||
document.getElementById('btn-update-apply').style.display='none';
|
||
_updateTag='';_updateUrl='';
|
||
fetch('/api/update/check').then(function(r){return r.json()}).then(function(d){
|
||
if(d.error){sb.textContent=T.update_error+': '+d.error;return;}
|
||
var cl=document.getElementById('update-changelog');
|
||
if(d.changelog&&d.changelog.trim()){cl.textContent=d.changelog;cl.style.display='block';}
|
||
else{cl.style.display='none';}
|
||
if(d.update_available){
|
||
sb.textContent='v'+d.latest+' '+T.update_available;
|
||
sb.style.color='var(--ok)';
|
||
_updateTag=d.tag;_updateUrl=d.download_url;
|
||
document.getElementById('btn-update-apply').style.display='inline-block';
|
||
} else {
|
||
sb.textContent=T.update_none;
|
||
sb.style.color='var(--txt2)';
|
||
}
|
||
}).catch(function(e){sb.textContent=T.update_error+': '+e;});
|
||
}
|
||
function applyUpdate(){
|
||
if(!_updateUrl)return;
|
||
var sb=document.getElementById('update-status');
|
||
var btn=document.getElementById('btn-update-apply');
|
||
btn.disabled=true;sb.textContent=T.update_applying;
|
||
post('/api/update/apply',{download_url:_updateUrl,tag:_updateTag}).then(function(){
|
||
sb.textContent=T.update_restarting;
|
||
closeSettings();
|
||
setTimeout(function(){poll();},5000);
|
||
}).catch(function(e){
|
||
btn.disabled=false;sb.textContent=T.update_error+': '+e;
|
||
});
|
||
}
|
||
|
||
// ── Poll ──
|
||
async function poll(){
|
||
try{
|
||
var r=await fetch('/api/state');
|
||
if(!r.ok)return;
|
||
var d=await r.json();
|
||
Object.assign(S,d);
|
||
applyState();
|
||
updateHistory();
|
||
}catch(e){clog('Poll-Fehler: '+e,'msg-err')}
|
||
}
|
||
var pollTimer;
|
||
(function(){
|
||
var ms=parseInt(localStorage.getItem('pollInterval')||'2000');
|
||
poll();pollTimer=setInterval(poll,ms);
|
||
})();
|
||
|
||
// ── Print actions ──
|
||
function printAction(a){
|
||
post('/printer/print/'+a,{}).then(function(){clog('Druck: '+a,'msg-ok');poll()})
|
||
.catch(function(e){clog('Fehler: '+e,'msg-err')});
|
||
}
|
||
function confirmCancel(){if(confirm('Druck wirklich abbrechen?'))printAction('cancel')}
|
||
|
||
// ── Axis motion ──
|
||
// axis codes: 0=X, 1=Y, 2=Z
|
||
// move_type 1=relative, distance positive/negative
|
||
function getStep(){return currentStep}
|
||
function setStep(btn,v){
|
||
currentStep=v;
|
||
document.querySelectorAll('.step-btn').forEach(b=>b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
document.getElementById('step-display').textContent=v;
|
||
}
|
||
function move(axis,dir,dist){
|
||
// axis: 0=X,1=Y,2=Z → printer axis codes: 1=X,2=Y,3=Z
|
||
var axisMap={0:1,1:2,2:3};
|
||
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')})
|
||
.catch(function(e){clog('Achse-Fehler: '+e,'msg-err')});
|
||
}
|
||
function homeAll(){
|
||
post('/api/axis',{axis:5,move_type:2,distance:0})
|
||
.then(function(){clog('Home All','msg-ok')})
|
||
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')});
|
||
}
|
||
function homeXY(){
|
||
post('/api/axis',{axis:4,move_type:2,distance:0})
|
||
.then(function(){clog('Home XY','msg-ok')})
|
||
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')});
|
||
}
|
||
function homeZ(){
|
||
post('/api/axis',{axis:3,move_type:2,distance:0})
|
||
.then(function(){clog('Home Z','msg-ok')})
|
||
.catch(function(e){clog('Home-Fehler: '+e,'msg-err')});
|
||
}
|
||
function disableMotors(){
|
||
post('/api/axis',{action:'turnOff'})
|
||
.then(function(){clog('Motors Off','msg-ok')})
|
||
.catch(function(e){clog('Motors-Fehler: '+e,'msg-err')});
|
||
}
|
||
|
||
// ── Temperature ──
|
||
function setNozzle(){
|
||
var v=parseFloat(document.getElementById('p-nozzle-inp').value||0);
|
||
post('/api/temperature',{nozzle:v,bed:S.bed_target})
|
||
.then(function(){clog('Nozzle → '+v+'°C','msg-ok')})
|
||
.catch(function(e){clog('Temp-Fehler: '+e,'msg-err')});
|
||
}
|
||
function setBed(){
|
||
var v=parseFloat(document.getElementById('p-bed-inp').value||0);
|
||
post('/api/temperature',{nozzle:S.nozzle_target,bed:v})
|
||
.then(function(){clog(T.label_bed+' → '+v+'°C','msg-ok')})
|
||
.catch(function(e){clog('Temp-Fehler: '+e,'msg-err')});
|
||
}
|
||
|
||
// ── Light ──
|
||
function setLight(){
|
||
var on=document.getElementById('d-light-toggle').checked;
|
||
post('/api/light',{on:on,brightness:80})
|
||
.then(function(){clog('Licht '+(on?'an, '+br+'%':'aus'),'msg-ok')})
|
||
.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);
|
||
document.getElementById('d-fan-val').textContent=v;
|
||
post('/api/fan',{speed:v})
|
||
.then(function(){clog('Lüfter → '+v+'%','msg-ok')})
|
||
.catch(function(e){clog('Lüfter-Fehler: '+e,'msg-err')});
|
||
}
|
||
function quickFan(v){
|
||
document.getElementById('d-fan').value=v;
|
||
document.getElementById('d-fan-val').textContent=v;
|
||
post('/api/fan',{speed:v})
|
||
.then(function(){clog('Lüfter → '+v+'%','msg-ok')})
|
||
.catch(function(e){clog('Lüfter-Fehler: '+e,'msg-err')});
|
||
}
|
||
|
||
// ── AMS ──
|
||
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?T.lbl_feed:T.lbl_unload)+' Slot '+(slot+1),'msg-ok')})
|
||
.catch(function(e){clog('AMS-Fehler: '+e,'msg-err')});
|
||
}
|
||
|
||
// ── Camera ──
|
||
function camStart(){
|
||
var img=document.getElementById('cam-img');
|
||
var ph=document.getElementById('cam-placeholder');
|
||
var sp=document.getElementById('cam-spinner');
|
||
ph.style.display='none';
|
||
img.style.display='none';
|
||
sp.style.display='block';
|
||
post('/api/camera/start',{}).then(function(){
|
||
img.onerror=function(){
|
||
sp.style.display='none';
|
||
img.style.display='none';
|
||
ph.style.display='flex';
|
||
camOn=false;
|
||
document.getElementById('cam-toggle-btn').textContent=T.btn_cam_start||'▶ Kamera';
|
||
clog((T.log_error||'Fehler:')+' Stream nicht verfügbar','msg-err');
|
||
};
|
||
img.src='/api/camera/stream?t='+Date.now();
|
||
camOn=true;
|
||
document.getElementById('cam-toggle-btn').textContent=T.btn_cam_stop||'◼ Kamera';
|
||
clog((T.log_cam_start||'Kamera gestartet'),'msg-ok');
|
||
// MJPEG liefert kein onload – Spinner nach kurzem Timeout ausblenden
|
||
setTimeout(function(){
|
||
sp.style.display='none';
|
||
img.style.display='block';
|
||
},1200);
|
||
}).catch(function(e){
|
||
sp.style.display='none';
|
||
ph.style.display='flex';
|
||
clog((T.log_error||'Fehler:')+' '+e,'msg-err');
|
||
});
|
||
}
|
||
function camStop(){
|
||
post('/api/camera/stop',{}).then(function(){
|
||
var img=document.getElementById('cam-img');
|
||
img.src='';
|
||
img.style.display='none';
|
||
document.getElementById('cam-spinner').style.display='none';
|
||
document.getElementById('cam-placeholder').style.display='flex';
|
||
camOn=false;
|
||
document.getElementById('cam-toggle-btn').textContent=T.btn_cam_start||'▶ Kamera';
|
||
clog(T.log_cam_stop||'Kamera gestoppt','msg-ok');
|
||
}).catch(function(e){clog((T.log_error||'Fehler:')+' '+e,'msg-err')});
|
||
}
|
||
function toggleCam(){if(camOn)camStop();else camStart()}
|
||
</script>
|
||
<footer style="text-align:center;padding:12px;font-size:11px;color:var(--txt2);border-top:1px solid var(--border);margin-top:auto">
|
||
© ViewIT 2026
|
||
</footer>
|
||
</body>
|
||
</html>"""
|
||
version = self._read_version()
|
||
html = html.replace("'__VERSION__'", f"'{version}'")
|
||
return web.Response(text=html, content_type="text/html",
|
||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"})
|
||
|
||
async def handle_api_light(self, request):
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
on = bool(body.get("on", True))
|
||
brightness = int(body.get("brightness", self._state["light_brightness"]))
|
||
loop = asyncio.get_event_loop()
|
||
await loop.run_in_executor(None, lambda: self.client.publish(
|
||
"light", "control",
|
||
{"type": 3, "status": 1 if on else 0, "brightness": brightness},
|
||
timeout=0
|
||
))
|
||
self._state["light_on"] = on
|
||
self._state["light_brightness"] = brightness
|
||
return web.json_response({"result": "ok"})
|
||
|
||
async def handle_api_fan(self, request):
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
speed = int(body.get("speed", 0))
|
||
loop = asyncio.get_event_loop()
|
||
await loop.run_in_executor(None, lambda: self.client.publish(
|
||
"fan", "setSpeed", {"fan_speed_pct": speed}, timeout=0
|
||
))
|
||
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_set_slot(self, request):
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
index = int(body.get("index", 0))
|
||
mat = str(body.get("type", "PLA")).upper()
|
||
color = body.get("color", [255, 255, 255])
|
||
if not (isinstance(color, list) and len(color) == 3):
|
||
return web.json_response({"error": "color must be [r,g,b]"}, status=400)
|
||
loop = asyncio.get_event_loop()
|
||
def _send():
|
||
resp = self.client.publish(
|
||
"multiColorBox", "setInfo",
|
||
{"multi_color_box": [{"id": -1, "slots": [{"index": index, "type": mat, "color": color}]}]},
|
||
timeout=5
|
||
)
|
||
log.info(f"setInfo slot={index} type={mat} color={color} → {resp}")
|
||
return resp
|
||
resp = await loop.run_in_executor(None, _send)
|
||
if resp and resp.get("code") == 200:
|
||
# Update cached slot immediately
|
||
for s in self._ams_slots:
|
||
if s.get("index") == index:
|
||
s["type"] = mat
|
||
s["color"] = color
|
||
break
|
||
return web.json_response({"result": "ok"})
|
||
|
||
async def handle_api_ams_feed(self, request):
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
slot_index = int(body.get("slot_index", 0))
|
||
feed_type = int(body.get("type", 1))
|
||
# Ausziehen (type=2): wenn kein Slot explizit gewählt, den zuletzt geladenen nehmen
|
||
if feed_type == 2 and self._ams_loaded_slot >= 0:
|
||
slot_index = self._ams_loaded_slot
|
||
loop = asyncio.get_event_loop()
|
||
def _send():
|
||
resp = self.client.publish(
|
||
"multiColorBox", "feedFilament",
|
||
{"multi_color_box": [{"id": -1, "feed_status": {"slot_index": slot_index, "type": feed_type}}]},
|
||
timeout=5
|
||
)
|
||
log.info(f"feedFilament type={feed_type} slot={slot_index} loaded_slot={self._ams_loaded_slot} → {resp}")
|
||
await loop.run_in_executor(None, _send)
|
||
return web.json_response({"result": "ok"})
|
||
|
||
async def handle_api_axis(self, request):
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
action = body.get("action", "move")
|
||
loop = asyncio.get_event_loop()
|
||
if action == "turnOff":
|
||
await loop.run_in_executor(None, lambda: self.client.publish(
|
||
"axis", "turnOff", None, timeout=0
|
||
))
|
||
else:
|
||
axis = int(body.get("axis", 4))
|
||
move_type = int(body.get("move_type", 2))
|
||
distance = float(body.get("distance", 0))
|
||
await loop.run_in_executor(None, lambda: self.client.publish(
|
||
"axis", "move",
|
||
{"axis": axis, "move_type": move_type, "distance": distance},
|
||
timeout=0
|
||
))
|
||
return web.json_response({"result": "ok"})
|
||
|
||
async def handle_api_temperature(self, request):
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
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_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",
|
||
{"target_nozzle_temp": n, "target_hotbed_temp": b},
|
||
timeout=0
|
||
))
|
||
return web.json_response({"result": "ok"})
|
||
|
||
async def handle_api_camera(self, request):
|
||
return web.json_response({"url": self._state["camera_url"]})
|
||
|
||
async def handle_api_camera_start(self, request):
|
||
loop = asyncio.get_event_loop()
|
||
# Wait for pushStarted confirmation before returning
|
||
result = await loop.run_in_executor(None, lambda: self.client.publish(
|
||
"video", "startCapture", None, timeout=8.0
|
||
))
|
||
state = (result or {}).get("state", "")
|
||
log.info(f"Kamera startCapture: state={state}")
|
||
return web.json_response({"result": "ok", "state": state})
|
||
|
||
async def handle_api_camera_stop(self, request):
|
||
loop = asyncio.get_event_loop()
|
||
await loop.run_in_executor(None, lambda: self.client.publish(
|
||
"video", "stopCapture", None, timeout=0
|
||
))
|
||
return web.json_response({"result": "ok"})
|
||
|
||
async def handle_api_camera_snapshot(self, request):
|
||
"""Einzelner JPEG-Frame aus dem Kamera-Stream – für Obico und andere Snapshot-Clients."""
|
||
url = self._state.get("camera_url", "")
|
||
if not url:
|
||
return web.Response(status=503, text="Keine Kamera-URL bekannt")
|
||
is_rtsp = url.lower().startswith("rtsp://")
|
||
input_args = ["-fflags", "nobuffer", "-flags", "low_delay"]
|
||
if is_rtsp:
|
||
input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
|
||
else:
|
||
input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"]
|
||
try:
|
||
proc = await asyncio.create_subprocess_exec(
|
||
_find_ffmpeg(), "-loglevel", "quiet",
|
||
*input_args, "-i", url,
|
||
"-frames:v", "1", "-f", "mjpeg", "-q:v", "3",
|
||
"pipe:1",
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.DEVNULL,
|
||
)
|
||
jpeg, _ = await asyncio.wait_for(proc.communicate(), timeout=20)
|
||
except asyncio.TimeoutError:
|
||
return web.Response(status=504, text="Snapshot-Timeout")
|
||
except Exception as e:
|
||
return web.Response(status=503, text=str(e))
|
||
if not jpeg:
|
||
return web.Response(status=503, text="Kein Frame empfangen")
|
||
return web.Response(body=jpeg, content_type="image/jpeg",
|
||
headers={"Cache-Control": "no-cache"})
|
||
|
||
async def handle_camera_stream(self, request):
|
||
"""MJPEG proxy: FLV → MJPEG via ffmpeg, served as multipart/x-mixed-replace."""
|
||
url = self._state.get("camera_url", "")
|
||
if not url:
|
||
return web.Response(status=503, text="Keine Kamera-URL bekannt")
|
||
|
||
is_rtsp = url.lower().startswith("rtsp://")
|
||
ffmpeg_input_args = [
|
||
"-fflags", "nobuffer",
|
||
"-flags", "low_delay",
|
||
]
|
||
if is_rtsp:
|
||
ffmpeg_input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
|
||
else:
|
||
ffmpeg_input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"]
|
||
|
||
# ffmpeg erst starten BEVOR der StreamResponse geöffnet wird
|
||
# (damit wir bei Fehler noch eine normale HTTP-Response senden können)
|
||
try:
|
||
proc = await asyncio.create_subprocess_exec(
|
||
_find_ffmpeg(), "-loglevel", "quiet",
|
||
*ffmpeg_input_args,
|
||
"-i", url,
|
||
"-vf", "fps=15,scale=640:-1",
|
||
"-f", "image2pipe",
|
||
"-vcodec", "mjpeg",
|
||
"-q:v", "3",
|
||
"-flush_packets", "1",
|
||
"pipe:1",
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.DEVNULL,
|
||
)
|
||
except (FileNotFoundError, OSError) as e:
|
||
log.warning("Kamera: ffmpeg nicht gefunden – Kamerastream nicht verfügbar")
|
||
return web.Response(status=503, text="ffmpeg not found")
|
||
except Exception as e:
|
||
log.warning(f"Kamera: ffmpeg konnte nicht gestartet werden: {e}")
|
||
return web.Response(status=503, text=str(e))
|
||
|
||
boundary = "kobraxframe"
|
||
resp = web.StreamResponse(headers={
|
||
"Content-Type": f"multipart/x-mixed-replace;boundary={boundary}",
|
||
"Cache-Control": "no-cache",
|
||
"Connection": "keep-alive",
|
||
})
|
||
await resp.prepare(request)
|
||
|
||
buf = b""
|
||
try:
|
||
while True:
|
||
chunk = await proc.stdout.read(65536)
|
||
if not chunk:
|
||
break
|
||
buf += chunk
|
||
# Extract complete JPEG frames (SOI=FFD8, EOI=FFD9)
|
||
while True:
|
||
start = buf.find(b"\xff\xd8")
|
||
if start == -1:
|
||
buf = b""
|
||
break
|
||
end = buf.find(b"\xff\xd9", start + 2)
|
||
if end == -1:
|
||
buf = buf[start:]
|
||
break
|
||
frame = buf[start:end + 2]
|
||
buf = buf[end + 2:]
|
||
header = (
|
||
f"--{boundary}\r\n"
|
||
f"Content-Type: image/jpeg\r\n"
|
||
f"Content-Length: {len(frame)}\r\n\r\n"
|
||
).encode()
|
||
try:
|
||
await resp.write(header + frame + b"\r\n")
|
||
except Exception:
|
||
return resp
|
||
except Exception as e:
|
||
log.warning(f"Kamera-Stream unterbrochen: {e}")
|
||
finally:
|
||
try:
|
||
proc.kill()
|
||
except Exception:
|
||
pass
|
||
|
||
return resp
|
||
|
||
async def handle_serve_file(self, request):
|
||
"""Liefert hochgeladene G-Code-Dateien vom Temp-Verzeichnis (für Drucker-Download)."""
|
||
filename = os.path.basename(request.match_info.get("filename", ""))
|
||
serve_path = os.path.join(self._serve_dir_path, filename)
|
||
if not os.path.isfile(serve_path):
|
||
return web.Response(status=404, text="not found")
|
||
size = os.path.getsize(serve_path)
|
||
log.info(f"Drucker lädt Datei ab: {filename} ({size} bytes)")
|
||
return web.FileResponse(serve_path, headers={
|
||
"Content-Disposition": f'attachment; filename="{filename}"'
|
||
})
|
||
|
||
async def handle_api_state(self, request):
|
||
s = self._state
|
||
return web.json_response({
|
||
"printer_name": s["printer_name"],
|
||
"firmware_version": s["firmware_version"],
|
||
"print_state": s["print_state"],
|
||
"kobra_state": s["kobra_state"],
|
||
"nozzle_temp": s["nozzle_temp"],
|
||
"nozzle_target": s["nozzle_target"],
|
||
"bed_temp": s["bed_temp"],
|
||
"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"],
|
||
"slicer_time": s["slicer_time"],
|
||
"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,
|
||
"ams_loaded_slot": self._ams_loaded_slot,
|
||
"thumbnail": self._thumbnail_b64,
|
||
"connection_error": s["connection_error"],
|
||
"file_ready": s["file_ready"],
|
||
"version": self._read_version(),
|
||
})
|
||
|
||
async def handle_moonraker_database(self, request):
|
||
"""OrcaSlicer Filament-Sync: /server/database/item?namespace=lane_data&key=lanes (AFC-Format)"""
|
||
namespace = request.rel_url.query.get("namespace", "")
|
||
key = request.rel_url.query.get("key", "")
|
||
|
||
if namespace == "lane_data":
|
||
await asyncio.get_event_loop().run_in_executor(None, self._get_ams_slots_fresh)
|
||
lanes = self._build_lane_data()
|
||
log.info(f"AMS-Sync: {len(lanes)} Lanes an OrcaSlicer")
|
||
return web.json_response({
|
||
"result": {
|
||
"namespace": "lane_data",
|
||
"key": key or "lanes",
|
||
"value": lanes,
|
||
}
|
||
})
|
||
|
||
if namespace in ("AFC", "afc-install", "happy_hare"):
|
||
return web.json_response({
|
||
"result": {"namespace": namespace, "key": key, "value": None}
|
||
})
|
||
|
||
return web.json_response(
|
||
{"error": {"code": 404, "message": f"Namespace '{namespace}' not found"}},
|
||
status=404
|
||
)
|
||
|
||
async def handle_database_list(self, request):
|
||
"""OrcaSlicer prüft welche Namespaces vorhanden sind um MMU-Typ zu erkennen."""
|
||
return web.json_response({"result": {"namespaces": ["lane_data"]}})
|
||
|
||
def _get_ams_slots_fresh(self):
|
||
"""Frische Slot-Daten per getInfo holen, Fallback auf gecachte."""
|
||
resp = self.client.publish("multiColorBox", "getInfo", None, timeout=5)
|
||
if resp and resp.get("data"):
|
||
boxes = resp["data"].get("multi_color_box") or []
|
||
if boxes:
|
||
slots = boxes[0].get("slots") or []
|
||
if slots:
|
||
self._ams_slots = slots
|
||
return self._ams_slots
|
||
|
||
# ─── Settings ────────────────────────────────────────────────────────────
|
||
|
||
def _find_config_path(self) -> pathlib.Path:
|
||
"""Gibt den Pfad zur config.ini zurück."""
|
||
if hasattr(env_loader, "find_config_path"):
|
||
return env_loader.find_config_path()
|
||
# Fallback für alten env_loader
|
||
script_dir = pathlib.Path(_BASE)
|
||
for base in (script_dir, script_dir.parent):
|
||
p = base / "config" / "config.ini"
|
||
if p.is_file():
|
||
return p
|
||
return script_dir / "config" / "config.ini"
|
||
|
||
async def handle_api_settings_get(self, request):
|
||
return web.json_response({
|
||
"printer_ip": self._args.printer_ip,
|
||
"mqtt_port": self._args.mqtt_port,
|
||
"username": self._args.username,
|
||
"password": self._args.password,
|
||
"mode_id": self._args.mode_id,
|
||
"device_id": self._args.device_id,
|
||
"default_ams_slot": getattr(self._args, "default_ams_slot", "auto"),
|
||
"auto_leveling": getattr(self._args, "auto_leveling", 1),
|
||
})
|
||
|
||
async def handle_api_settings_post(self, request):
|
||
import configparser
|
||
data = await request.json()
|
||
config_path = self._find_config_path()
|
||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Bestehende config.ini lesen (Kommentare gehen verloren, aber Werte bleiben)
|
||
cfg = configparser.ConfigParser()
|
||
if config_path.is_file():
|
||
cfg.read(config_path, encoding="utf-8")
|
||
|
||
# Sections sicherstellen
|
||
for section in ("connection", "print", "bridge"):
|
||
if not cfg.has_section(section):
|
||
cfg.add_section(section)
|
||
|
||
printer_ip = str(data.get("printer_ip", self._args.printer_ip or "")).split(":")[0]
|
||
cfg.set("connection", "printer_ip", printer_ip)
|
||
cfg.set("connection", "mqtt_port", str(data.get("mqtt_port", self._args.mqtt_port or 9883)))
|
||
cfg.set("connection", "username", str(data.get("username", self._args.username or "")))
|
||
cfg.set("connection", "password", str(data.get("password", self._args.password or "")))
|
||
cfg.set("connection", "mode_id", str(data.get("mode_id", self._args.mode_id or "")))
|
||
cfg.set("connection", "device_id", str(data.get("device_id", self._args.device_id or "")))
|
||
cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto"))))
|
||
cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1))))
|
||
if not cfg.has_option("bridge", "poll_interval"):
|
||
cfg.set("bridge", "poll_interval", "3")
|
||
|
||
with open(config_path, "w", encoding="utf-8") as f:
|
||
f.write("# KX-Bridge Konfigurationsdatei\n\n")
|
||
cfg.write(f)
|
||
log.info(f"Settings gespeichert in {config_path}")
|
||
# Response senden, dann Neustart
|
||
response = web.json_response({"status": "restarting"})
|
||
asyncio.get_event_loop().call_later(0.3, self._restart_bridge)
|
||
return response
|
||
|
||
def _restart_bridge(self):
|
||
log.info("Bridge wird neu gestartet …")
|
||
exe = sys.executable
|
||
# PyInstaller frozen binary: sys.argv[0] == sys.executable → nicht doppelt übergeben
|
||
if getattr(sys, "frozen", False):
|
||
os.execv(exe, [exe])
|
||
else:
|
||
os.execv(exe, [exe] + sys.argv)
|
||
|
||
# ─── Update ──────────────────────────────────────────────────────────────
|
||
|
||
STABLE_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=1"
|
||
DEV_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=10&pre-release=true"
|
||
GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag"
|
||
|
||
def _read_version(self) -> str:
|
||
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(_BASE), pathlib.Path(_BASE).parent):
|
||
p = base / "VERSION"
|
||
if p.is_file():
|
||
p.write_text(version + "\n", encoding="utf-8")
|
||
return
|
||
(pathlib.Path(_BASE) / "VERSION").write_text(version + "\n", encoding="utf-8")
|
||
|
||
@staticmethod
|
||
def _parse_version(v: str) -> "tuple[int, ...]":
|
||
"""'v0.9.1-beta1' → (0, 9, 1) – nur numerische Teile vor dem ersten '-'"""
|
||
v = v.lstrip("v").split("-")[0]
|
||
parts = re.split(r"[.\s]+", v)
|
||
result = []
|
||
for p in parts:
|
||
try:
|
||
result.append(int(p))
|
||
except ValueError:
|
||
break
|
||
return tuple(result) or (0,)
|
||
|
||
async def handle_api_log_stream(self, request):
|
||
"""SSE-Endpoint: sendet Log-Einträge live an den Browser."""
|
||
resp = web.StreamResponse(headers={
|
||
"Content-Type": "text/event-stream",
|
||
"Cache-Control": "no-cache",
|
||
"X-Accel-Buffering": "no",
|
||
})
|
||
await resp.prepare(request)
|
||
# Zuerst Ring-Buffer senden
|
||
for entry in list(_log_buffer):
|
||
data = json.dumps(entry, ensure_ascii=False)
|
||
await resp.write(f"data: {data}\n\n".encode())
|
||
# Dann live streamen
|
||
q: asyncio.Queue = asyncio.Queue()
|
||
_log_sse_queues.append(q)
|
||
try:
|
||
while True:
|
||
entry = await asyncio.wait_for(q.get(), timeout=25)
|
||
data = json.dumps(entry, ensure_ascii=False)
|
||
await resp.write(f"data: {data}\n\n".encode())
|
||
except asyncio.TimeoutError:
|
||
await resp.write(b": keepalive\n\n")
|
||
except (ConnectionResetError, Exception):
|
||
pass
|
||
finally:
|
||
_log_sse_queues.remove(q) if q in _log_sse_queues else None
|
||
return resp
|
||
|
||
async def handle_api_log_download(self, request):
|
||
"""Gibt alle gepufferten Log-Einträge als Plaintext zum Download."""
|
||
lines = [f"[{e['ts']}] {e['lvl']:<5} {e['name']}: {e['msg']}" for e in _log_buffer]
|
||
text = "\n".join(lines)
|
||
return web.Response(
|
||
body=text.encode("utf-8"),
|
||
content_type="text/plain",
|
||
headers={"Content-Disposition": 'attachment; filename="kx-bridge.log"'},
|
||
)
|
||
|
||
async def handle_api_update_check(self, request):
|
||
current = self._read_version()
|
||
is_dev = "-dev+" in current
|
||
api_url = self.DEV_RELEASE_API if is_dev else self.STABLE_RELEASE_API
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.get(api_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||
if resp.status != 200:
|
||
return web.json_response({"error": f"Gitea HTTP {resp.status}"}, status=502)
|
||
releases = await resp.json(content_type=None)
|
||
if not releases:
|
||
return web.json_response({"error": "Keine Releases gefunden"}, status=404)
|
||
# Dev: neuestes Release mit "-dev+" im Tag suchen
|
||
if is_dev:
|
||
dev_releases = [r for r in releases if "-dev+" in r.get("tag_name", "")]
|
||
if not dev_releases:
|
||
return web.json_response({"error": "Keine Dev-Releases gefunden"}, status=404)
|
||
data = dev_releases[0]
|
||
else:
|
||
data = releases[0]
|
||
tag = data.get("tag_name", "")
|
||
latest = tag.lstrip("v")
|
||
if is_dev:
|
||
update_available = tag != f"v{current}"
|
||
else:
|
||
update_available = self._parse_version(tag) > self._parse_version(current)
|
||
download_url = f"{self.GITEA_RAW_BASE}/{tag}/kobrax_moonraker_bridge.py"
|
||
return web.json_response({
|
||
"current": current,
|
||
"latest": latest,
|
||
"update_available": update_available,
|
||
"tag": tag,
|
||
"download_url": download_url,
|
||
"changelog": data.get("body", ""),
|
||
})
|
||
except Exception as e:
|
||
return web.json_response({"error": str(e)}, status=502)
|
||
|
||
async def handle_api_update_apply(self, request):
|
||
data = await request.json()
|
||
download_url = data.get("download_url", "")
|
||
new_tag = data.get("tag", "")
|
||
if not download_url:
|
||
return web.json_response({"error": "download_url fehlt"}, status=400)
|
||
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:
|
||
if resp.status != 200:
|
||
return web.json_response({"error": f"Download HTTP {resp.status}"}, status=502)
|
||
content = await resp.read()
|
||
# Atomisch ersetzen
|
||
tmp = script_path.with_suffix(".py.new")
|
||
tmp.write_bytes(content)
|
||
os.replace(tmp, script_path)
|
||
if new_tag:
|
||
self._write_version(new_tag.lstrip("v"))
|
||
log.info(f"Update auf {new_tag} installiert, starte neu …")
|
||
except Exception as e:
|
||
return web.json_response({"error": str(e)}, status=502)
|
||
response = web.json_response({"status": "updating"})
|
||
asyncio.get_event_loop().call_later(0.3, self._restart_bridge)
|
||
return response
|
||
|
||
async def handle_catchall(self, request):
|
||
body = await request.read()
|
||
log.warning(f"UNBEKANNT {request.method} {request.path_qs} body={body[:200]}")
|
||
return web.json_response({"result": {}}, status=200)
|
||
|
||
async def handle_favicon(self, request):
|
||
# Minimales 1x1 ICO damit der Browser nicht 404 loggt
|
||
ico = bytes([
|
||
0,0,1,0,1,0,1,1,0,0,1,0,24,0,40,0,0,0,22,0,0,0,40,0,0,0,
|
||
1,0,0,0,2,0,0,0,1,0,24,0,0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,
|
||
0,0,0,0,0,0,0,0,255,102,0,0,0,0,0,0
|
||
])
|
||
return web.Response(body=ico, content_type="image/x-icon")
|
||
|
||
# -------------------------------------------------------------------------
|
||
# WebSocket handler
|
||
# -------------------------------------------------------------------------
|
||
|
||
async def handle_websocket(self, request):
|
||
ws = web.WebSocketResponse(heartbeat=30)
|
||
await ws.prepare(request)
|
||
ws._loop = asyncio.get_event_loop()
|
||
self.ws_clients.add(ws)
|
||
log.info(f"WS client verbunden ({len(self.ws_clients)} gesamt)")
|
||
|
||
# Send klippy_ready notification
|
||
await ws.send_str(json.dumps({
|
||
"jsonrpc": "2.0",
|
||
"method": "notify_klippy_ready",
|
||
"params": [],
|
||
}))
|
||
# Send initial status
|
||
await ws.send_str(json.dumps({
|
||
"jsonrpc": "2.0",
|
||
"method": "notify_status_update",
|
||
"params": [self._build_printer_objects(), time.time()],
|
||
}))
|
||
|
||
async for msg in ws:
|
||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||
await self._handle_ws_rpc(ws, msg.data)
|
||
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE):
|
||
break
|
||
|
||
self.ws_clients.discard(ws)
|
||
log.info(f"WS client getrennt ({len(self.ws_clients)} verbleibend)")
|
||
return ws
|
||
|
||
async def _handle_ws_rpc(self, ws: web.WebSocketResponse, raw: str):
|
||
try:
|
||
req = json.loads(raw)
|
||
except Exception:
|
||
return
|
||
rpc_id = req.get("id")
|
||
method = req.get("method", "")
|
||
log.info(f"WS RPC: {method} params={str(req.get('params',''))[:120]}")
|
||
params = req.get("params") or {}
|
||
if isinstance(params, list):
|
||
params = params[0] if params else {}
|
||
|
||
result = None
|
||
error = None
|
||
|
||
try:
|
||
if method in ("printer.info", "printer_info"):
|
||
result = {
|
||
"state": "ready",
|
||
"state_message": "Printer is ready",
|
||
"hostname": "kobrax-bridge",
|
||
"software_version": KLIPPER_VERSION,
|
||
"cpu_info": self._state["printer_name"],
|
||
"klipper_path": "/home/pi/klipper",
|
||
"python_path": "/home/pi/klippy-env/bin/python",
|
||
}
|
||
elif method in ("server.info", "server_info"):
|
||
result = {
|
||
"klippy_connected": True,
|
||
"klippy_state": "ready",
|
||
"moonraker_version": MOONRAKER_VERSION,
|
||
"components": [],
|
||
"failed_components": [],
|
||
"registered_directories": ["gcodes"],
|
||
"warnings": [],
|
||
}
|
||
elif method in ("printer.objects.list",):
|
||
result = {"objects": list(self._build_printer_objects().keys())}
|
||
elif method in ("printer.objects.query", "printer.objects.get"):
|
||
objects = params.get("objects", {})
|
||
all_objs = self._build_printer_objects()
|
||
if objects:
|
||
filtered = {k: all_objs.get(k, {}) for k in objects}
|
||
else:
|
||
filtered = all_objs
|
||
result = {"status": filtered, "eventtime": time.time()}
|
||
elif method == "printer.objects.subscribe":
|
||
objects = params.get("objects", {})
|
||
all_objs = self._build_printer_objects()
|
||
if objects:
|
||
filtered = {k: all_objs.get(k, {}) for k in objects}
|
||
else:
|
||
filtered = all_objs
|
||
result = {"status": filtered, "eventtime": time.time()}
|
||
elif method == "printer.print.start":
|
||
filename = params.get("filename", self._last_uploaded_file)
|
||
loop = asyncio.get_event_loop()
|
||
resp = await loop.run_in_executor(
|
||
None, lambda: self.client.publish("print", "start",
|
||
{"filename": filename, "use_ams": False}, timeout=15.0)
|
||
)
|
||
result = "ok" if resp else "timeout"
|
||
elif method == "printer.print.pause":
|
||
loop = asyncio.get_event_loop()
|
||
await loop.run_in_executor(None, self.client.pause_print)
|
||
result = "ok"
|
||
elif method == "printer.print.resume":
|
||
loop = asyncio.get_event_loop()
|
||
await loop.run_in_executor(None, self.client.resume_print)
|
||
result = "ok"
|
||
elif method == "printer.print.cancel":
|
||
loop = asyncio.get_event_loop()
|
||
await loop.run_in_executor(None, self.client.stop_print)
|
||
result = "ok"
|
||
elif method == "machine.system_info":
|
||
result = {"system_info": {"cpu_info": {"cpu_desc": "Kobra X Bridge"}}}
|
||
elif method == "server.files.list":
|
||
result = []
|
||
else:
|
||
log.debug(f"Unbekannte RPC-Methode: {method}")
|
||
result = {}
|
||
except Exception as e:
|
||
log.error(f"RPC-Fehler für {method}: {e}")
|
||
error = {"code": -32603, "message": str(e)}
|
||
|
||
if rpc_id is not None:
|
||
response = {"jsonrpc": "2.0", "id": rpc_id}
|
||
if error:
|
||
response["error"] = error
|
||
else:
|
||
response["result"] = result
|
||
await ws.send_str(json.dumps(response))
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Poll loop (sync, runs in executor)
|
||
# -------------------------------------------------------------------------
|
||
|
||
def _printer_reachable(self) -> bool:
|
||
"""TCP-Probe auf den MQTT-Port – kein ICMP nötig, kein root erforderlich."""
|
||
import socket as _socket
|
||
try:
|
||
with _socket.create_connection(
|
||
(self._args.printer_ip, self._args.mqtt_port), timeout=2.0
|
||
):
|
||
return True
|
||
except OSError:
|
||
return False
|
||
|
||
def _poll_loop(self, stop_event: threading.Event):
|
||
_offline = self._state["kobra_state"] == "offline"
|
||
_probe_interval = 10.0 # Sekunden zwischen TCP-Probes im Offline-Modus
|
||
|
||
while not stop_event.is_set():
|
||
# ── Offline-Modus: warten bis Drucker wieder erreichbar ──────────
|
||
if _offline:
|
||
if self._printer_reachable():
|
||
log.info("Drucker erreichbar – stelle MQTT-Verbindung her …")
|
||
try:
|
||
self.client.connect()
|
||
_offline = False
|
||
self._state["print_state"] = "standby"
|
||
self._state["kobra_state"] = "free"
|
||
self._state["connection_error"] = ""
|
||
log.info("MQTT-Verbindung wiederhergestellt")
|
||
except Exception as e:
|
||
err = _mqtt_error_msg(e)
|
||
self._state["connection_error"] = err
|
||
log.warning(f"Verbindungsaufbau fehlgeschlagen: {err}")
|
||
stop_event.wait(_probe_interval)
|
||
continue
|
||
else:
|
||
stop_event.wait(_probe_interval)
|
||
continue
|
||
|
||
# ── Online-Modus: normaler Poll ──────────────────────────────────
|
||
try:
|
||
info = self.client.query_info()
|
||
if info:
|
||
self._on_info(info)
|
||
# Während Druck: print/report direkt abfragen
|
||
if self._state["print_state"] in ("printing", "preheating",
|
||
"auto_leveling", "checking", "init"):
|
||
print_r = self.client.publish("print", "query", timeout=3.0)
|
||
if print_r:
|
||
self._on_print(print_r)
|
||
box = self.client.query_multicolor_box()
|
||
if box:
|
||
boxes = (box.get("data") or {}).get("multi_color_box") or []
|
||
slots = boxes[0].get("slots") or [] if boxes else []
|
||
if slots:
|
||
self._ams_slots = slots
|
||
except Exception as e:
|
||
log.warning(f"Poll-Fehler: {e}")
|
||
# Prüfen ob Drucker wirklich weg ist
|
||
if not self._printer_reachable():
|
||
log.info("Drucker nicht erreichbar – wechsle in Offline-Modus")
|
||
self._state["print_state"] = "error"
|
||
self._state["kobra_state"] = "offline"
|
||
self._state["connection_error"] = f"Printer unreachable ({self._args.printer_ip})"
|
||
try:
|
||
self.client.disconnect()
|
||
except Exception:
|
||
pass
|
||
_offline = True
|
||
stop_event.wait(3.0)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# App factory + main
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _mqtt_error_msg(exc: Exception) -> str:
|
||
msg = str(exc)
|
||
if "20020005" in msg:
|
||
return "Wrong MQTT credentials (username, password or device ID incorrect)"
|
||
return msg
|
||
|
||
|
||
def build_app(bridge: KobraXBridge) -> web.Application:
|
||
app = web.Application()
|
||
r = app.router
|
||
|
||
# Moonraker API
|
||
r.add_get("/server/info", bridge.handle_server_info)
|
||
r.add_get("/printer/info", bridge.handle_printer_info)
|
||
r.add_get("/machine/system_info", bridge.handle_machine_system_info)
|
||
r.add_get("/printer/objects/list", bridge.handle_objects_list)
|
||
r.add_get("/printer/objects/query", bridge.handle_objects_query)
|
||
r.add_get("/printer/objects/subscribe", bridge.handle_objects_subscribe)
|
||
r.add_post("/printer/objects/subscribe", bridge.handle_objects_subscribe)
|
||
r.add_get("/server/files/list", bridge.handle_files_list)
|
||
r.add_post("/server/files/upload", bridge.handle_file_upload)
|
||
r.add_post("/printer/print/start", bridge.handle_print_start)
|
||
r.add_post("/printer/print/pause", bridge.handle_print_pause)
|
||
r.add_post("/printer/print/resume", bridge.handle_print_resume)
|
||
r.add_post("/printer/print/cancel", bridge.handle_print_cancel)
|
||
|
||
# OctoPrint compatibility (OrcaSlicer probes this + uploads here)
|
||
r.add_get("/api/version", bridge.handle_octoprint_version)
|
||
r.add_post("/api/files/local", bridge.handle_file_upload)
|
||
r.add_post("/api/files/{path:.*}", bridge.handle_file_upload)
|
||
|
||
# Moonraker database (OrcaSlicer AMS-Sync)
|
||
r.add_get("/server/database/item", bridge.handle_moonraker_database)
|
||
r.add_get("/server/database/list", bridge.handle_database_list)
|
||
|
||
# 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/ams/set_slot", bridge.handle_api_ams_set_slot)
|
||
r.add_post("/api/axis", bridge.handle_api_axis)
|
||
r.add_post("/api/temperature", bridge.handle_api_temperature)
|
||
r.add_get("/api/camera", bridge.handle_api_camera)
|
||
r.add_get("/api/camera/stream", bridge.handle_camera_stream)
|
||
r.add_get("/api/camera/snapshot", bridge.handle_api_camera_snapshot)
|
||
r.add_post("/api/camera/start", bridge.handle_api_camera_start)
|
||
r.add_post("/api/camera/stop", bridge.handle_api_camera_stop)
|
||
r.add_get("/api/state", bridge.handle_api_state)
|
||
r.add_get("/api/settings", bridge.handle_api_settings_get)
|
||
r.add_post("/api/settings", bridge.handle_api_settings_post)
|
||
r.add_get("/api/update/check", bridge.handle_api_update_check)
|
||
r.add_post("/api/update/apply", bridge.handle_api_update_apply)
|
||
r.add_post("/api/file_ready/clear", bridge.handle_api_file_ready_clear)
|
||
r.add_get("/api/log/stream", bridge.handle_api_log_stream)
|
||
r.add_get("/api/log/download", bridge.handle_api_log_download)
|
||
r.add_get("/serve/{filename}", bridge.handle_serve_file)
|
||
|
||
# Root + favicon (OrcaSlicer öffnet / in eingebettetem Browser)
|
||
r.add_get("/", bridge.handle_index)
|
||
r.add_get("/favicon.ico", bridge.handle_favicon)
|
||
|
||
# WebSocket
|
||
r.add_get("/websocket", bridge.handle_websocket)
|
||
|
||
# Catch-all: alle unbekannten Requests loggen statt 404
|
||
r.add_route("*", "/{path:.*}", bridge.handle_catchall)
|
||
|
||
return app
|
||
|
||
|
||
async def run_bridge(args):
|
||
client = KobraXClient(
|
||
host=args.printer_ip,
|
||
port=args.mqtt_port,
|
||
username=args.username,
|
||
password=args.password,
|
||
mode_id=args.mode_id,
|
||
device_id=args.device_id,
|
||
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")
|
||
except Exception as e:
|
||
err = _mqtt_error_msg(e)
|
||
log.warning(f"Verbindung fehlgeschlagen: {err} – starte im Offline-Modus")
|
||
bridge._state["print_state"] = "error"
|
||
bridge._state["kobra_state"] = "offline"
|
||
bridge._state["connection_error"] = err
|
||
app = build_app(bridge)
|
||
|
||
stop_event = threading.Event()
|
||
poll_thread = threading.Thread(
|
||
target=bridge._poll_loop, args=(stop_event,), daemon=True, name="poll"
|
||
)
|
||
poll_thread.start()
|
||
|
||
runner = web.AppRunner(app)
|
||
await runner.setup()
|
||
site = web.TCPSite(runner, args.host, args.port)
|
||
await site.start()
|
||
|
||
import socket as _socket
|
||
try:
|
||
with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as _s:
|
||
_s.connect(("8.8.8.8", 80))
|
||
_local_ip = _s.getsockname()[0]
|
||
except Exception:
|
||
_local_ip = args.host
|
||
log.info(f"Bridge läuft auf http://{_local_ip}:{args.port}")
|
||
log.info(f"OrcaSlicer → Klipper → Host: {_local_ip} Port: {args.port}")
|
||
|
||
log.info("Ctrl-C zum Beenden")
|
||
|
||
try:
|
||
while True:
|
||
await asyncio.sleep(3600)
|
||
except (KeyboardInterrupt, asyncio.CancelledError):
|
||
pass
|
||
finally:
|
||
stop_event.set()
|
||
await runner.cleanup()
|
||
client.disconnect()
|
||
log.info("Bridge beendet")
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Moonraker-Bridge für Anycubic Kobra X")
|
||
parser.add_argument("--printer-ip", default=env_loader.PRINTER_IP,
|
||
help="IP-Adresse des Druckers")
|
||
parser.add_argument("--mqtt-port", type=int, default=env_loader.MQTT_PORT)
|
||
parser.add_argument("--username", default=env_loader.USERNAME)
|
||
parser.add_argument("--password", default=env_loader.PASSWORD)
|
||
parser.add_argument("--mode-id", default=env_loader.MODE_ID)
|
||
parser.add_argument("--device-id", default=env_loader.DEVICE_ID)
|
||
parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT)
|
||
parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING)
|
||
|
||
parser.add_argument("--host", default="0.0.0.0",
|
||
help="Bind-Adresse für den Bridge-Server")
|
||
parser.add_argument("--port", type=int, default=7125,
|
||
help="HTTP/WS-Port (Moonraker-Standard: 7125)")
|
||
args = parser.parse_args()
|
||
if args.printer_ip and ":" in args.printer_ip:
|
||
args.printer_ip = args.printer_ip.split(":")[0]
|
||
|
||
# Windows braucht ProactorEventLoop für asyncio.create_subprocess_exec
|
||
if sys.platform == "win32":
|
||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||
|
||
asyncio.run(run_bridge(args))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|