From f76c059fca26875a70282e4fb1778d748d48b99f Mon Sep 17 00:00:00 2001 From: viewit Date: Tue, 4 Aug 2026 20:25:25 +0200 Subject: [PATCH] refactor(bridge): extract EndpointsMixin (stage 3, mixin 5/6) --- bridge_endpoints.py | 2621 ++++++++++++++++++++++++++++++++++++ kobrax_moonraker_bridge.py | 2566 +---------------------------------- 2 files changed, 2625 insertions(+), 2562 deletions(-) create mode 100644 bridge_endpoints.py diff --git a/bridge_endpoints.py b/bridge_endpoints.py new file mode 100644 index 0000000..0fe9fee --- /dev/null +++ b/bridge_endpoints.py @@ -0,0 +1,2621 @@ +""" +bridge_endpoints.py - EndpointsMixin for KobraXBridge. + +The bridge's own HTTP surface: KX file/profile/print/skip endpoints, printer +management (add/remove/power/restart) + version/update, file upload + print +start, camera endpoints, printer-control API (light/fan/axis/temp/ams/ace), +Moonraker DB/state, settings, log stream/download, UI/index serving, and the +gcode-script + catchall/favicon handlers. Mixed into KobraXBridge; relies on +the shared bridge state and the _json_cors/_push_status_update/_build_* spine +provided by the core class. + +──────────────────────────────────────────────────────────────────────────── +Copyright (C) 2026 viewit (KX-Bridge contributors) + +Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md. +""" + +import os +import re +import sys +import json +import copy +import time +import html +import hashlib +import pathlib +import asyncio +import logging +import subprocess +from urllib.parse import quote + +try: + import config_loader as env_loader +except ImportError: + import env_loader + +import aiohttp +from aiohttp import web + +from gcode_meta import ( + _parse_gcode_estimated_time, + _parse_gcode_layer_heights, + _extract_thumbnail, + _extract_filament_info, +) +from credentials import _kx_fetch_credentials +from camera import _find_ffmpeg +from bridge_logging import _set_verbose_http_log, _log_buffer, _log_sse_queues + +log = logging.getLogger("bridge") + +# Base paths (same logic as the main module): next to sys.executable in a +# PyInstaller binary, otherwise next to this file; web assets under _MEIPASS +# in a onefile binary. +_BASE = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os.path.dirname(os.path.abspath(__file__)) +_WEB_BASE = getattr(sys, "_MEIPASS", _BASE) + +# Web UI: subdirectory under web/themes//index.html +_UI_THEME_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$") +# Allowed static theme files under /kx/ui/ +_KX_UI_ASSETS: dict[str, str] = { + "style.css": "text/css", + "app.js": "application/javascript", +} +# Files from lib/ are served based on their extension (no whitelist entry needed) +_KX_UI_LIB_TYPES: dict[str, str] = { + ".js": "application/javascript", + ".css": "text/css", +} +_KX_UI_TRANSLATION_RE = re.compile(r"^translations/([a-z]{2}(?:-[a-z]{2})?)\.json$") + + +class EndpointsMixin: + async def handle_kx_options(self, request): + return web.Response(status=204, headers=self._CORS) + + async def handle_kx_files(self, request): + files = self._store.list_files() + # Backfill legacy entries without stored filament metadata + # so the dialog's left side shows GCode colors instead of AMS slots. + for f in files: + needs_refresh = not f.get("gcode_filaments") + if not needs_refresh: + try: + cached = f.get("gcode_filaments") + parsed_cached = cached if isinstance(cached, list) else json.loads(cached) + needs_refresh = any("is_used" not in item for item in (parsed_cached or [])) + except Exception: + needs_refresh = True + if not needs_refresh: + continue + path = f.get("path") or "" + if not path or not os.path.isfile(path): + continue + try: + with open(path, "rb") as fh: + parsed_filaments = _extract_filament_info(fh.read()) + if parsed_filaments: + f["gcode_filaments"] = json.dumps(parsed_filaments) + self._store.update_file_filaments(f["id"], parsed_filaments) + except Exception as e: + log.debug(f"Filament metadata backfill failed for {f.get('filename')}: {e}") + # Add last job status + duration per file + jobs = self._store.list_jobs(limit=500) + last_job: dict = {} + for j in reversed(jobs): + last_job[j["gcode_file_id"]] = j + for f in files: + f["web_unverified"] = bool(f.get("web_unverified")) + lj = last_job.get(f["id"]) + f["last_print_status"] = lj["status"] if lj else None + f["last_print_duration"] = lj["duration_sec"] if lj else None + f["last_print_at"] = lj["started_at"] if lj else None + return self._json_cors({"result": files}) + + async def handle_kx_file_delete(self, request): + file_id = request.match_info["file_id"] + if self._store.delete_file(file_id): + return self._json_cors({"result": "ok"}) + return self._json_cors({"error": "not found"}, status=404) + + async def handle_kx_printer_files(self, request): + """GET /kx/printer-files - lists files on the printer's OWN internal + storage (file/listLocal MQTT action), as opposed to /kx/files which + lists what the bridge itself has stored. Needed because prints + started directly from Anycubic Slicer Next (bypassing the bridge) + leave files on the printer that were previously only visible/ + deletable from the printer's own display (Issue #102 context).""" + loop = asyncio.get_event_loop() + def _fetch(): + return self._wait_for_file_action( + "listLocal", + lambda: self.client.publish( + "file", "listLocal", + {"page_num": 1, "page_size": 200, "path": "/"}, + timeout=0, + ), + timeout=8.0, + ) + result = await loop.run_in_executor(None, _fetch) + if not result or result.get("code") != 200: + return self._json_cors({"error": "printer unreachable or query failed"}, status=502) + records = (result.get("data") or {}).get("records") or [] + files = [r for r in records if not r.get("is_dir")] + return self._json_cors({"result": files}) + + async def handle_kx_printer_file_delete(self, request): + """POST /kx/printer-files/delete - body: {"filenames": ["a.gcode", ...]}. + Single endpoint for both single and multi-select delete - the + printer's file/deleteBatch MQTT action natively accepts a list.""" + try: + body = await request.json() + except Exception: + body = {} + filenames = body.get("filenames") or [] + if not filenames: + return self._json_cors({"error": "no filenames given"}, status=400) + files = [{"path": "/", "filename": fn} for fn in filenames if fn] + loop = asyncio.get_event_loop() + def _delete(): + return self._wait_for_file_action( + "deleteBatch", + lambda: self.client.publish( + "file", "deleteBatch", + {"root": "local", "files": files}, + timeout=0, + ), + timeout=8.0, + ) + result = await loop.run_in_executor(None, _delete) + if not result or result.get("state") != "success": + return self._json_cors({"error": "delete failed", "detail": result}, status=502) + return self._json_cors({"result": "ok"}) + + async def handle_kx_printer_file_thumbnail(self, request): + """GET /kx/printer-files/{filename}/thumbnail - fetches the embedded + GCode thumbnail for a file on the printer's own storage, via + file/fileDetails. The printer extracts and base64-encodes the + "; thumbnail begin"-block from the GCode header on demand and + returns it inline in data.file_details.thumbnail - no separate + download/presigned-URL step needed (verified live against a real + Kobra X). Cached in-memory per filename since a file's thumbnail + never changes while it exists on the printer, and re-querying on + every render/scroll would mean one MQTT roundtrip per visible card.""" + filename = request.match_info.get("filename", "") + if not filename: + return self._json_cors({"error": "no filename given"}, status=400) + cached = self._printer_thumbnail_cache.get(filename) + if cached is not None: + return self._json_cors({"result": {"thumbnail": cached}}) + loop = asyncio.get_event_loop() + def _fetch(): + return self._wait_for_file_action( + "fileDetails", + lambda: self.client.publish( + "file", "fileDetails", + {"root": "local", "filename": filename}, + timeout=0, + ), + timeout=8.0, + ) + result = await loop.run_in_executor(None, _fetch) + if not result or result.get("code") != 200: + return self._json_cors({"error": "printer unreachable or query failed"}, status=502) + thumb = ((result.get("data") or {}).get("file_details") or {}).get("thumbnail") or "" + self._printer_thumbnail_cache[filename] = thumb + return self._json_cors({"result": {"thumbnail": thumb}}) + + async def handle_kx_file_download(self, request): + file_id = request.match_info["file_id"] + f = self._store.get_file(file_id) + if not f: + return self._json_cors({"error": "not found"}, status=404) + path = f.get("path") or "" + if not path or not os.path.isfile(path): + return self._json_cors({"error": "not found"}, status=404) + filename = os.path.basename(f.get("filename") or path) + # RFC 5987: filename* with URL encoding for special chars/UTF-8, + # plus ASCII fallback (strip all " and \ from filename for the + # quoted-string-Part). + ascii_fallback = filename.encode("ascii", "replace").decode("ascii").replace('"', "").replace("\\", "") + encoded = quote(filename, safe="") + disposition = f'attachment; filename="{ascii_fallback}"; filename*=UTF-8\'\'{encoded}' + return web.FileResponse(path, headers={"Content-Disposition": disposition}) + + async def handle_kx_file_verify(self, request): + file_id = request.match_info["file_id"] + if self._store.clear_web_unverified(file_id): + return self._json_cors({"result": "ok"}) + return self._json_cors({"error": "not found"}, status=404) + + async def handle_kx_filament_slots(self, request): + slots = [] + for i, s in enumerate(self._ams_slots): + gidx = int(s.get("global_index", i)) + # Stale-profile guard: only show the override while its material + # family matches the loaded AMS material (else slot has no brand). + profile = self._effective_slot_profile(gidx, s.get("type", "")) + slots.append({ + "slot_index": gidx, + "material": s.get("type", ""), + "color_hex": "#{:02X}{:02X}{:02X}".format(*s.get("color", [0,0,0])[:3]), + "status": "loaded" if s.get("status") == 5 else "empty", + "nozzle_temp": 0, + # Current user override from config.ini [filament_profiles] + # - (vendor,name) is unique, id is only a hint. + "filament_id": profile.get("id", ""), + "filament_vendor": profile.get("vendor", ""), + "filament_name": profile.get("name", ""), + }) + return self._json_cors({"result": slots}) + + async def handle_kx_filament_profiles(self, request): + """Returns the static list of OrcaSlicer filament profiles + (from bridge/data/orca_filaments.json - produced by the generator script + tools/gen_orca_filament_list.py erzeugt). + + Optional Filter via ?type=PLA / ?vendor=Polymaker. + The frontend uses this for the slot profile dropdown. + """ + type_filter = request.rel_url.query.get("type", "").upper().strip() + vendor_filter = request.rel_url.query.get("vendor", "").strip() + profiles = self._load_orca_filaments() + if type_filter: + profiles = [p for p in profiles if p.get("type", "").upper() == type_filter] + if vendor_filter: + profiles = [p for p in profiles if p.get("vendor", "") == vendor_filter] + return self._json_cors({"result": profiles}) + + async def handle_kx_filament_profiles_user_list(self, request): + """GET /kx/filament/profiles/user - only the user-imported profiles, + for the settings tab (management with delete buttons).""" + path = self._orca_filaments_user_path() + if not os.path.isfile(path): + return self._json_cors({"result": []}) + try: + with open(path, encoding="utf-8") as f: + user_profiles = json.load(f) or [] + except Exception: + user_profiles = [] + return self._json_cors({"result": user_profiles}) + + async def handle_kx_filament_profiles_import(self, request): + """POST /kx/filament/profiles/user - multipart upload with one + ZIP file or multiple `.json` files from + ~/.config/OrcaSlicer/user//filament/. + + Existing user profiles with the same (vendor, name) key are + overwritten. Parsed profiles use the same schema as + orca_filaments.json (id, name, vendor, type, color).""" + import io, zipfile + from orca_filaments import parse_profile_bytes + added: list[dict] = [] + skipped: int = 0 + # System index for inherits resolution: user profiles reference + # System-Parents via "inherits" (z.B. "Generic PLA @System"). Damit + # we can pull filament_id/vendor/type/color from the system parent + # when the user profile does not set them itself. + sys_idx = [p for p in self._load_orca_filaments() if not p.get("is_user")] + try: + reader = await request.multipart() + except Exception: + return self._json_cors({"error": "expected multipart"}, status=400) + async for part in reader: + if part.name not in ("file", "files", "upload"): + continue + blob = await part.read() + fn = (part.filename or "").lower() + if fn.endswith(".zip"): + try: + with zipfile.ZipFile(io.BytesIO(blob)) as zf: + for inner in zf.namelist(): + if not inner.lower().endswith(".json"): + continue + try: + with zf.open(inner) as zf_in: + p = parse_profile_bytes(zf_in.read(), source_name=inner, system_index=sys_idx) + except Exception: + skipped += 1 + continue + if p: + added.append(p) + else: + skipped += 1 + except zipfile.BadZipFile: + return self._json_cors({"error": "bad zip"}, status=400) + elif fn.endswith(".json"): + p = parse_profile_bytes(blob, source_name=fn, system_index=sys_idx) + if p: + added.append(p) + else: + skipped += 1 + + if not added: + return self._json_cors({"result": "ok", "added": 0, "skipped": skipped}) + + # Merge with existing user JSON (same (vendor,name) -> replace) + path = self._orca_filaments_user_path() + existing: list[dict] = [] + if os.path.isfile(path): + try: + with open(path, encoding="utf-8") as f: + existing = json.load(f) or [] + except Exception: + existing = [] + by_key = {(p.get("vendor"), p.get("name")): p for p in existing} + for p in added: + by_key[(p.get("vendor"), p.get("name"))] = p + merged = sorted(by_key.values(), key=lambda x: (x.get("vendor",""), x.get("name",""))) + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(merged, f, indent=2, ensure_ascii=False) + f.write("\n") + except Exception as e: + return self._json_cors({"error": f"write failed: {e}"}, status=500) + self._invalidate_filaments_cache() + return self._json_cors({"result": "ok", + "added": len(added), + "skipped": skipped, + "total_user": len(merged)}) + + async def handle_kx_filament_profiles_user_delete(self, request): + """DELETE /kx/filament/profiles/user - deletes either a single + entry (?vendor=...&name=...) or all when no query is given.""" + vendor = request.rel_url.query.get("vendor", "").strip() + name = request.rel_url.query.get("name", "").strip() + path = self._orca_filaments_user_path() + if not os.path.isfile(path): + return self._json_cors({"result": "ok", "removed": 0}) + try: + with open(path, encoding="utf-8") as f: + existing = json.load(f) or [] + except Exception: + existing = [] + before = len(existing) + if vendor and name: + existing = [p for p in existing + if not (p.get("vendor") == vendor and p.get("name") == name)] + else: + existing = [] + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(existing, f, indent=2, ensure_ascii=False) + f.write("\n") + except Exception as e: + return self._json_cors({"error": str(e)}, status=500) + self._invalidate_filaments_cache() + return self._json_cors({"result": "ok", + "removed": before - len(existing), + "total_user": len(existing)}) + + def _find_orca_filaments_json(self) -> str | None: + """Finds the static JSON file. Sits next to web/ under _WEB_BASE/data/ + — in allen 3 Deployment-Modi: + • Dev: bridge/data/orca_filaments.json + * Docker: /app/data/orca_filaments.json (static in the image, NOT the + volume data/ holding runtime state - see Dockerfile) + • Onefile: sys._MEIPASS/data/orca_filaments.json + When the volume-mounted /app/data/ shadows the static data, a copy + also sits under _WEB_BASE/data/ (= /app/ in Docker = the same path). + On conflict: second lookup under ../bridge/data/ as a fallback for dev setups.""" + candidates = [ + # Docker: COPY bridge/data -> /app/static/ (data/ is a volume -> shadowed) + os.path.join(_WEB_BASE, "static", "orca_filaments.json"), + os.path.join(_WEB_BASE, "data", "orca_filaments.json"), + ] + here = os.path.dirname(os.path.abspath(__file__)) + candidates.append(os.path.join(here, "data", "orca_filaments.json")) + candidates.append(os.path.join(here, "..", "bridge", "data", "orca_filaments.json")) + for c in candidates: + if os.path.isfile(c): + return c + return None + + async def handle_kx_filament_slot_profile(self, request): + """POST /kx/filament/slots//profile - saves or deletes + a user override mapping for a single AMS slot. + + The primary selector is (vendor, name) - the ID is not unique in the Orca + data model (136 profiles share e.g. 'OGFL99'). The ID is looked up + from orca_filaments.json on save and carried along as a hint + for OrcaSlicer's `tray_info_idx`. + + Body: {"vendor": "Polymaker", "name": "PolyTerra PLA"} + {"vendor": "", "name": ""} → Mapping entfernen + (Backwards compat: {"id":..., "vendor":...} is accepted, + but `name` has been the primary selector since v0.9.18.) + """ + try: + slot_idx = int(request.match_info.get("idx", "-1")) + except ValueError: + return self._json_cors({"error": "bad slot index"}, status=400) + if slot_idx < 0: + return self._json_cors({"error": "bad slot index"}, status=400) + try: + data = await request.json() + except Exception: + data = {} + new_vendor = (data.get("vendor") or "").strip() + new_name = (data.get("name") or "").strip() + new_id = (data.get("id") or "").strip() # Backwards-Kompat-Hint + if new_vendor and new_name: + # Look up the ID from JSON (not from the request body, which could + # be stale or a generic fallback). + looked_up_id = self._lookup_filament_id(new_vendor, new_name) + self._filament_profiles[slot_idx] = { + "vendor": new_vendor, + "name": new_name, + "id": looked_up_id or new_id, + } + else: + self._filament_profiles.pop(slot_idx, None) + # Persistieren in config.ini + try: + import config_loader as _cl + _cl.save_filament_profiles(self._filament_profiles, self._printer_id) + except Exception as e: + log.warning(f"save_filament_profiles failed: {e}") + return self._json_cors({"error": str(e)}, status=500) + entry = self._filament_profiles.get(slot_idx, {}) + return self._json_cors({"result": "ok", + "slot_index": slot_idx, + "vendor": entry.get("vendor", ""), + "name": entry.get("name", ""), + "id": entry.get("id", "")}) + + async def handle_kx_visible_vendors(self, request): + """GET/POST /kx/filament/visible_vendors — Vendor-Sichtbarkeitsfilter + for the slot profile dropdown (Issue #41 option A). + + GET → {"result": ["Polymaker", "eSUN", ...]} + POST {"vendors": [...]} → speichert in config.ini [filament_profiles] + visible_vendors. Empty list = all visible. NO bridge restart + needed (display filter only).""" + if request.method == "POST": + try: + data = await request.json() + except Exception: + data = {} + vendors = data.get("vendors") or [] + if not isinstance(vendors, list): + return self._json_cors({"error": "vendors must be a list"}, status=400) + self._visible_vendors = [str(v).strip() for v in vendors if str(v).strip()] + try: + import config_loader as _cl + _cl.save_visible_vendors(self._visible_vendors, self._printer_id) + except Exception as e: + log.warning(f"save_visible_vendors failed: {e}") + return self._json_cors({"error": str(e)}, status=500) + return self._json_cors({"result": self._visible_vendors}) + + def _load_orca_filaments(self) -> list[dict]: + """Loads system + user profiles from the cache. System profiles come + from bridge/data/orca_filaments.json (image-embedded), user profiles + from /orca_filaments.user.json (volume-persistent - + survives image updates). User profiles get an `is_user: True` + flag so the frontend can mark them.""" + if getattr(self, "_orca_filaments_cache", None) is not None: + return self._orca_filaments_cache + merged: list[dict] = [] + # System + sys_path = self._find_orca_filaments_json() + if sys_path and os.path.isfile(sys_path): + try: + with open(sys_path, encoding="utf-8") as f: + merged.extend(json.load(f) or []) + except Exception as e: + log.warning(f"orca_filaments.json read error: {e}") + # User + usr_path = self._orca_filaments_user_path() + if usr_path and os.path.isfile(usr_path): + try: + with open(usr_path, encoding="utf-8") as f: + for p in (json.load(f) or []): + p["is_user"] = True + merged.append(p) + except Exception as e: + log.warning(f"orca_filaments.user.json read error: {e}") + self._orca_filaments_cache = merged + return self._orca_filaments_cache + + def _orca_filaments_user_path(self) -> str: + """Path to the user profiles JSON. Lives in the volume mount (KX_DATA_DIR) + so image updates do not destroy the data.""" + data_dir = os.environ.get("KX_DATA_DIR") or os.path.join(_WEB_BASE, "data") + os.makedirs(data_dir, exist_ok=True) + return os.path.join(data_dir, "orca_filaments.user.json") + + def _invalidate_filaments_cache(self): + self._orca_filaments_cache = None + + def _lookup_filament_id(self, vendor: str, name: str) -> str: + """Looks up the filament_id for a (vendor,name) tuple in + orca_filaments.json. Returns '' when not found.""" + for p in self._load_orca_filaments(): + if p.get("vendor") == vendor and p.get("name") == name: + return p.get("id", "") + return "" + + async def handle_kx_history(self, request): + limit = int(request.rel_url.query.get("limit", 50)) + offset = int(request.rel_url.query.get("offset", 0)) + jobs = self._store.list_jobs(limit=limit, offset=offset) + return self._json_cors({"result": jobs}) + + async def handle_kx_file_objects(self, request): + """Returns the object list + optional SVG for a file. + + GET /kx/files/{id}/objects → {"names": [...], "svg_b64": "..."} + If the file has no objects yet (old entry): querying file/fileDetails + from the printer and awaiting the response is the frontend's job + (reload after upload). Only return the database state here. + """ + fid = request.match_info.get("id", "") + f = self._store.get_file(fid) + if not f: + return self._json_cors({"error": "file not found"}, status=404) + try: + names = json.loads(f.get("objects_skip_parts") or "[]") + except Exception: + names = [] + # No objects in the store yet (fresh Orca/web upload): actively request + # file/fileDetails from the printer once. _on_file() backfills the store, + # the frontend polls this endpoint and receives the list on the next + # attempt (Issue #57 - skip parity outside the file browser too). + if not names: + fn = f.get("filename") or "" + if fn: + try: + self.client.publish("file", "fileDetails", + {"root": "local", "filename": fn}, timeout=0) + except Exception as e: + log.debug(f"fileDetails request failed: {e}") + return self._json_cors({ + "result": { + "names": names, + "svg_b64": f.get("svg_image") or "", + } + }) + + async def handle_kx_skip(self, request): + """Trigger a mid-print skip. + + POST /kx/skip body={"names": ["..", ".."]} + """ + try: + body = await request.json() + except Exception: + return self._json_cors({"error": "invalid json"}, status=400) + names = body.get("names") or [] + if not isinstance(names, list) or not all(isinstance(n, str) for n in names): + return self._json_cors({"error": "names must be list[str]"}, status=400) + try: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, lambda: self.client.skip_objects(names)) + except Exception as e: + return self._json_cors({"error": str(e)}, status=502) + return self._json_cors({"result": "ok", "names": names}) + + def _build_skip_state_result(self) -> dict: + """Builds the combined skip state for UI endpoints.""" + filename = self._state.get("filename", "") + all_objects: list[str] = [] + svg = "" + if filename: + try: + f = self._store.get_file_by_name(filename) + if f: + all_objects = json.loads(f.get("objects_skip_parts") or "[]") + svg = f.get("svg_image") or "" + except Exception as e: + log.warning(f"skip_state lookup failed: {e}") + return { + "objects": all_objects, + "skipped": list(self._skip_state.get("skipped", [])), + "svg_b64": svg, + "ts": self._skip_state.get("ts", 0), + "filename": filename, + } + + async def handle_kx_skip_query(self, request): + """Re-request the print object list from the printer. + + POST /kx/skip/query → triggert skip/query_obj, wartet kurz auf den + async skip/report and returns the merged skip state. + """ + prev_ts = int(self._skip_state.get("ts", 0) or 0) + try: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, lambda: self.client.query_skip_objects()) + except Exception as e: + return self._json_cors({"error": str(e)}, status=502) + + deadline = time.time() + 1.5 + while time.time() < deadline: + if int(self._skip_state.get("ts", 0) or 0) > prev_ts: + break + await asyncio.sleep(0.1) + + return self._json_cors({"result": self._build_skip_state_result()}) + + async def handle_kx_skip_state(self, request): + """Aktueller Skip-State. + + Kombiniert: + - Full object list: from the GCode store, matched via the currently + running filename (file/report at print start populated the list). + skip/query_obj only returns the already-skipped ones, + not the full list. + - Skipped: from self._skip_state (updated by skip/report). + """ + return self._json_cors({"result": self._build_skip_state_result()}) + + async def handle_kx_printers(self, request): + # Collect active printers (with IP) + active = [(pid, br) for pid, br in self._all_bridges.items() + if (br._args.printer_ip or "").strip()] + # Host for bridge_url: keep the browser view, but never export "localhost" - + # otherwise browser fetches fail when the UI is opened via the LAN IP. + host = request.host.split(":")[0] + if host in ("localhost", "127.0.0.1", "::1", "0.0.0.0"): + host = "" + out = [] + for pid, br in active: + port = getattr(br._args, "port", 7125) + # Only set a concrete bridge_url for multi-printer setups (cross-instance fetch). + # Single printer: empty bridge_url -> JS uses relative paths (same origin as the UI). + bridge_url = "" + if len(active) > 1 and host: + bridge_url = f"http://{host}:{port}" + out.append({ + "id": pid, + "name": br._state.get("printer_name") or f"Drucker {pid}", + "bridge_url": bridge_url, + "printer_ip": br._args.printer_ip, + "device_id": br._args.device_id or "", + "has_power_control": bool( + (getattr(br._args, "power_on_url", "") or "").strip() + or (getattr(br._args, "power_off_url", "") or "").strip() + ), + }) + return self._json_cors({"result": out}) + + async def handle_kx_printer_power(self, request): + """Toggles an external smart plug (e.g. Tasmota) for a printer that + has no MQTT-level power-off/standby command of its own (Issue #103). + + Just fires a plain HTTP GET at the configured power_on_url/power_off_url - + works for Tasmota's cmnd=Power%20on/off style URLs and any other + switch that exposes a GET-triggered on/off endpoint.""" + pid = str(request.match_info.get("pid", "")).strip() + br = self._all_bridges.get(pid) + if br is None: + return self._json_cors({"error": "unknown printer id"}, status=404) + try: + body = await request.json() + except Exception: + body = {} + action = str(body.get("action", "")).lower() + if action not in ("on", "off"): + return self._json_cors({"error": "action must be 'on' or 'off'"}, status=400) + url = getattr(br._args, f"power_{action}_url", "") or "" + if not url: + return self._json_cors({"error": f"no power_{action}_url configured"}, status=400) + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: + ok = resp.status == 200 + except Exception as e: + return self._json_cors({"error": f"power switch unreachable: {e}"}, status=502) + return self._json_cors({"result": "ok" if ok else "error", "status": "on" if action == "on" else "off"}) + + async def handle_kx_printer_power_status(self, request): + """Queries the configured smart plug for its current on/off state. + + Tries to parse a Tasmota-style {"POWER":"ON"/"OFF"} JSON body first, + falls back to a plain substring search for "ON"/"OFF" in the raw + response so other switch firmwares with a simpler status endpoint + still work.""" + pid = str(request.match_info.get("pid", "")).strip() + br = self._all_bridges.get(pid) + if br is None: + return self._json_cors({"error": "unknown printer id"}, status=404) + url = getattr(br._args, "power_status_url", "") or "" + if not url: + return self._json_cors({"error": "no power_status_url configured"}, status=400) + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: + text = await resp.text() + except Exception as e: + return self._json_cors({"error": f"power switch unreachable: {e}"}, status=502) + state = "unknown" + try: + data = json.loads(text) + power = str(data.get("POWER", "")).upper() + if power in ("ON", "OFF"): + state = power.lower() + except Exception: + pass + if state == "unknown": + up = text.upper() + if "ON" in up and "OFF" not in up: + state = "on" + elif "OFF" in up: + state = "off" + return self._json_cors({"state": state}) + + async def handle_kx_print(self, request): + """Print start from the GCode store with optional filament assignments.""" + try: + body = await request.json() + except Exception: + return self._json_cors({"error": "invalid json"}, status=400) + + file_id = body.get("file_id") + if not file_id: + return self._json_cors({"error": "file_id required"}, status=400) + + gcode_file = self._store.get_file(file_id) + if not gcode_file: + return self._json_cors({"error": "file not found"}, status=404) + + # filament_assignments: [{slot_index, material, color_hex}, …] + assignments = body.get("filament_assignments") + # excluded_objects: ["name1","name2",...] – Pre-Print Skip (v0.9.10) + excluded_objects = body.get("excluded_objects") or [] + if not isinstance(excluded_objects, list): + excluded_objects = [] + + if assignments: + ams_box_mapping, unused_count, invalid_count = self._build_assigned_ams_box_mapping(assignments) + if unused_count: + log.debug(f"Skipped {unused_count} unused filament assignment(s) for mode={self._filament_mode}") + if invalid_count: + log.warning(f"Ignored {invalid_count} unusable filament assignment(s) for mode={self._filament_mode}") + if not ams_box_mapping: + return self._json_cors({"error": "no usable filament assignments for current filament mode"}, status=400) + else: + # No dialog -> all occupied slots as with a normal upload print + ams_box_mapping = self._build_auto_ams_box_mapping() + + auto_leveling = int(body.get("auto_leveling", getattr(self._args, "auto_leveling", 1))) + filename = gcode_file["filename"] + file_path = gcode_file["path"] + + # Serve the file via the internal serve endpoint + url = f"http://localhost:{self._args.port}/serve/{os.path.basename(file_path)}" + + payload = self._build_print_payload( + filename, url, "", gcode_file.get("size_bytes", 0), + ams_box_mapping=ams_box_mapping, + auto_leveling=auto_leveling, + excluded_objects=excluded_objects, + ) + self._reset_skip_state(excluded_objects) + + log.info(f"KX store print start: {filename} ams={len(ams_box_mapping)} slots assignments={bool(assignments)} excluded={len(excluded_objects)}") + 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 self._json_cors({"error": "no response from printer"}, status=504) + + if excluded_objects: + loop.run_in_executor(None, lambda: self._apply_preprint_skip_after_start(excluded_objects)) + + # Start the job in the history + self._current_job_id = self._store.start_job( + gcode_file_id=gcode_file["id"], + printer_id=getattr(self._args, "device_id", "unknown"), + filament_assignments=assignments, + ) + self._current_job_filename = filename + + return self._json_cors({"result": "ok", "filename": filename}) + + # ------------------------------------------------------------------------- + # HTTP handlers + # ------------------------------------------------------------------------- + + + 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 + web_upload = 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" + elif part.name == "web_upload": + val = (await part.read()).decode("utf-8", errors="replace").strip().lower() + web_upload = 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) + + # Only allow printable files (Issue #59) - the Kobra X accepts + # only .gcode and .bgcode; .3mf uploads are not processed by the + # printer and are therefore rejected (Issue #59, @gangoke). + _allowed_ext = (".gcode", ".bgcode") + _fn_lower = (remote_filename or "").lower() + if not _fn_lower.endswith(_allowed_ext): + log.warning(f"Upload rejected (not GCode): {remote_filename}") + return web.json_response( + {"error": f"only GCode files allowed ({', '.join(_allowed_ext)})"}, + status=400, + ) + + file_md5 = hashlib.md5(file_data).hexdigest() + file_size = len(file_data) + + # Read slicer time estimate + thumbnail from GCode + est_time = _parse_gcode_estimated_time(file_data) + self._state["slicer_time"] = est_time + thumbnail_b64 = _extract_thumbnail(file_data) + gcode_filaments = _extract_filament_info(file_data) + layer_h, first_h = _parse_gcode_layer_heights(file_data) + self._state["layer_height"] = layer_h + self._state["first_layer_height"] = first_h + + # Persist the file in the GCode store + self._store.save_file( + file_id=file_md5, + filename=remote_filename, + data=file_data, + est_time_sec=est_time, + thumbnail_b64=thumbnail_b64, + gcode_filaments=gcode_filaments or None, + web_unverified=web_upload, + layer_height=layer_h, + first_layer_height=first_h, + ) + serve_path = os.path.join(self._serve_dir_path, os.path.basename(remote_filename)) + del file_data # free RAM + + self._last_uploaded_file = remote_filename + log.info(f"Upload: {remote_filename} ({file_size} bytes) md5={file_md5} -> store + printer") + + # Upload the file to the printer via HTTP (serve_path is already on 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 failed: {e}") + return web.json_response({"error": str(e)}, status=500) + + log.info(f"Upload successful: {result}") + + # Start the print with the full payload (incl. serve URL + md5 + size) + serve_url = f"http://{request.host}/serve/{remote_filename}" + + # print=true in the multipart form (Moonraker) or query string -> start print + # print=false or missing -> upload only + if not auto_print: + auto_print = request.rel_url.query.get("print", "false").lower() == "true" + + # Always request the thumbnail (printer responds async with file/report) + self._thumbnail_b64 = "" + self.client.publish("file", "fileDetails", {"root": "local", "filename": remote_filename}, timeout=0) + + self._state["last_upload_url"] = serve_url + self._state["last_upload_md5"] = file_md5 + self._state["last_upload_size"] = file_size + + if auto_print: + mismatch = self._check_filament_mismatch(gcode_filaments) + if mismatch: + log.info(f"Upload+print blocked - filament mismatch: {mismatch}") + self._state["file_ready"] = remote_filename + self._state["filament_mismatch"] = mismatch + return self._octoprint_upload_response( + request, remote_filename, + extra={"filament_mismatch": True, "mismatch_details": mismatch}, + ) + 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, gcode_filaments=gcode_filaments)) + else: + log.info(f"Upload only (print=false): {remote_filename}") + self._state["file_ready"] = remote_filename + + return self._octoprint_upload_response(request, remote_filename) + + @staticmethod + def _octoprint_upload_response(request, remote_filename: str, extra: dict | None = None): + """OctoPrint-compatible upload response (OrcaSlicer evaluates refs).""" + body = { + "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", + } + } + if extra: + body.update(extra) + return web.json_response(body, status=201) + + def _check_filament_mismatch(self, gcode_filaments: list | None) -> list[dict] | None: + """Compares GCode filaments (is_used=True) with currently occupied AMS slots. + + Returns a list of mismatch entries when at least one used + GCode slot has no matching material in the AMS - otherwise None. + Only triggered when AMS data is present (at least 1 occupied slot).""" + if not gcode_filaments: + return None + slots = self._ams_slots or [] + occupied = {s["global_index"]: s for s in slots if s.get("type") and s.get("status") == 5} + if not occupied: + return None + mismatches = [] + for f in gcode_filaments: + if not f.get("is_used"): + continue + idx = int(f.get("slot_index", -1)) + gcode_mat = (f.get("material") or "").upper().strip() + if not gcode_mat: + continue + slot = occupied.get(idx) + if slot is None: + mismatches.append({ + "slot_index": idx, + "gcode_material": gcode_mat, + "ams_material": None, + "reason": "empty", + }) + else: + ams_mat = (slot.get("type") or "").upper().strip() + if ams_mat and ams_mat != gcode_mat: + mismatches.append({ + "slot_index": idx, + "gcode_material": gcode_mat, + "ams_material": ams_mat, + "reason": "mismatch", + }) + return mismatches if mismatches else None + + def _build_print_payload(self, filename: str, url: str, md5: str, filesize: int, + ams_box_mapping: list, auto_leveling: int, + excluded_objects: list | None = None, + ai_type: int = 1, timelapse_type: int = 64) -> dict: + """Builds the complete print/start MQTT payload. Single source for all + three print start paths (upload, KX store, Moonraker API).""" + return { + "taskid": "-1", + "url": url, + "filename": filename, + "md5": md5, + "filepath": None, + "filetype": 1, + "project_type": 1, + "filesize": filesize, + "ams_settings": { + "use_ams": len(ams_box_mapping) > 0, + "ams_box_mapping": ams_box_mapping, + }, + "task_settings": { + "auto_leveling": auto_leveling, + "vibration_compensation": getattr(self._args, "vibration_compensation", 0), + "flow_calibration": 0, + "dry_mode": 0, + "ai_settings": {"status": 0, "count": 0, "type": ai_type}, + "timelapse": {"status": 0, "count": 0, "type": timelapse_type}, + "drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0}, + "model_objects_skip_parts": excluded_objects or [], + }, + } + + def _reset_skip_state(self, excluded_objects: list | None = None): + """Resets the skip state before a print start. The UI is marked as + "skipped" only after real printer confirmation.""" + self._skip_state = {"skipped": [], "ts": int(time.time())} + if excluded_objects: + self._pending_preprint_skip = [str(n) for n in excluded_objects if isinstance(n, str) and n] + self._pending_preprint_skip_deadline = time.time() + 12.0 + else: + self._pending_preprint_skip = [] + self._pending_preprint_skip_deadline = 0.0 + + def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0, + gcode_filaments: list | None = None): + self._state["file_ready"] = "" + loaded = self._select_loaded_slots_for_print(warn_on_empty_default=True) + + # Only map the paints ACTUALLY used in the GCode to slots. OrcaSlicer + # writes all configured filaments into the header (filament_colour=...;...;...), + # but often uses only one (e.g. single color -> only T3). If we mapped all + # occupied slots, the printer would expect all colors and block + # when another (unused) slot is empty. The used paint indices + # liefert _extract_filament_info via is_used (echte T-Tool-Changes). + used_paint_indices = None + if gcode_filaments: + used = [int(f["slot_index"]) for f in gcode_filaments + if f.get("is_used") and "slot_index" in f] + if used: + used_paint_indices = set(used) + + if used_paint_indices is not None: + # GCode-Paint-Index N entspricht AMS-Slot N (global_index). Nur belegte + # used slots; used-but-unloaded -> a warning may follow later. + loaded = [(gidx, s) for (gidx, s) in loaded if gidx in used_paint_indices] + + ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded) + log.debug(f"AMS slots: {len(loaded)} mapped (used paints: {used_paint_indices}) -> {[i for i, _ in loaded]}") + payload = self._build_print_payload( + filename, url, md5, filesize, + ams_box_mapping=ams_box_mapping, + auto_leveling=getattr(self._args, "auto_leveling", 1), + ) + log.info(f"print/start → {filename} url={url} ams={len(ams_box_mapping)} slots mode={self._filament_mode}") + result = self.client.publish("print", "start", payload, timeout=15.0) + if result: + log.info(f"Print start confirmed: state={result.get('state')}") + else: + log.warning("Print start: no response from printer") + + def _theme_index_path(self) -> str: + return os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, "index.html") + + def _load_index_template_cached(self) -> str: + path = self._theme_index_path() + mtime = os.path.getmtime(path) + key = (path, mtime) + if self._index_tpl_cache is not None and self._index_tpl_cache_key == key: + return self._index_tpl_cache + with open(path, "r", encoding="utf-8") as f: + self._index_tpl_cache = f.read() + self._index_tpl_cache_key = key + return self._index_tpl_cache + + def _ui_asset_cache_buster(self) -> str: + base = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme) + mt = 0.0 + for fn in ("index.html", "style.css", "app.js"): + try: + mt = max(mt, os.path.getmtime(os.path.join(base, fn))) + except OSError: + pass + return str(int(mt)) if mt else "0" + + 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"Starting print: {filename}") + + # Optional slot selection from the filament dialog + filament_assignments = body.get("filament_assignments") + # Pre-Print Skip (v0.9.10) + excluded_objects = body.get("excluded_objects") or [] + if not isinstance(excluded_objects, list): + excluded_objects = [] + + auto_leveling = int(body.get("auto_leveling", getattr(self._args, "auto_leveling", 1))) + url = self._state.get("last_upload_url", "") + filesize = self._state.get("last_upload_size", 0) + md5 = self._state.get("last_upload_md5", "") + + if filament_assignments is not None: + # Explicit slot assignment from the filament dialog + ams_box_mapping, unused_count, invalid_count = self._build_assigned_ams_box_mapping(filament_assignments) + if unused_count: + log.debug(f"Skipped {unused_count} unused filament assignment(s) for mode={self._filament_mode}") + if invalid_count: + log.warning(f"Ignored {invalid_count} unusable filament assignment(s) for mode={self._filament_mode}") + if not ams_box_mapping: + return web.json_response({"error": "no usable filament assignments for current filament mode"}, status=400) + else: + # Dashboard reprint: load gcode_filaments from DB so the used_paint_indices + # filter applies and empty/shifted slots are not mapped incorrectly. + gcode_filaments = None + try: + db_file = self._store.get_file_by_name(filename) + if db_file and db_file.get("gcode_filaments"): + gcode_filaments = json.loads(db_file["gcode_filaments"]) + except Exception as e: + log.warning(f"Could not load cached gcode_filaments for {filename}: {e} " + "- slot mapping falls back to all occupied slots") + + # Set the pre-print skip before _start_print is called + self._reset_skip_state(excluded_objects) + + log.info(f"print/start api=1 mode={self._filament_mode} assignments=False gcode_filaments={gcode_filaments is not None}") + loop = asyncio.get_event_loop() + loop.run_in_executor(None, lambda: self._start_print( + filename, url, md5, filesize, + gcode_filaments=gcode_filaments, + )) + return web.json_response({"result": "ok"}) + + payload = self._build_print_payload( + filename, url, md5, filesize, + ams_box_mapping=ams_box_mapping, + auto_leveling=auto_leveling, + excluded_objects=excluded_objects, + ai_type=0, timelapse_type=0, + ) + self._reset_skip_state(excluded_objects) + + log.info( + f"print/start api=1 mode={self._filament_mode} " + f"ams={len(ams_box_mapping)} slots assignments=True" + ) + + 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": "no response from printer"}, status=504) + + if excluded_objects: + loop.run_in_executor(None, lambda: self._apply_preprint_skip_after_start(excluded_objects)) + + 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._state["filament_mismatch"] = None + 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_kx_ui_asset(self, request): + name = request.match_info.get("name", "").lstrip("/") + ctype = _KX_UI_ASSETS.get(name) + cache_control = "public, max-age=86400" + + if ctype is not None: + path = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, name) + elif name.startswith("lib/"): + ext = os.path.splitext(name)[1].lower() + ctype = _KX_UI_LIB_TYPES.get(ext) + if not ctype: + raise web.HTTPNotFound() + path = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, name) + else: + m = _KX_UI_TRANSLATION_RE.match(name) + if not m: + raise web.HTTPNotFound() + lang = m.group(1) + ctype = "application/json" + cache_control = "no-store" + path = os.path.join(_WEB_BASE, "web", "translations", f"{lang}.json") + + try: + raw = pathlib.Path(path).read_text(encoding="utf-8") + except OSError: + raise web.HTTPNotFound() + if name == "app.js": + raw = raw.replace("'__VERSION__'", f"'{self._read_version()}'") + return web.Response( + text=raw, + content_type=ctype, + headers={"Cache-Control": cache_control}, + ) + + async def handle_index(self, request): + try: + tpl = self._load_index_template_cached() + except OSError: + p = self._theme_index_path() + log.error("Web UI theme file missing or unreadable: %s (theme: %s)", p, self._ui_theme) + return web.Response( + text="
KX-Bridge: index.html not found.\nExpected:\n"
+                + html.escape(p, quote=True)
+                + "
", + status=500, + content_type="text/html; charset=utf-8", + ) + page = tpl.replace("__UI_ASSETS_VER__", self._ui_asset_cache_buster()) + + # Embed CSS + JS INLINE instead of just linking. OrcaSlicer's + # embedded device tab webview does NOT load external /") + except OSError: + pass + + _inline_css("lib/gridstack.min.css", '') + _inline_js("lib/gridstack-all.min.js", '') + _inline_css("style.css", '') + _inline_js("app.js", '', version_sub=True) + + return web.Response(text=page, 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("Connected manually") + 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_restart(self, request): + log.info("Restart requested via API") + response = web.json_response({"status": "restarting"}) + asyncio.get_event_loop().call_later(0.3, self._restart_bridge) + return response + + 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)) # global slot index + 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) + box_id, local_slot = self._global_to_box_slot(index) + loop = asyncio.get_event_loop() + self._state["last_ams_set_error"] = False + # Remembered so a later state="failed" report (which carries no slot + # info of its own, see _on_multicolor_box) can be logged alongside the + # request that triggered it - otherwise the failure is unattributable. + self._last_ams_set_request = {"global": index, "box": box_id, "local_slot": local_slot, "type": mat, "color": color} + # setInfo goes via the web/printer topic (like tempature/set). Verified via + # Workbench-Vue mqtt_setInfo verifiziert — via slicer/printer/ wurden + # slot changes are ignored by the printer and overwritten with the old + # material on the next multiColorBox/report. + def _send(): + self.client.publish_web( + "multiColorBox", "setInfo", + {"multi_color_box": [{"id": box_id, "slots": [{"index": local_slot, "type": mat, "color": color}]}]}, + ) + log.info(f"setInfo (web) global={index} box={box_id} local_slot={local_slot} type={mat} color={color}") + await loop.run_in_executor(None, _send) + # Optimistisches Update: cached slot sofort anpassen (Drucker echoed + # gleich via multiColorBox/report — falls er den Befehl ignoriert, + # the report overwrites it again). + for s in self._ams_slots: + if s.get("global_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)) + if feed_type == 1: + self._pending_load_slot = slot_index + # Feed-out (type=2): if no slot was explicitly chosen, use the last loaded one + if feed_type == 2 and self._ams_loaded_slot >= 0: + slot_index = self._ams_loaded_slot + box_id, local_slot = self._global_to_box_slot(slot_index) + loop = asyncio.get_event_loop() + def _send(): + resp = self.client.publish( + "multiColorBox", "feedFilament", + {"multi_color_box": [{"id": box_id, "feed_status": {"slot_index": local_slot, "type": feed_type}}]}, + timeout=5 + ) + log.info(f"feedFilament type={feed_type} global_slot={slot_index} box={box_id} local_slot={local_slot} loaded_slot={self._ams_loaded_slot} → {resp}") + await loop.run_in_executor(None, _send) + return web.json_response({"result": "ok"}) + + async def handle_api_ace_auto_feed(self, request): + try: + body = await request.json() + except Exception: + body = {} + + ace_id_raw = body.get("ace_id", None) + on_raw = body.get("on", None) + if ace_id_raw is None or on_raw is None: + return web.json_response({"error": "ace_id and on are required"}, status=400) + try: + ace_id = int(ace_id_raw) + on = int(bool(on_raw)) + except Exception: + return web.json_response({"error": "invalid parameters"}, status=400) + if not (0 <= ace_id <= 3): + return web.json_response({"error": "ace_id must be 0-3"}, status=400) + + payload = {"multi_color_box": [{"id": ace_id, "auto_feed": on}]} + loop = asyncio.get_event_loop() + # Fire-and-forget: setAutoFeed ACK arrives via multiColorBox/report callback. + # Waiting for a response on that busy push topic causes false "code:0" rejections. + await loop.run_in_executor( + None, + lambda: self.client.publish("multiColorBox", "setAutoFeed", payload, timeout=0) + ) + self._ace_auto_feed[ace_id] = on + self._state_dirty = True + return web.json_response({"result": "ok", "ace_id": ace_id, "auto_feed": on}) + + async def handle_api_ace_dry(self, request): + try: + body = await request.json() + except Exception: + body = {} + + action = str(body.get("action", "start")).lower() + if action not in ("start", "stop"): + return web.json_response({"error": "action must be 'start' or 'stop'"}, status=400) + + ace_ids = [i for i in self._ace_box_ids if 0 <= i <= 3] + if not ace_ids: + ace_ids = sorted({ + int(s.get("box_id", -1)) + for s in self._ams_slots + if 0 <= int(s.get("box_id", -1)) <= 3 + }) + if not ace_ids and self._state.get("filament_mode") != "toolhead": + ace_ids = [0] + if not ace_ids: + return web.json_response({"error": "ACE not detected"}, status=400) + + ace_id_raw = body.get("ace_id", None) + if ace_id_raw is not None: + try: + ace_id = int(ace_id_raw) + except Exception: + return web.json_response({"error": "ace_id must be an integer"}, status=400) + if ace_id not in ace_ids: + return web.json_response({"error": f"ACE {ace_id + 1} not detected"}, status=400) + ace_ids = [ace_id] + + if action == "start": + target_temp = int(body.get("target_temp", 45)) + duration = int(body.get("duration", 240)) + target_temp = max(30, min(80, target_temp)) + duration = max(10, min(24 * 60, duration)) + humidity = (self._state.get("ace_drying") or {}).get("humidity") + current_temp = (self._state.get("ace_drying") or {}).get("current_temp") + drying_status = { + "status": 1, + "target_temp": target_temp, + "duration": duration, + "remain_time": duration, + } + ui_state = { + "status": 1, + "target_temp": target_temp, + "duration": duration, + "remain_time": duration, + "humidity": humidity, + "current_temp": current_temp, + } + else: + drying_status = {"status": 0} + humidity = (self._state.get("ace_drying") or {}).get("humidity") + current_temp = (self._state.get("ace_drying") or {}).get("current_temp") + ui_state = { + "status": 0, + "target_temp": 0, + "duration": 0, + "remain_time": 0, + "humidity": humidity, + "current_temp": current_temp, + } + + payload = { + "multi_color_box": [ + {"id": bid, "drying_status": dict(drying_status)} + for bid in ace_ids + ] + } + + loop = asyncio.get_event_loop() + + def _send(): + return self.client.publish("multiColorBox", "setDry", payload, timeout=0) + # Fire-and-forget: setDry ACK arrives via multiColorBox/report callback. + # Waiting for a response on that busy push topic causes false "code:0" rejections. + await loop.run_in_executor(None, _send) + + self._state["ace_drying"] = ui_state + self._state_dirty = True + return web.json_response({"result": "ok"}) + + async def handle_api_axis(self, request): + try: + body = await request.json() + except Exception: + body = {} + + loop = asyncio.get_event_loop() + action = str(body.get("action", "")).lower() + + 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: tempature/set via the `web/printer` topic with a `type` field. + # Confirmed by live sniffing the Anycubic Slicer Next on 2026-05-29: + # topic = web/printer/.../tempature + # data = {"type": 0|1|2, "target_hotbed_temp": B, "target_nozzle_temp": N} + # type values (from Workbench Vue): 0=nozzle, 1=bed, 2=both. + # Ohne `type` ODER auf `slicer/printer`-Topic → Systemfehler am Drucker. + if nozzle is not None and bed is not None: + t, n, b = 2, int(float(nozzle)), int(float(bed)) + elif nozzle is not None: + t, n, b = 0, int(float(nozzle)), 0 + elif bed is not None: + t, n, b = 1, 0, int(float(bed)) + else: + return web.json_response({"result": "ok"}) + await loop.run_in_executor(None, lambda: self.client.publish_web( + "tempature", "set", + {"type": t, "target_nozzle_temp": n, "target_hotbed_temp": b}, + )) + 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"Camera 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 + )) + # Prevents the auto-start guard from restarting the camera during the + # laufenden Drucks wieder einschaltet (State-Flicker-Problem). + self._camera_user_stopped = True + return web.json_response({"result": "ok"}) + + async def handle_api_camera_reset(self, request): + """Reset the backoff counter and restart ffmpeg immediately. + Useful after a 429 lock (Retry-After expired) or after a printer restart.""" + self.camera_cache.reset() + url = self._state.get("camera_url", "") + if not url: + log.warning("Camera reset requested but no camera_url is known yet (waiting for printer status)") + return web.json_response({ + "result": "no_url", + "message": "No camera URL known yet - wait for the next printer status update, or start a print/enable the camera first.", + }) + self.camera_cache.set_url(url) + await self.camera_cache.ensure_running() + return web.json_response({"result": "ok", "url": url}) + + async def handle_api_camera_snapshot(self, request): + """Last JPEG frame from the CameraCache - instant from RAM, + no separate ffmpeg instance anymore (prevents the single-client 429 at the + printer and is ~1 s faster).""" + url = self._state.get("camera_url", "") + if not url: + return web.Response(status=503, text="No camera URL known") + self.camera_cache.set_url(url) + await self.camera_cache.ensure_running() + # Initial warmup: wait up to 5s for the first frame + deadline = time.time() + 5.0 + while not self.camera_cache.latest_jpeg and time.time() < deadline: + await asyncio.sleep(0.1) + jpeg = self.camera_cache.latest_jpeg + if not jpeg: + return web.Response(status=503, text="No frame in cache yet") + # If the last frame is older than 10 s -> the cache ffmpeg is probably + # no longer running stably; deliver anyway but with a stale header. + age = time.time() - self.camera_cache.latest_jpeg_ts + headers = {"Cache-Control": "no-cache"} + if age > 10: + headers["X-Frame-Age"] = f"{age:.1f}" + return web.Response(body=jpeg, content_type="image/jpeg", headers=headers) + + async def handle_camera_stream(self, request): + """MJPEG live view, served as multipart/x-mixed-replace. + + Fed from the central CameraCache fanout (same pattern as + handle_camera_h264) instead of spawning a dedicated ffmpeg process + per HTTP client. The printer's camera server only tolerates a very + limited number of concurrent connections (see CameraCache docstring) + - previously every consumer of this endpoint (dashboard, OrcaSlicer, + moonraker-obico, a second browser tab, ...) opened its own separate + connection, so two simultaneous viewers could already exhaust the + printer's connection limit and cause intermittent "stream + unavailable" failures. Now all consumers share one connection. + """ + url = self._state.get("camera_url", "") + if not url: + return web.Response(status=503, text="No camera URL known") + self.camera_cache.set_url(url) + await self.camera_cache.ensure_running() + + q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=8) + self.camera_cache.mjpeg_subscribers.add(q) + + # Wait for the first frame BEFORE resp.prepare() - once prepare() sends + # the response headers the status is committed to 200, so a stalled + # source (Issue #99) must be caught here to actually return a 503 + # instead of hanging the client forever with no frame ever arriving. + try: + first_frame = await asyncio.wait_for(q.get(), timeout=5.0) + except asyncio.TimeoutError: + self.camera_cache.mjpeg_subscribers.discard(q) + return web.Response(status=503, text="No frame in cache yet") + + 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) + try: + frame = first_frame + while True: + 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 (ConnectionResetError, asyncio.CancelledError): + break + except Exception: + break + frame = await q.get() + except Exception as e: + log.warning(f"Camera stream interrupted: {e}") + finally: + self.camera_cache.mjpeg_subscribers.discard(q) + + return resp + + async def handle_camera_h264(self, request): + """H.264 passthrough as MPEG-TS, fed from the central + CameraCache fanout. Allows multiple parallel consumers without an + additional FLV connection to the printer (single-client limit).""" + url = self._state.get("camera_url", "") + if not url: + return web.Response(status=503, text="No camera URL known") + self.camera_cache.set_url(url) + await self.camera_cache.ensure_running() + + q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=64) + self.camera_cache.h264_subscribers.add(q) + + resp = web.StreamResponse(headers={ + "Content-Type": "video/mp2t", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }) + await resp.prepare(request) + try: + while True: + chunk = await q.get() + try: + await resp.write(chunk) + except (ConnectionResetError, asyncio.CancelledError): + break + except Exception as e: + log.warning(f"H.264-Stream unterbrochen: {e}") + finally: + self.camera_cache.h264_subscribers.discard(q) + return resp + + async def handle_serve_file(self, request): + """Serves uploaded G-code files from the temp directory (for printer 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"Printer downloading file: {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 + # Slicer time + thumbnail are only transient in state (set during upload). + # After a browser reload or an OrcaSlicer direct print (file did not come + # through the UI upload) they are missing -> restore from the GCode store via the + # laufenden Dateinamens nachladen. + slicer_time = s["slicer_time"] + thumbnail = self._thumbnail_b64 + fname = s.get("filename", "") + if fname and (not slicer_time or not thumbnail): + try: + gf = self._store.get_file_by_name(fname) + if gf: + if not slicer_time and gf.get("est_print_time_sec"): + slicer_time = int(gf["est_print_time_sec"]) + if not thumbnail and gf.get("thumbnail_b64"): + thumbnail = gf["thumbnail_b64"] + except Exception: + pass + 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"], + "z_mm": self._estimate_current_z(), + "filename": s["filename"], + "slicer_time": slicer_time, + "camera_url": s["camera_url"], + "fan_speed": s["fan_speed"], + "print_speed_mode": s["print_speed_mode"], + "auto_leveling": getattr(self._args, "auto_leveling", 1), + "vibration_compensation": getattr(self._args, "vibration_compensation", 0), + "camera_on_print": getattr(self._args, "camera_on_print", 0), + "web_upload_warning": getattr(self._args, "web_upload_warning", 1), + "light_on": s["light_on"], + "light_brightness": s["light_brightness"], + "ams_slots": self._ams_slots, + "ams_loaded_slot": self._ams_loaded_slot, + "filament_mode": s.get("filament_mode", self._filament_mode), + "ace_drying": s.get("ace_drying", {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None}), + "ace_units": list(self._ace_box_ids), + "ace_auto_feed": dict(self._ace_auto_feed), + "ace_dry_presets": self._ace_dry_presets, + "thumbnail": thumbnail, + "connection_error": s["connection_error"], + "file_ready": s["file_ready"], + "print_start_dialog": s.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)), + "version": self._read_version(), + "pause_msg": s.get("pause_msg", ""), + "error_code": s.get("error_code", 0), + "storage_total_mb": s.get("storage_total_mb", 0), + "storage_used_mb": s.get("storage_used_mb", 0), + }) + + 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} + }) + + # mainsail/presets: Obico asks for temperature presets. The schema is evaluated in + # find_all_thermal_presets as data['value']['presets'].values(), + # so we need at least {presets: {}} to avoid a crash. + if namespace == "mainsail": + if key == "presets": + return web.json_response({ + "result": {"namespace": "mainsail", "key": "presets", + "value": {"presets": {}}} + }) + return web.json_response({ + "result": {"namespace": "mainsail", "key": key, "value": {}} + }) + + # obico namespace: in-memory KV store for plugin settings (key=printer_id etc.) + if namespace == "obico": + store = self._moonraker_kv_store.setdefault("obico", {}) + if key and key in store: + return web.json_response({ + "result": {"namespace": "obico", "key": key, "value": store[key]} + }) + return web.json_response({ + "result": {"namespace": "obico", "key": key, "value": store if not key else None} + }) + + return web.json_response( + {"error": {"code": 404, "message": f"Namespace '{namespace}' not found"}}, + status=404 + ) + + async def handle_moonraker_database_post(self, request): + """POST /server/database/item — KV-Store-Write (von moonraker-obico verwendet). + moonraker-obico sends namespace/key/value as form-urlencoded POST params.""" + # Versuche JSON, fallback auf form-data, fallback auf Query-Params + namespace = "" + key = "" + value = None + try: + data = await request.json() + if isinstance(data, dict): + namespace = data.get("namespace", "") + key = data.get("key", "") + value = data.get("value") + except Exception: + try: + form = await request.post() + namespace = form.get("namespace", "") or "" + key = form.get("key", "") or "" + value = form.get("value") + except Exception: + pass + if not namespace: + namespace = request.rel_url.query.get("namespace", "") + if not key: + key = request.rel_url.query.get("key", "") + if namespace and key: + store = self._moonraker_kv_store.setdefault(namespace, {}) + store[key] = value + return web.json_response({ + "result": {"namespace": namespace, "key": key, "value": value} + }) + return web.json_response({"error": {"code": 400, "message": "namespace + key required"}}, status=400) + + async def handle_database_list(self, request): + """OrcaSlicer checks which namespaces exist to detect the MMU type.""" + return web.json_response({"result": {"namespaces": ["lane_data", "mainsail", "obico"]}}) + + 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"): + data = resp["data"] + self._head_tools_model = int(data.get("head_tools_model", self._head_tools_model)) + boxes = data.get("multi_color_box") or [] + if boxes: + self._update_ace_drying_state(data, boxes) + self._filament_mode = self._detect_filament_mode(boxes, self._head_tools_model) + self._state["filament_mode"] = self._filament_mode + global_slots, global_loaded = self._aggregate_slots(boxes, self._filament_mode) + activity_map = self._slot_activity_map(boxes, global_loaded) + for s in global_slots: + s["activity"] = activity_map.get(s.get("global_index"), "") + if global_slots: + self._ams_slots = global_slots + self._ams_loaded_slot = global_loaded + return self._ams_slots + + # ─── Settings ──────────────────────────────────────────────────────────── + + def _find_config_path(self) -> pathlib.Path: + """Returns the path to config.ini.""" + if hasattr(env_loader, "find_config_path"): + return env_loader.find_config_path() + # Fallback for the old 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_name": self._state.get("printer_name", ""), + "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, + "power_on_url": getattr(self._args, "power_on_url", "") or "", + "power_off_url": getattr(self._args, "power_off_url", "") or "", + "power_status_url": getattr(self._args, "power_status_url", "") or "", + "default_ams_slot": getattr(self._args, "default_ams_slot", "auto"), + "auto_leveling": getattr(self._args, "auto_leveling", 1), + "vibration_compensation": getattr(self._args, "vibration_compensation", 0), + "camera_on_print": getattr(self._args, "camera_on_print", 0), + "web_upload_warning": getattr(self._args, "web_upload_warning", 1), + "delete_printer_file_after_print": getattr(self._args, "delete_printer_file_after_print", 0), + "print_start_dialog": getattr(self._args, "print_start_dialog", 1), + "poll_interval": getattr(self._args, "poll_interval", 3), + "verbose_http_log": getattr(self._args, "verbose_http_log", 0), + "filament_profiles": {str(k): v for k, v in self._filament_profiles.items()}, + "visible_vendors": self._visible_vendors, + "ace_dry_presets": self._ace_dry_presets, + "spoolman_server": getattr(self._args, "spoolman_server", "") or "", + "spoolman_sync_rate": getattr(self._args, "spoolman_sync_rate", 0), + }) + + async def handle_api_settings_post(self, request): + import configparser + try: + data = await request.json() + except Exception: + return self._json_cors({"error": "invalid json"}, status=400) + config_path = self._find_config_path() + config_path.parent.mkdir(parents=True, exist_ok=True) + + # Read the existing config.ini (comments are lost, but values are kept) + cfg = configparser.ConfigParser(interpolation=None) + if config_path.is_file(): + cfg.read(config_path, encoding="utf-8") + + # Sections sicherstellen + for section in ("connection", "print", "bridge", "ace_dry_presets", "spoolman"): + 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("connection", "power_on_url", str(data.get("power_on_url", getattr(self._args, "power_on_url", "") or "")).strip()) + cfg.set("connection", "power_off_url", str(data.get("power_off_url", getattr(self._args, "power_off_url", "") or "")).strip()) + cfg.set("connection", "power_status_url", str(data.get("power_status_url", getattr(self._args, "power_status_url", "") or "")).strip()) + 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)))) + cfg.set("print", "vibration_compensation", str(int(bool(data.get("vibration_compensation", getattr(self._args, "vibration_compensation", 0)))))) + cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0)))))) + cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1)))))) + cfg.set("print", "delete_printer_file_after_print", str(int(bool(data.get("delete_printer_file_after_print", getattr(self._args, "delete_printer_file_after_print", 0)))))) + cfg.set("print", "print_start_dialog", str(int(bool(data.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)))))) + if "poll_interval" in data: + try: + pi = max(1, min(60, int(data["poll_interval"]))) + except (TypeError, ValueError): + pi = 3 + cfg.set("bridge", "poll_interval", str(pi)) + elif not cfg.has_option("bridge", "poll_interval"): + cfg.set("bridge", "poll_interval", "3") + verbose_http_log = int(bool(data.get("verbose_http_log", getattr(self._args, "verbose_http_log", 0)))) + cfg.set("bridge", "verbose_http_log", str(verbose_http_log)) + _set_verbose_http_log(bool(verbose_http_log)) + self._args.verbose_http_log = verbose_http_log + printer_name = str(data.get("printer_name", "")).strip() + if printer_name: + cfg.set("bridge", "printer_name", printer_name) + elif cfg.has_option("bridge", "printer_name"): + cfg.remove_option("bridge", "printer_name") + + # Spoolman + if "spoolman_server" in data: + cfg.set("spoolman", "server", str(data["spoolman_server"]).strip()) + if "spoolman_sync_rate" in data: + try: + sr = max(0, int(data["spoolman_sync_rate"])) + except (TypeError, ValueError): + sr = 30 + cfg.set("spoolman", "sync_rate", str(sr)) + + incoming_presets = data.get("ace_dry_presets") if isinstance(data, dict) else None + presets = self._sanitize_ace_dry_presets(incoming_presets if isinstance(incoming_presets, dict) else self._ace_dry_presets) + for key, val in presets.items(): + cfg.set("ace_dry_presets", f"{key}_temp", str(val["temp"])) + cfg.set("ace_dry_presets", f"{key}_duration_sec", str(val["duration_sec"])) + if key.startswith("custom_"): + cfg.set("ace_dry_presets", f"{key}_name", str(val.get("name", key.replace("_", " ").title()))) + self._ace_dry_presets = presets + + with open(config_path, "w", encoding="utf-8") as f: + f.write("# KX-Bridge Konfigurationsdatei\n\n") + cfg.write(f) + log.info(f"Settings saved to {config_path}") + # Send the response, then restart + response = web.json_response({"status": "restarting"}) + asyncio.get_event_loop().call_later(0.3, self._restart_bridge) + return response + + async def handle_kx_printer_add(self, request): + """Adds a printer: fetches credentials via IP, writes [printer_N], restarts.""" + try: + body = await request.json() + except Exception: + return self._json_cors({"error": "invalid json"}, status=400) + ip = str(body.get("printer_ip", "")).strip().split(":")[0] + name = str(body.get("name", "")).strip() + if not ip: + return self._json_cors({"error": "printer_ip required"}, status=400) + try: + creds = await _kx_fetch_credentials(ip) + except Exception as e: + return self._json_cors({"error": f"printer unreachable or error: {e}"}, status=502) + + import configparser + config_path = self._find_config_path() + cfg = configparser.ConfigParser(interpolation=None) + if config_path.is_file(): + cfg.read(config_path, encoding="utf-8") + + # Vorhandene [printer_N]-Sektionen + belegte http_ports ermitteln + n = 1 + existing_ports: set[int] = set() + while cfg.has_section(f"printer_{n}"): + p = cfg[f"printer_{n}"] + if p.get("http_port"): + try: + existing_ports.add(int(p["http_port"])) + except ValueError: + pass + n += 1 + + # No [printer_N], but a populated [connection]? -> migrate as printer_1 + # (empty [connection] = no existing printer -> don't migrate, the new one becomes printer_1) + if n == 1 and cfg.has_section("connection") and (cfg["connection"].get("printer_ip") or "").strip(): + c = cfg["connection"] + cfg.add_section("printer_1") + cfg.set("printer_1", "name", self._state.get("printer_name") or "Kobra X") + for k in ("printer_ip", "mqtt_port", "username", "password", "mode_id", "device_id"): + if c.get(k): + cfg.set("printer_1", k, c.get(k)) + cfg.set("printer_1", "http_port", "7125") + existing_ports.add(7125) + n = 2 + + # Create the new printer as [printer_n], pick a free port + new_port = 7125 + (n - 1) + while new_port in existing_ports: + new_port += 1 + sec = f"printer_{n}" + cfg.add_section(sec) + cfg.set(sec, "name", name or creds["model"]) + cfg.set(sec, "printer_ip", creds["printer_ip"]) + cfg.set(sec, "mqtt_port", "9883") + cfg.set(sec, "username", creds["username"]) + cfg.set(sec, "password", creds["password"]) + cfg.set(sec, "mode_id", creds["mode_id"]) + cfg.set(sec, "device_id", creds["device_id"]) + cfg.set(sec, "http_port", str(new_port)) + + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as f: + f.write("# KX-Bridge Konfigurationsdatei\n\n") + cfg.write(f) + log.info(f"Printer '{name or creds['model']}' added as {sec} (port {new_port})") + response = self._json_cors({"status": "restarting", "section": sec, "http_port": new_port}) + asyncio.get_event_loop().call_later(0.5, self._restart_bridge) + return response + + async def handle_kx_printer_remove(self, request): + """Removes a printer from config.ini, then restarts. + + - Multi mode: [printer_N] is deleted, the rest renumbered (printer_3 -> printer_2), + printer_1 bekommt immer http_port 7125. + - Single mode (no [printer_N], only [connection]): pid "1" clears the [connection] block + → Bridge startet im Offline-Modus auf 7125, UI bleibt erreichbar. + - When the last [printer_N] is removed: all gone -> also the "empty" state. + """ + pid = str(request.match_info.get("pid", "")).strip() + if not pid: + return self._json_cors({"error": "printer id required"}, status=400) + + import configparser + config_path = self._find_config_path() + cfg = configparser.ConfigParser(interpolation=None) + if config_path.is_file(): + cfg.read(config_path, encoding="utf-8") + + has_printer_sections = cfg.has_section("printer_1") + target = f"printer_{pid}" + + if has_printer_sections: + if not cfg.has_section(target): + return self._json_cors({"error": f"{target} not found"}, status=404) + # Collect all [printer_N] (except the one being deleted), renumber + kept = [] + n = 1 + while cfg.has_section(f"printer_{n}"): + if str(n) != pid: + kept.append(dict(cfg[f"printer_{n}"])) + cfg.remove_section(f"printer_{n}") + n += 1 + for i, sec_data in enumerate(kept, start=1): + sec = f"printer_{i}" + cfg.add_section(sec) + for k, v in sec_data.items(): + cfg.set(sec, k, v) + cfg.set(sec, "http_port", str(7125 + i - 1)) + remaining = len(kept) + # Was that the last printer? Then also clear [connection] -> truly "no printer" + if remaining == 0 and cfg.has_section("connection"): + for k in ("printer_ip", "username", "password", "device_id"): + cfg.set("connection", k, "") + else: + # Single mode: only pid "1" is valid (pseudo entry from handle_kx_printers) + if pid != "1": + return self._json_cors({"error": "no printer with this ID"}, status=404) + # Clear [connection] values -> bridge starts without a printer + if cfg.has_section("connection"): + for k in ("printer_ip", "username", "password", "device_id"): + cfg.set("connection", k, "") + remaining = 0 + + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as f: + f.write("# KX-Bridge Konfigurationsdatei\n\n") + cfg.write(f) + log.info(f"Printer {target} removed ({remaining} remaining)") + response = self._json_cors({"status": "restarting", "removed": target, "remaining": remaining}) + asyncio.get_event_loop().call_later(0.5, self._restart_bridge) + return response + + def _restart_bridge(self): + log.info("Restarting bridge...") + # config_loader caches config.ini values in os.environ ("only if not set"). + # On restart, environ must be cleaned, otherwise the new process reads + # the old values instead of the modified config.ini. Keys are derived + # from config_loader.CONFIG_ENV_MAPPING (single source of truth) so a + # newly added setting can never be forgotten here again. + try: + import config_loader as _cl + _restart_env_keys = set(_cl.CONFIG_ENV_MAPPING.keys()) | {"FILE_READY_DIALOG"} + except Exception: + _restart_env_keys = () + for _k in _restart_env_keys: + os.environ.pop(_k, None) + + in_docker = os.path.exists("/.dockerenv") or os.environ.get("KX_IN_DOCKER") + if in_docker: + # Docker/systemd: exiting the process is enough - the supervisor restarts (fresh environ) + log.info("Container environment detected – exiting for supervisor restart") + os._exit(0) + + frozen = getattr(sys, "frozen", False) + + # Linux: os.execv replaces the process image directly - clean even with PyInstaller onefile + # (subprocess+exit would fail there on the deleted _MEIxxxx temp directory). + if sys.platform != "win32": + exe = sys.executable + try: + if frozen: + os.execv(exe, [exe] + sys.argv[1:]) + else: + os.execv(exe, [exe] + sys.argv) + except Exception as e: + log.error(f"Restart (execv) failed: {e} - please restart the bridge manually") + os._exit(1) + + # Windows: os.execv is broken there (new PID, old process returns) -> subprocess + cmd = ([sys.executable] + sys.argv[1:]) if frozen else ([sys.executable] + sys.argv) + try: + subprocess.Popen(cmd, cwd=os.getcwd(), + creationflags=(subprocess.DETACHED_PROCESS + | subprocess.CREATE_NEW_PROCESS_GROUP)) + except Exception as e: + log.error(f"Restart failed: {e} - please restart the bridge manually") + os._exit(0) + + # ─── Update ────────────────────────────────────────────────────────────── + + # limit=1 would only ever see the single newest release regardless of type - + # if that happens to be a nightly/dev prerelease (the common case, since + # those publish far more often than stable), the stable_releases filter + # below finds nothing and update checks fail with "no stable releases + # found" even though older stable releases exist (Issue #104). + STABLE_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=20" + NIGHTLY_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=5&pre-release=true" + 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: + # PyInstaller onefile unpacks VERSION (via kx-bridge.spec datas) to + # sys._MEIPASS - therefore use _WEB_BASE instead of _BASE. + for base in (pathlib.Path(_WEB_BASE), 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) - only numeric parts before the first '-'""" + 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: streams log entries live to the 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): + """Returns all buffered log entries as plaintext for download.""" + header = (f"# KX-Bridge Log | Version {self._read_version()} | " + f"{time.strftime('%Y-%m-%d %H:%M:%S')} | {len(_log_buffer)} entries\n") + lines = [f"[{e['ts']}] {e['lvl']:<7} {e['name']}: {e['msg']}" for e in _log_buffer] + text = header + "\n".join(lines) + "\n" + fname = f"kx-bridge-log_{time.strftime('%Y%m%d-%H%M%S')}.txt" + return web.Response( + body=text.encode("utf-8"), + content_type="text/plain", + headers={"Content-Disposition": f'attachment; filename="{fname}"'}, + ) + + async def handle_api_update_check(self, request): + current = self._read_version() + is_nightly = "nightly" in current + is_dev = "-dev+" in current + if is_nightly: + api_url = self.NIGHTLY_RELEASE_API + elif is_dev: + api_url = self.DEV_RELEASE_API + else: + api_url = 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": "no releases found"}, status=404) + + if is_nightly: + # Find the newest prerelease with a nightly tag + nightly_releases = [r for r in releases if r.get("prerelease") and "nightly" in r.get("tag_name", "")] + if not nightly_releases: + return web.json_response({"error": "no nightly releases found"}, status=404) + data = nightly_releases[0] + tag = data.get("tag_name", "") + # Tag-Format: "nightly-0.9.27-nightly4", current: "0.9.27-nightly4" + tag_version = tag[len("nightly-"):] if tag.startswith("nightly-") else tag + update_available = tag_version != current + latest = tag + return web.json_response({ + "current": current, + "latest": latest, + "update_available": update_available, + "tag": tag, + "docker_only": True, + "changelog": data.get("body", ""), + }) + elif 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": "no dev releases found"}, status=404) + data = dev_releases[0] + else: + # Stable: only take non-prereleases + stable_releases = [r for r in releases if not r.get("prerelease")] + if not stable_releases: + return web.json_response({"error": "no stable releases found"}, status=404) + data = stable_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, + "docker_only": False, + "changelog": data.get("body", ""), + }) + except Exception as e: + return web.json_response({"error": str(e)}, status=502) + + # Bridge Python modules the self-update must include. If only the + # main file is replaced, the new version may crash with ModuleNotFoundError. + # Note: since the theme system, the frontend lives under web/themes// + # (no flat .py anymore); theme files are currently NOT included in the + # self-update - theme changes arrive via Docker image/binary updates. + _UPDATE_FILES = [ + "kobrax_moonraker_bridge.py", + "kobrax_client.py", + "config_loader.py", + "env_loader.py", + ] + + async def handle_api_update_apply(self, request): + try: + data = await request.json() + except Exception: + return web.json_response({"error": "invalid json"}, status=400) + new_tag = data.get("tag", "") + if "nightly" in self._read_version(): + return web.json_response( + {"error": "nightly updates are delivered via Docker: " + "docker compose pull && docker compose up -d"}, status=400) + if getattr(sys, "frozen", False): + return web.json_response( + {"error": "self-update is not supported in binary mode - " + "please download the new binary/Docker image."}, status=400) + if not new_tag: + return web.json_response({"error": "missing tag"}, status=400) + + app_dir = pathlib.Path(__file__).resolve().parent + try: + # Phase 1: ALLE Dateien herunterladen (in .new), nichts ersetzen. + downloaded: list[tuple[pathlib.Path, bytes]] = [] + async with aiohttp.ClientSession() as session: + for fname in self._UPDATE_FILES: + url = f"{self.GITEA_RAW_BASE}/{new_tag}/{fname}" + async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: + if resp.status != 200: + # _web_assets.py etc. may not exist in older tags - + # the main file is mandatory, optional ones may be missing. + if fname == "kobrax_moonraker_bridge.py": + return web.json_response( + {"error": f"Download {fname}: HTTP {resp.status}"}, status=502) + log.warning(f"Update: {fname} not found in release ({resp.status}) – skipped") + continue + downloaded.append((app_dir / fname, await resp.read())) + # Phase 2: replace atomically (only after a complete, successful download) + for path, content in downloaded: + tmp = path.with_suffix(path.suffix + ".new") + tmp.write_bytes(content) + os.replace(tmp, path) + self._write_version(new_tag.lstrip("v")) + log.info(f"Update to {new_tag} installed ({len(downloaded)} files), restarting...") + 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): + # Minimal 1x1 ICO so the browser doesn't log a 404 + 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") + + # ------------------------------------------------------------------------- + # Klipper G-code script emulation for moonraker-obico + # ------------------------------------------------------------------------- + + async def _exec_gcode_script(self, script: str) -> str: + """Maps a Klipper or Marlin G-code line to an MQTT command + for the Kobra X. Supports: + - PAUSE / M25, RESUME / M24, CANCEL_PRINT / M0/M1/M524/ABORT + - M104 S → Nozzle-Temperatur + - M140 S → Bett-Temperatur + - SET_HEATER_TEMPERATURE HEATER=extruder TARGET=200 (Klipper) + - SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=60 (Klipper) + Unknown scripts are acknowledged with 'ok' (Obico e.g. sends G28 + for homing, which the bridge silently ignores).""" + if not script: + return "ok" + s = script.strip().upper() + loop = asyncio.get_event_loop() + + def _parse_marlin_temp(line: str) -> int | None: + """Extract the temperature value from 'M104 S200' or 'M140 S60'.""" + try: + return int(line.split("S", 1)[1].split()[0]) + except Exception: + return None + + def _parse_klipper_set_heater(line: str) -> tuple[str | None, int | None]: + """Extract heater + target from 'SET_HEATER_TEMPERATURE HEATER=extruder TARGET=143'. + Heater ID + target. Heater is 'extruder' or + 'heater_bed', target is int. Returns (None,None) on error.""" + heater = None + target = None + for part in line.split(): + if part.startswith("HEATER="): + heater = part.split("=", 1)[1].strip().lower() + elif part.startswith("TARGET="): + try: + target = int(float(part.split("=", 1)[1])) + except Exception: + pass + return heater, target + + async def _set_temps(nozzle: int | None, bed: int | None): + """Sets nozzle/bed temperature via the correct MQTT path - + printing: print/update with taskid, idle: tempature/set with both.""" + is_printing = self._state.get("print_state") in ("printing", "paused") + if is_printing: + taskid = self._state.get("taskid", "") + if nozzle is not None: + await loop.run_in_executor(None, lambda: self.client.publish_web( + "print", "update", + {"taskid": taskid, "settings": {"target_nozzle_temp": int(nozzle)}}, + )) + if bed is not None: + await loop.run_in_executor(None, lambda: self.client.publish_web( + "print", "update", + {"taskid": taskid, "settings": {"target_hotbed_temp": int(bed)}}, + )) + else: + # Idle: tempature/set via the web/printer topic with a type field + # (Live-Sniff 2026-05-29). type: 0=Nozzle, 1=Bed, 2=beide. + if nozzle is not None and bed is not None: + t, n, b = 2, int(nozzle), int(bed) + elif nozzle is not None: + t, n, b = 0, int(nozzle), 0 + elif bed is not None: + t, n, b = 1, 0, int(bed) + else: + return + await loop.run_in_executor(None, lambda: self.client.publish_web( + "tempature", "set", + {"type": t, "target_nozzle_temp": n, "target_hotbed_temp": b}, + )) + + try: + if s in ("PAUSE", "M25"): + await loop.run_in_executor(None, self.client.pause_print) + elif s in ("RESUME", "M24"): + await loop.run_in_executor(None, self.client.resume_print) + elif s in ("CANCEL_PRINT", "M0", "M1", "M524", "ABORT"): + await loop.run_in_executor(None, self.client.stop_print) + elif s.startswith("M104 "): + t = _parse_marlin_temp(s) + if t is not None: + log.info(f"gcode.script: Nozzle-Target {t}°C (M104)") + await _set_temps(t, None) + elif s.startswith("M140 "): + t = _parse_marlin_temp(s) + if t is not None: + log.info(f"gcode.script: Bed-Target {t}°C (M140)") + await _set_temps(None, t) + elif s.startswith("SET_HEATER_TEMPERATURE"): + heater, target = _parse_klipper_set_heater(s) + if target is not None and heater: + if heater == "extruder": + log.info(f"gcode.script: Nozzle-Target {target}°C (Klipper)") + await _set_temps(target, None) + elif heater in ("heater_bed", "bed"): + log.info(f"gcode.script: Bed-Target {target}°C (Klipper)") + await _set_temps(None, target) + else: + log.debug(f"gcode.script: unbekannter Heater '{heater}' ignoriert") + else: + # Unbekanntes Script: stillschweigend OK quittieren. + log.debug(f"gcode.script ignored: {s[:60]}") + except Exception as e: + log.warning(f"gcode.script {s[:30]}: {e}") + return "ok" + + async def handle_printer_gcode_script(self, request): + """HTTP POST /printer/gcode/script — Klipper-G-Code-Wrapper (siehe _exec_gcode_script).""" + script = "" + if request.method == "POST": + try: + body = await request.json() + if isinstance(body, dict): + script = body.get("script", "") or "" + except Exception: + pass + if not script: + script = request.rel_url.query.get("script", "") + result = await self._exec_gcode_script(script) + return web.json_response({"result": result}) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 04e3bb2..cd8bd0b 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -71,6 +71,7 @@ from bridge_spoolman import SpoolmanMixin from bridge_mqtt import MqttCallbacksMixin from bridge_ams import AmsFilamentMixin from bridge_moonraker import MoonrakerCompatMixin +from bridge_endpoints import EndpointsMixin try: @@ -90,19 +91,9 @@ log = logging.getLogger("bridge") logging.getLogger("aiohttp.access").setLevel(logging.WARNING) -# Web UI: subdirectory under web/themes//index.html +# UI theme-name validation (used in __init__); the /kx/ui asset-serving +# constants live in bridge_endpoints alongside their only consumers. _UI_THEME_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$") -# Allowed static theme files under /kx/ui/ -_KX_UI_ASSETS: dict[str, str] = { - "style.css": "text/css", - "app.js": "application/javascript", -} -# Files from lib/ are served based on their extension (no whitelist entry needed) -_KX_UI_LIB_TYPES: dict[str, str] = { - ".js": "application/javascript", - ".css": "text/css", -} -_KX_UI_TRANSLATION_RE = re.compile(r"^translations/([a-z]{2}(?:-[a-z]{2})?)\.json$") # Browser log stream (ring buffer + SSE queues + handler) lives in # bridge_logging; import the shared buffer/queues so the log-stream and @@ -119,7 +110,7 @@ from bridge_constants import KOBRA_TO_KLIPPER_STATE, MOONRAKER_VERSION, KLIPPER_ class KobraXBridge(SpoolmanMixin, MqttCallbacksMixin, AmsFilamentMixin, - MoonrakerCompatMixin): + MoonrakerCompatMixin, EndpointsMixin): def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None): self.client = client self._args = args @@ -680,2555 +671,6 @@ class KobraXBridge(SpoolmanMixin, MqttCallbacksMixin, AmsFilamentMixin, def _json_cors(self, data, status=200): return web.json_response(data, status=status, headers=self._CORS) - async def handle_kx_options(self, request): - return web.Response(status=204, headers=self._CORS) - - async def handle_kx_files(self, request): - files = self._store.list_files() - # Backfill legacy entries without stored filament metadata - # so the dialog's left side shows GCode colors instead of AMS slots. - for f in files: - needs_refresh = not f.get("gcode_filaments") - if not needs_refresh: - try: - cached = f.get("gcode_filaments") - parsed_cached = cached if isinstance(cached, list) else json.loads(cached) - needs_refresh = any("is_used" not in item for item in (parsed_cached or [])) - except Exception: - needs_refresh = True - if not needs_refresh: - continue - path = f.get("path") or "" - if not path or not os.path.isfile(path): - continue - try: - with open(path, "rb") as fh: - parsed_filaments = _extract_filament_info(fh.read()) - if parsed_filaments: - f["gcode_filaments"] = json.dumps(parsed_filaments) - self._store.update_file_filaments(f["id"], parsed_filaments) - except Exception as e: - log.debug(f"Filament metadata backfill failed for {f.get('filename')}: {e}") - # Add last job status + duration per file - jobs = self._store.list_jobs(limit=500) - last_job: dict = {} - for j in reversed(jobs): - last_job[j["gcode_file_id"]] = j - for f in files: - f["web_unverified"] = bool(f.get("web_unverified")) - lj = last_job.get(f["id"]) - f["last_print_status"] = lj["status"] if lj else None - f["last_print_duration"] = lj["duration_sec"] if lj else None - f["last_print_at"] = lj["started_at"] if lj else None - return self._json_cors({"result": files}) - - async def handle_kx_file_delete(self, request): - file_id = request.match_info["file_id"] - if self._store.delete_file(file_id): - return self._json_cors({"result": "ok"}) - return self._json_cors({"error": "not found"}, status=404) - - async def handle_kx_printer_files(self, request): - """GET /kx/printer-files - lists files on the printer's OWN internal - storage (file/listLocal MQTT action), as opposed to /kx/files which - lists what the bridge itself has stored. Needed because prints - started directly from Anycubic Slicer Next (bypassing the bridge) - leave files on the printer that were previously only visible/ - deletable from the printer's own display (Issue #102 context).""" - loop = asyncio.get_event_loop() - def _fetch(): - return self._wait_for_file_action( - "listLocal", - lambda: self.client.publish( - "file", "listLocal", - {"page_num": 1, "page_size": 200, "path": "/"}, - timeout=0, - ), - timeout=8.0, - ) - result = await loop.run_in_executor(None, _fetch) - if not result or result.get("code") != 200: - return self._json_cors({"error": "printer unreachable or query failed"}, status=502) - records = (result.get("data") or {}).get("records") or [] - files = [r for r in records if not r.get("is_dir")] - return self._json_cors({"result": files}) - - async def handle_kx_printer_file_delete(self, request): - """POST /kx/printer-files/delete - body: {"filenames": ["a.gcode", ...]}. - Single endpoint for both single and multi-select delete - the - printer's file/deleteBatch MQTT action natively accepts a list.""" - try: - body = await request.json() - except Exception: - body = {} - filenames = body.get("filenames") or [] - if not filenames: - return self._json_cors({"error": "no filenames given"}, status=400) - files = [{"path": "/", "filename": fn} for fn in filenames if fn] - loop = asyncio.get_event_loop() - def _delete(): - return self._wait_for_file_action( - "deleteBatch", - lambda: self.client.publish( - "file", "deleteBatch", - {"root": "local", "files": files}, - timeout=0, - ), - timeout=8.0, - ) - result = await loop.run_in_executor(None, _delete) - if not result or result.get("state") != "success": - return self._json_cors({"error": "delete failed", "detail": result}, status=502) - return self._json_cors({"result": "ok"}) - - async def handle_kx_printer_file_thumbnail(self, request): - """GET /kx/printer-files/{filename}/thumbnail - fetches the embedded - GCode thumbnail for a file on the printer's own storage, via - file/fileDetails. The printer extracts and base64-encodes the - "; thumbnail begin"-block from the GCode header on demand and - returns it inline in data.file_details.thumbnail - no separate - download/presigned-URL step needed (verified live against a real - Kobra X). Cached in-memory per filename since a file's thumbnail - never changes while it exists on the printer, and re-querying on - every render/scroll would mean one MQTT roundtrip per visible card.""" - filename = request.match_info.get("filename", "") - if not filename: - return self._json_cors({"error": "no filename given"}, status=400) - cached = self._printer_thumbnail_cache.get(filename) - if cached is not None: - return self._json_cors({"result": {"thumbnail": cached}}) - loop = asyncio.get_event_loop() - def _fetch(): - return self._wait_for_file_action( - "fileDetails", - lambda: self.client.publish( - "file", "fileDetails", - {"root": "local", "filename": filename}, - timeout=0, - ), - timeout=8.0, - ) - result = await loop.run_in_executor(None, _fetch) - if not result or result.get("code") != 200: - return self._json_cors({"error": "printer unreachable or query failed"}, status=502) - thumb = ((result.get("data") or {}).get("file_details") or {}).get("thumbnail") or "" - self._printer_thumbnail_cache[filename] = thumb - return self._json_cors({"result": {"thumbnail": thumb}}) - - async def handle_kx_file_download(self, request): - file_id = request.match_info["file_id"] - f = self._store.get_file(file_id) - if not f: - return self._json_cors({"error": "not found"}, status=404) - path = f.get("path") or "" - if not path or not os.path.isfile(path): - return self._json_cors({"error": "not found"}, status=404) - filename = os.path.basename(f.get("filename") or path) - # RFC 5987: filename* with URL encoding for special chars/UTF-8, - # plus ASCII fallback (strip all " and \ from filename for the - # quoted-string-Part). - ascii_fallback = filename.encode("ascii", "replace").decode("ascii").replace('"', "").replace("\\", "") - encoded = quote(filename, safe="") - disposition = f'attachment; filename="{ascii_fallback}"; filename*=UTF-8\'\'{encoded}' - return web.FileResponse(path, headers={"Content-Disposition": disposition}) - - async def handle_kx_file_verify(self, request): - file_id = request.match_info["file_id"] - if self._store.clear_web_unverified(file_id): - return self._json_cors({"result": "ok"}) - return self._json_cors({"error": "not found"}, status=404) - - async def handle_kx_filament_slots(self, request): - slots = [] - for i, s in enumerate(self._ams_slots): - gidx = int(s.get("global_index", i)) - # Stale-profile guard: only show the override while its material - # family matches the loaded AMS material (else slot has no brand). - profile = self._effective_slot_profile(gidx, s.get("type", "")) - slots.append({ - "slot_index": gidx, - "material": s.get("type", ""), - "color_hex": "#{:02X}{:02X}{:02X}".format(*s.get("color", [0,0,0])[:3]), - "status": "loaded" if s.get("status") == 5 else "empty", - "nozzle_temp": 0, - # Current user override from config.ini [filament_profiles] - # - (vendor,name) is unique, id is only a hint. - "filament_id": profile.get("id", ""), - "filament_vendor": profile.get("vendor", ""), - "filament_name": profile.get("name", ""), - }) - return self._json_cors({"result": slots}) - - async def handle_kx_filament_profiles(self, request): - """Returns the static list of OrcaSlicer filament profiles - (from bridge/data/orca_filaments.json - produced by the generator script - tools/gen_orca_filament_list.py erzeugt). - - Optional Filter via ?type=PLA / ?vendor=Polymaker. - The frontend uses this for the slot profile dropdown. - """ - type_filter = request.rel_url.query.get("type", "").upper().strip() - vendor_filter = request.rel_url.query.get("vendor", "").strip() - profiles = self._load_orca_filaments() - if type_filter: - profiles = [p for p in profiles if p.get("type", "").upper() == type_filter] - if vendor_filter: - profiles = [p for p in profiles if p.get("vendor", "") == vendor_filter] - return self._json_cors({"result": profiles}) - - async def handle_kx_filament_profiles_user_list(self, request): - """GET /kx/filament/profiles/user - only the user-imported profiles, - for the settings tab (management with delete buttons).""" - path = self._orca_filaments_user_path() - if not os.path.isfile(path): - return self._json_cors({"result": []}) - try: - with open(path, encoding="utf-8") as f: - user_profiles = json.load(f) or [] - except Exception: - user_profiles = [] - return self._json_cors({"result": user_profiles}) - - async def handle_kx_filament_profiles_import(self, request): - """POST /kx/filament/profiles/user - multipart upload with one - ZIP file or multiple `.json` files from - ~/.config/OrcaSlicer/user//filament/. - - Existing user profiles with the same (vendor, name) key are - overwritten. Parsed profiles use the same schema as - orca_filaments.json (id, name, vendor, type, color).""" - import io, zipfile - from orca_filaments import parse_profile_bytes - added: list[dict] = [] - skipped: int = 0 - # System index for inherits resolution: user profiles reference - # System-Parents via "inherits" (z.B. "Generic PLA @System"). Damit - # we can pull filament_id/vendor/type/color from the system parent - # when the user profile does not set them itself. - sys_idx = [p for p in self._load_orca_filaments() if not p.get("is_user")] - try: - reader = await request.multipart() - except Exception: - return self._json_cors({"error": "expected multipart"}, status=400) - async for part in reader: - if part.name not in ("file", "files", "upload"): - continue - blob = await part.read() - fn = (part.filename or "").lower() - if fn.endswith(".zip"): - try: - with zipfile.ZipFile(io.BytesIO(blob)) as zf: - for inner in zf.namelist(): - if not inner.lower().endswith(".json"): - continue - try: - with zf.open(inner) as zf_in: - p = parse_profile_bytes(zf_in.read(), source_name=inner, system_index=sys_idx) - except Exception: - skipped += 1 - continue - if p: - added.append(p) - else: - skipped += 1 - except zipfile.BadZipFile: - return self._json_cors({"error": "bad zip"}, status=400) - elif fn.endswith(".json"): - p = parse_profile_bytes(blob, source_name=fn, system_index=sys_idx) - if p: - added.append(p) - else: - skipped += 1 - - if not added: - return self._json_cors({"result": "ok", "added": 0, "skipped": skipped}) - - # Merge with existing user JSON (same (vendor,name) -> replace) - path = self._orca_filaments_user_path() - existing: list[dict] = [] - if os.path.isfile(path): - try: - with open(path, encoding="utf-8") as f: - existing = json.load(f) or [] - except Exception: - existing = [] - by_key = {(p.get("vendor"), p.get("name")): p for p in existing} - for p in added: - by_key[(p.get("vendor"), p.get("name"))] = p - merged = sorted(by_key.values(), key=lambda x: (x.get("vendor",""), x.get("name",""))) - try: - with open(path, "w", encoding="utf-8") as f: - json.dump(merged, f, indent=2, ensure_ascii=False) - f.write("\n") - except Exception as e: - return self._json_cors({"error": f"write failed: {e}"}, status=500) - self._invalidate_filaments_cache() - return self._json_cors({"result": "ok", - "added": len(added), - "skipped": skipped, - "total_user": len(merged)}) - - async def handle_kx_filament_profiles_user_delete(self, request): - """DELETE /kx/filament/profiles/user - deletes either a single - entry (?vendor=...&name=...) or all when no query is given.""" - vendor = request.rel_url.query.get("vendor", "").strip() - name = request.rel_url.query.get("name", "").strip() - path = self._orca_filaments_user_path() - if not os.path.isfile(path): - return self._json_cors({"result": "ok", "removed": 0}) - try: - with open(path, encoding="utf-8") as f: - existing = json.load(f) or [] - except Exception: - existing = [] - before = len(existing) - if vendor and name: - existing = [p for p in existing - if not (p.get("vendor") == vendor and p.get("name") == name)] - else: - existing = [] - try: - with open(path, "w", encoding="utf-8") as f: - json.dump(existing, f, indent=2, ensure_ascii=False) - f.write("\n") - except Exception as e: - return self._json_cors({"error": str(e)}, status=500) - self._invalidate_filaments_cache() - return self._json_cors({"result": "ok", - "removed": before - len(existing), - "total_user": len(existing)}) - - def _find_orca_filaments_json(self) -> str | None: - """Finds the static JSON file. Sits next to web/ under _WEB_BASE/data/ - — in allen 3 Deployment-Modi: - • Dev: bridge/data/orca_filaments.json - * Docker: /app/data/orca_filaments.json (static in the image, NOT the - volume data/ holding runtime state - see Dockerfile) - • Onefile: sys._MEIPASS/data/orca_filaments.json - When the volume-mounted /app/data/ shadows the static data, a copy - also sits under _WEB_BASE/data/ (= /app/ in Docker = the same path). - On conflict: second lookup under ../bridge/data/ as a fallback for dev setups.""" - candidates = [ - # Docker: COPY bridge/data -> /app/static/ (data/ is a volume -> shadowed) - os.path.join(_WEB_BASE, "static", "orca_filaments.json"), - os.path.join(_WEB_BASE, "data", "orca_filaments.json"), - ] - here = os.path.dirname(os.path.abspath(__file__)) - candidates.append(os.path.join(here, "data", "orca_filaments.json")) - candidates.append(os.path.join(here, "..", "bridge", "data", "orca_filaments.json")) - for c in candidates: - if os.path.isfile(c): - return c - return None - - async def handle_kx_filament_slot_profile(self, request): - """POST /kx/filament/slots//profile - saves or deletes - a user override mapping for a single AMS slot. - - The primary selector is (vendor, name) - the ID is not unique in the Orca - data model (136 profiles share e.g. 'OGFL99'). The ID is looked up - from orca_filaments.json on save and carried along as a hint - for OrcaSlicer's `tray_info_idx`. - - Body: {"vendor": "Polymaker", "name": "PolyTerra PLA"} - {"vendor": "", "name": ""} → Mapping entfernen - (Backwards compat: {"id":..., "vendor":...} is accepted, - but `name` has been the primary selector since v0.9.18.) - """ - try: - slot_idx = int(request.match_info.get("idx", "-1")) - except ValueError: - return self._json_cors({"error": "bad slot index"}, status=400) - if slot_idx < 0: - return self._json_cors({"error": "bad slot index"}, status=400) - try: - data = await request.json() - except Exception: - data = {} - new_vendor = (data.get("vendor") or "").strip() - new_name = (data.get("name") or "").strip() - new_id = (data.get("id") or "").strip() # Backwards-Kompat-Hint - if new_vendor and new_name: - # Look up the ID from JSON (not from the request body, which could - # be stale or a generic fallback). - looked_up_id = self._lookup_filament_id(new_vendor, new_name) - self._filament_profiles[slot_idx] = { - "vendor": new_vendor, - "name": new_name, - "id": looked_up_id or new_id, - } - else: - self._filament_profiles.pop(slot_idx, None) - # Persistieren in config.ini - try: - import config_loader as _cl - _cl.save_filament_profiles(self._filament_profiles, self._printer_id) - except Exception as e: - log.warning(f"save_filament_profiles failed: {e}") - return self._json_cors({"error": str(e)}, status=500) - entry = self._filament_profiles.get(slot_idx, {}) - return self._json_cors({"result": "ok", - "slot_index": slot_idx, - "vendor": entry.get("vendor", ""), - "name": entry.get("name", ""), - "id": entry.get("id", "")}) - - async def handle_kx_visible_vendors(self, request): - """GET/POST /kx/filament/visible_vendors — Vendor-Sichtbarkeitsfilter - for the slot profile dropdown (Issue #41 option A). - - GET → {"result": ["Polymaker", "eSUN", ...]} - POST {"vendors": [...]} → speichert in config.ini [filament_profiles] - visible_vendors. Empty list = all visible. NO bridge restart - needed (display filter only).""" - if request.method == "POST": - try: - data = await request.json() - except Exception: - data = {} - vendors = data.get("vendors") or [] - if not isinstance(vendors, list): - return self._json_cors({"error": "vendors must be a list"}, status=400) - self._visible_vendors = [str(v).strip() for v in vendors if str(v).strip()] - try: - import config_loader as _cl - _cl.save_visible_vendors(self._visible_vendors, self._printer_id) - except Exception as e: - log.warning(f"save_visible_vendors failed: {e}") - return self._json_cors({"error": str(e)}, status=500) - return self._json_cors({"result": self._visible_vendors}) - - def _load_orca_filaments(self) -> list[dict]: - """Loads system + user profiles from the cache. System profiles come - from bridge/data/orca_filaments.json (image-embedded), user profiles - from /orca_filaments.user.json (volume-persistent - - survives image updates). User profiles get an `is_user: True` - flag so the frontend can mark them.""" - if getattr(self, "_orca_filaments_cache", None) is not None: - return self._orca_filaments_cache - merged: list[dict] = [] - # System - sys_path = self._find_orca_filaments_json() - if sys_path and os.path.isfile(sys_path): - try: - with open(sys_path, encoding="utf-8") as f: - merged.extend(json.load(f) or []) - except Exception as e: - log.warning(f"orca_filaments.json read error: {e}") - # User - usr_path = self._orca_filaments_user_path() - if usr_path and os.path.isfile(usr_path): - try: - with open(usr_path, encoding="utf-8") as f: - for p in (json.load(f) or []): - p["is_user"] = True - merged.append(p) - except Exception as e: - log.warning(f"orca_filaments.user.json read error: {e}") - self._orca_filaments_cache = merged - return self._orca_filaments_cache - - def _orca_filaments_user_path(self) -> str: - """Path to the user profiles JSON. Lives in the volume mount (KX_DATA_DIR) - so image updates do not destroy the data.""" - data_dir = os.environ.get("KX_DATA_DIR") or os.path.join(_WEB_BASE, "data") - os.makedirs(data_dir, exist_ok=True) - return os.path.join(data_dir, "orca_filaments.user.json") - - def _invalidate_filaments_cache(self): - self._orca_filaments_cache = None - - def _lookup_filament_id(self, vendor: str, name: str) -> str: - """Looks up the filament_id for a (vendor,name) tuple in - orca_filaments.json. Returns '' when not found.""" - for p in self._load_orca_filaments(): - if p.get("vendor") == vendor and p.get("name") == name: - return p.get("id", "") - return "" - - async def handle_kx_history(self, request): - limit = int(request.rel_url.query.get("limit", 50)) - offset = int(request.rel_url.query.get("offset", 0)) - jobs = self._store.list_jobs(limit=limit, offset=offset) - return self._json_cors({"result": jobs}) - - async def handle_kx_file_objects(self, request): - """Returns the object list + optional SVG for a file. - - GET /kx/files/{id}/objects → {"names": [...], "svg_b64": "..."} - If the file has no objects yet (old entry): querying file/fileDetails - from the printer and awaiting the response is the frontend's job - (reload after upload). Only return the database state here. - """ - fid = request.match_info.get("id", "") - f = self._store.get_file(fid) - if not f: - return self._json_cors({"error": "file not found"}, status=404) - try: - names = json.loads(f.get("objects_skip_parts") or "[]") - except Exception: - names = [] - # No objects in the store yet (fresh Orca/web upload): actively request - # file/fileDetails from the printer once. _on_file() backfills the store, - # the frontend polls this endpoint and receives the list on the next - # attempt (Issue #57 - skip parity outside the file browser too). - if not names: - fn = f.get("filename") or "" - if fn: - try: - self.client.publish("file", "fileDetails", - {"root": "local", "filename": fn}, timeout=0) - except Exception as e: - log.debug(f"fileDetails request failed: {e}") - return self._json_cors({ - "result": { - "names": names, - "svg_b64": f.get("svg_image") or "", - } - }) - - async def handle_kx_skip(self, request): - """Trigger a mid-print skip. - - POST /kx/skip body={"names": ["..", ".."]} - """ - try: - body = await request.json() - except Exception: - return self._json_cors({"error": "invalid json"}, status=400) - names = body.get("names") or [] - if not isinstance(names, list) or not all(isinstance(n, str) for n in names): - return self._json_cors({"error": "names must be list[str]"}, status=400) - try: - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, lambda: self.client.skip_objects(names)) - except Exception as e: - return self._json_cors({"error": str(e)}, status=502) - return self._json_cors({"result": "ok", "names": names}) - - def _build_skip_state_result(self) -> dict: - """Builds the combined skip state for UI endpoints.""" - filename = self._state.get("filename", "") - all_objects: list[str] = [] - svg = "" - if filename: - try: - f = self._store.get_file_by_name(filename) - if f: - all_objects = json.loads(f.get("objects_skip_parts") or "[]") - svg = f.get("svg_image") or "" - except Exception as e: - log.warning(f"skip_state lookup failed: {e}") - return { - "objects": all_objects, - "skipped": list(self._skip_state.get("skipped", [])), - "svg_b64": svg, - "ts": self._skip_state.get("ts", 0), - "filename": filename, - } - - async def handle_kx_skip_query(self, request): - """Re-request the print object list from the printer. - - POST /kx/skip/query → triggert skip/query_obj, wartet kurz auf den - async skip/report and returns the merged skip state. - """ - prev_ts = int(self._skip_state.get("ts", 0) or 0) - try: - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, lambda: self.client.query_skip_objects()) - except Exception as e: - return self._json_cors({"error": str(e)}, status=502) - - deadline = time.time() + 1.5 - while time.time() < deadline: - if int(self._skip_state.get("ts", 0) or 0) > prev_ts: - break - await asyncio.sleep(0.1) - - return self._json_cors({"result": self._build_skip_state_result()}) - - async def handle_kx_skip_state(self, request): - """Aktueller Skip-State. - - Kombiniert: - - Full object list: from the GCode store, matched via the currently - running filename (file/report at print start populated the list). - skip/query_obj only returns the already-skipped ones, - not the full list. - - Skipped: from self._skip_state (updated by skip/report). - """ - return self._json_cors({"result": self._build_skip_state_result()}) - - async def handle_kx_printers(self, request): - # Collect active printers (with IP) - active = [(pid, br) for pid, br in self._all_bridges.items() - if (br._args.printer_ip or "").strip()] - # Host for bridge_url: keep the browser view, but never export "localhost" - - # otherwise browser fetches fail when the UI is opened via the LAN IP. - host = request.host.split(":")[0] - if host in ("localhost", "127.0.0.1", "::1", "0.0.0.0"): - host = "" - out = [] - for pid, br in active: - port = getattr(br._args, "port", 7125) - # Only set a concrete bridge_url for multi-printer setups (cross-instance fetch). - # Single printer: empty bridge_url -> JS uses relative paths (same origin as the UI). - bridge_url = "" - if len(active) > 1 and host: - bridge_url = f"http://{host}:{port}" - out.append({ - "id": pid, - "name": br._state.get("printer_name") or f"Drucker {pid}", - "bridge_url": bridge_url, - "printer_ip": br._args.printer_ip, - "device_id": br._args.device_id or "", - "has_power_control": bool( - (getattr(br._args, "power_on_url", "") or "").strip() - or (getattr(br._args, "power_off_url", "") or "").strip() - ), - }) - return self._json_cors({"result": out}) - - async def handle_kx_printer_power(self, request): - """Toggles an external smart plug (e.g. Tasmota) for a printer that - has no MQTT-level power-off/standby command of its own (Issue #103). - - Just fires a plain HTTP GET at the configured power_on_url/power_off_url - - works for Tasmota's cmnd=Power%20on/off style URLs and any other - switch that exposes a GET-triggered on/off endpoint.""" - pid = str(request.match_info.get("pid", "")).strip() - br = self._all_bridges.get(pid) - if br is None: - return self._json_cors({"error": "unknown printer id"}, status=404) - try: - body = await request.json() - except Exception: - body = {} - action = str(body.get("action", "")).lower() - if action not in ("on", "off"): - return self._json_cors({"error": "action must be 'on' or 'off'"}, status=400) - url = getattr(br._args, f"power_{action}_url", "") or "" - if not url: - return self._json_cors({"error": f"no power_{action}_url configured"}, status=400) - try: - async with aiohttp.ClientSession() as session: - async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: - ok = resp.status == 200 - except Exception as e: - return self._json_cors({"error": f"power switch unreachable: {e}"}, status=502) - return self._json_cors({"result": "ok" if ok else "error", "status": "on" if action == "on" else "off"}) - - async def handle_kx_printer_power_status(self, request): - """Queries the configured smart plug for its current on/off state. - - Tries to parse a Tasmota-style {"POWER":"ON"/"OFF"} JSON body first, - falls back to a plain substring search for "ON"/"OFF" in the raw - response so other switch firmwares with a simpler status endpoint - still work.""" - pid = str(request.match_info.get("pid", "")).strip() - br = self._all_bridges.get(pid) - if br is None: - return self._json_cors({"error": "unknown printer id"}, status=404) - url = getattr(br._args, "power_status_url", "") or "" - if not url: - return self._json_cors({"error": "no power_status_url configured"}, status=400) - try: - async with aiohttp.ClientSession() as session: - async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: - text = await resp.text() - except Exception as e: - return self._json_cors({"error": f"power switch unreachable: {e}"}, status=502) - state = "unknown" - try: - data = json.loads(text) - power = str(data.get("POWER", "")).upper() - if power in ("ON", "OFF"): - state = power.lower() - except Exception: - pass - if state == "unknown": - up = text.upper() - if "ON" in up and "OFF" not in up: - state = "on" - elif "OFF" in up: - state = "off" - return self._json_cors({"state": state}) - - async def handle_kx_print(self, request): - """Print start from the GCode store with optional filament assignments.""" - try: - body = await request.json() - except Exception: - return self._json_cors({"error": "invalid json"}, status=400) - - file_id = body.get("file_id") - if not file_id: - return self._json_cors({"error": "file_id required"}, status=400) - - gcode_file = self._store.get_file(file_id) - if not gcode_file: - return self._json_cors({"error": "file not found"}, status=404) - - # filament_assignments: [{slot_index, material, color_hex}, …] - assignments = body.get("filament_assignments") - # excluded_objects: ["name1","name2",...] – Pre-Print Skip (v0.9.10) - excluded_objects = body.get("excluded_objects") or [] - if not isinstance(excluded_objects, list): - excluded_objects = [] - - if assignments: - ams_box_mapping, unused_count, invalid_count = self._build_assigned_ams_box_mapping(assignments) - if unused_count: - log.debug(f"Skipped {unused_count} unused filament assignment(s) for mode={self._filament_mode}") - if invalid_count: - log.warning(f"Ignored {invalid_count} unusable filament assignment(s) for mode={self._filament_mode}") - if not ams_box_mapping: - return self._json_cors({"error": "no usable filament assignments for current filament mode"}, status=400) - else: - # No dialog -> all occupied slots as with a normal upload print - ams_box_mapping = self._build_auto_ams_box_mapping() - - auto_leveling = int(body.get("auto_leveling", getattr(self._args, "auto_leveling", 1))) - filename = gcode_file["filename"] - file_path = gcode_file["path"] - - # Serve the file via the internal serve endpoint - url = f"http://localhost:{self._args.port}/serve/{os.path.basename(file_path)}" - - payload = self._build_print_payload( - filename, url, "", gcode_file.get("size_bytes", 0), - ams_box_mapping=ams_box_mapping, - auto_leveling=auto_leveling, - excluded_objects=excluded_objects, - ) - self._reset_skip_state(excluded_objects) - - log.info(f"KX store print start: {filename} ams={len(ams_box_mapping)} slots assignments={bool(assignments)} excluded={len(excluded_objects)}") - 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 self._json_cors({"error": "no response from printer"}, status=504) - - if excluded_objects: - loop.run_in_executor(None, lambda: self._apply_preprint_skip_after_start(excluded_objects)) - - # Start the job in the history - self._current_job_id = self._store.start_job( - gcode_file_id=gcode_file["id"], - printer_id=getattr(self._args, "device_id", "unknown"), - filament_assignments=assignments, - ) - self._current_job_filename = filename - - return self._json_cors({"result": "ok", "filename": filename}) - - # ------------------------------------------------------------------------- - # HTTP handlers - # ------------------------------------------------------------------------- - - - 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 - web_upload = 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" - elif part.name == "web_upload": - val = (await part.read()).decode("utf-8", errors="replace").strip().lower() - web_upload = 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) - - # Only allow printable files (Issue #59) - the Kobra X accepts - # only .gcode and .bgcode; .3mf uploads are not processed by the - # printer and are therefore rejected (Issue #59, @gangoke). - _allowed_ext = (".gcode", ".bgcode") - _fn_lower = (remote_filename or "").lower() - if not _fn_lower.endswith(_allowed_ext): - log.warning(f"Upload rejected (not GCode): {remote_filename}") - return web.json_response( - {"error": f"only GCode files allowed ({', '.join(_allowed_ext)})"}, - status=400, - ) - - file_md5 = hashlib.md5(file_data).hexdigest() - file_size = len(file_data) - - # Read slicer time estimate + thumbnail from GCode - est_time = _parse_gcode_estimated_time(file_data) - self._state["slicer_time"] = est_time - thumbnail_b64 = _extract_thumbnail(file_data) - gcode_filaments = _extract_filament_info(file_data) - layer_h, first_h = _parse_gcode_layer_heights(file_data) - self._state["layer_height"] = layer_h - self._state["first_layer_height"] = first_h - - # Persist the file in the GCode store - self._store.save_file( - file_id=file_md5, - filename=remote_filename, - data=file_data, - est_time_sec=est_time, - thumbnail_b64=thumbnail_b64, - gcode_filaments=gcode_filaments or None, - web_unverified=web_upload, - layer_height=layer_h, - first_layer_height=first_h, - ) - serve_path = os.path.join(self._serve_dir_path, os.path.basename(remote_filename)) - del file_data # free RAM - - self._last_uploaded_file = remote_filename - log.info(f"Upload: {remote_filename} ({file_size} bytes) md5={file_md5} -> store + printer") - - # Upload the file to the printer via HTTP (serve_path is already on 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 failed: {e}") - return web.json_response({"error": str(e)}, status=500) - - log.info(f"Upload successful: {result}") - - # Start the print with the full payload (incl. serve URL + md5 + size) - serve_url = f"http://{request.host}/serve/{remote_filename}" - - # print=true in the multipart form (Moonraker) or query string -> start print - # print=false or missing -> upload only - if not auto_print: - auto_print = request.rel_url.query.get("print", "false").lower() == "true" - - # Always request the thumbnail (printer responds async with file/report) - self._thumbnail_b64 = "" - self.client.publish("file", "fileDetails", {"root": "local", "filename": remote_filename}, timeout=0) - - self._state["last_upload_url"] = serve_url - self._state["last_upload_md5"] = file_md5 - self._state["last_upload_size"] = file_size - - if auto_print: - mismatch = self._check_filament_mismatch(gcode_filaments) - if mismatch: - log.info(f"Upload+print blocked - filament mismatch: {mismatch}") - self._state["file_ready"] = remote_filename - self._state["filament_mismatch"] = mismatch - return self._octoprint_upload_response( - request, remote_filename, - extra={"filament_mismatch": True, "mismatch_details": mismatch}, - ) - 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, gcode_filaments=gcode_filaments)) - else: - log.info(f"Upload only (print=false): {remote_filename}") - self._state["file_ready"] = remote_filename - - return self._octoprint_upload_response(request, remote_filename) - - @staticmethod - def _octoprint_upload_response(request, remote_filename: str, extra: dict | None = None): - """OctoPrint-compatible upload response (OrcaSlicer evaluates refs).""" - body = { - "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", - } - } - if extra: - body.update(extra) - return web.json_response(body, status=201) - - def _check_filament_mismatch(self, gcode_filaments: list | None) -> list[dict] | None: - """Compares GCode filaments (is_used=True) with currently occupied AMS slots. - - Returns a list of mismatch entries when at least one used - GCode slot has no matching material in the AMS - otherwise None. - Only triggered when AMS data is present (at least 1 occupied slot).""" - if not gcode_filaments: - return None - slots = self._ams_slots or [] - occupied = {s["global_index"]: s for s in slots if s.get("type") and s.get("status") == 5} - if not occupied: - return None - mismatches = [] - for f in gcode_filaments: - if not f.get("is_used"): - continue - idx = int(f.get("slot_index", -1)) - gcode_mat = (f.get("material") or "").upper().strip() - if not gcode_mat: - continue - slot = occupied.get(idx) - if slot is None: - mismatches.append({ - "slot_index": idx, - "gcode_material": gcode_mat, - "ams_material": None, - "reason": "empty", - }) - else: - ams_mat = (slot.get("type") or "").upper().strip() - if ams_mat and ams_mat != gcode_mat: - mismatches.append({ - "slot_index": idx, - "gcode_material": gcode_mat, - "ams_material": ams_mat, - "reason": "mismatch", - }) - return mismatches if mismatches else None - - def _build_print_payload(self, filename: str, url: str, md5: str, filesize: int, - ams_box_mapping: list, auto_leveling: int, - excluded_objects: list | None = None, - ai_type: int = 1, timelapse_type: int = 64) -> dict: - """Builds the complete print/start MQTT payload. Single source for all - three print start paths (upload, KX store, Moonraker API).""" - return { - "taskid": "-1", - "url": url, - "filename": filename, - "md5": md5, - "filepath": None, - "filetype": 1, - "project_type": 1, - "filesize": filesize, - "ams_settings": { - "use_ams": len(ams_box_mapping) > 0, - "ams_box_mapping": ams_box_mapping, - }, - "task_settings": { - "auto_leveling": auto_leveling, - "vibration_compensation": getattr(self._args, "vibration_compensation", 0), - "flow_calibration": 0, - "dry_mode": 0, - "ai_settings": {"status": 0, "count": 0, "type": ai_type}, - "timelapse": {"status": 0, "count": 0, "type": timelapse_type}, - "drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0}, - "model_objects_skip_parts": excluded_objects or [], - }, - } - - def _reset_skip_state(self, excluded_objects: list | None = None): - """Resets the skip state before a print start. The UI is marked as - "skipped" only after real printer confirmation.""" - self._skip_state = {"skipped": [], "ts": int(time.time())} - if excluded_objects: - self._pending_preprint_skip = [str(n) for n in excluded_objects if isinstance(n, str) and n] - self._pending_preprint_skip_deadline = time.time() + 12.0 - else: - self._pending_preprint_skip = [] - self._pending_preprint_skip_deadline = 0.0 - - def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0, - gcode_filaments: list | None = None): - self._state["file_ready"] = "" - loaded = self._select_loaded_slots_for_print(warn_on_empty_default=True) - - # Only map the paints ACTUALLY used in the GCode to slots. OrcaSlicer - # writes all configured filaments into the header (filament_colour=...;...;...), - # but often uses only one (e.g. single color -> only T3). If we mapped all - # occupied slots, the printer would expect all colors and block - # when another (unused) slot is empty. The used paint indices - # liefert _extract_filament_info via is_used (echte T-Tool-Changes). - used_paint_indices = None - if gcode_filaments: - used = [int(f["slot_index"]) for f in gcode_filaments - if f.get("is_used") and "slot_index" in f] - if used: - used_paint_indices = set(used) - - if used_paint_indices is not None: - # GCode-Paint-Index N entspricht AMS-Slot N (global_index). Nur belegte - # used slots; used-but-unloaded -> a warning may follow later. - loaded = [(gidx, s) for (gidx, s) in loaded if gidx in used_paint_indices] - - ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded) - log.debug(f"AMS slots: {len(loaded)} mapped (used paints: {used_paint_indices}) -> {[i for i, _ in loaded]}") - payload = self._build_print_payload( - filename, url, md5, filesize, - ams_box_mapping=ams_box_mapping, - auto_leveling=getattr(self._args, "auto_leveling", 1), - ) - log.info(f"print/start → {filename} url={url} ams={len(ams_box_mapping)} slots mode={self._filament_mode}") - result = self.client.publish("print", "start", payload, timeout=15.0) - if result: - log.info(f"Print start confirmed: state={result.get('state')}") - else: - log.warning("Print start: no response from printer") - - def _theme_index_path(self) -> str: - return os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, "index.html") - - def _load_index_template_cached(self) -> str: - path = self._theme_index_path() - mtime = os.path.getmtime(path) - key = (path, mtime) - if self._index_tpl_cache is not None and self._index_tpl_cache_key == key: - return self._index_tpl_cache - with open(path, "r", encoding="utf-8") as f: - self._index_tpl_cache = f.read() - self._index_tpl_cache_key = key - return self._index_tpl_cache - - def _ui_asset_cache_buster(self) -> str: - base = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme) - mt = 0.0 - for fn in ("index.html", "style.css", "app.js"): - try: - mt = max(mt, os.path.getmtime(os.path.join(base, fn))) - except OSError: - pass - return str(int(mt)) if mt else "0" - - 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"Starting print: {filename}") - - # Optional slot selection from the filament dialog - filament_assignments = body.get("filament_assignments") - # Pre-Print Skip (v0.9.10) - excluded_objects = body.get("excluded_objects") or [] - if not isinstance(excluded_objects, list): - excluded_objects = [] - - auto_leveling = int(body.get("auto_leveling", getattr(self._args, "auto_leveling", 1))) - url = self._state.get("last_upload_url", "") - filesize = self._state.get("last_upload_size", 0) - md5 = self._state.get("last_upload_md5", "") - - if filament_assignments is not None: - # Explicit slot assignment from the filament dialog - ams_box_mapping, unused_count, invalid_count = self._build_assigned_ams_box_mapping(filament_assignments) - if unused_count: - log.debug(f"Skipped {unused_count} unused filament assignment(s) for mode={self._filament_mode}") - if invalid_count: - log.warning(f"Ignored {invalid_count} unusable filament assignment(s) for mode={self._filament_mode}") - if not ams_box_mapping: - return web.json_response({"error": "no usable filament assignments for current filament mode"}, status=400) - else: - # Dashboard reprint: load gcode_filaments from DB so the used_paint_indices - # filter applies and empty/shifted slots are not mapped incorrectly. - gcode_filaments = None - try: - db_file = self._store.get_file_by_name(filename) - if db_file and db_file.get("gcode_filaments"): - gcode_filaments = json.loads(db_file["gcode_filaments"]) - except Exception as e: - log.warning(f"Could not load cached gcode_filaments for {filename}: {e} " - "- slot mapping falls back to all occupied slots") - - # Set the pre-print skip before _start_print is called - self._reset_skip_state(excluded_objects) - - log.info(f"print/start api=1 mode={self._filament_mode} assignments=False gcode_filaments={gcode_filaments is not None}") - loop = asyncio.get_event_loop() - loop.run_in_executor(None, lambda: self._start_print( - filename, url, md5, filesize, - gcode_filaments=gcode_filaments, - )) - return web.json_response({"result": "ok"}) - - payload = self._build_print_payload( - filename, url, md5, filesize, - ams_box_mapping=ams_box_mapping, - auto_leveling=auto_leveling, - excluded_objects=excluded_objects, - ai_type=0, timelapse_type=0, - ) - self._reset_skip_state(excluded_objects) - - log.info( - f"print/start api=1 mode={self._filament_mode} " - f"ams={len(ams_box_mapping)} slots assignments=True" - ) - - 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": "no response from printer"}, status=504) - - if excluded_objects: - loop.run_in_executor(None, lambda: self._apply_preprint_skip_after_start(excluded_objects)) - - 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._state["filament_mismatch"] = None - 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_kx_ui_asset(self, request): - name = request.match_info.get("name", "").lstrip("/") - ctype = _KX_UI_ASSETS.get(name) - cache_control = "public, max-age=86400" - - if ctype is not None: - path = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, name) - elif name.startswith("lib/"): - ext = os.path.splitext(name)[1].lower() - ctype = _KX_UI_LIB_TYPES.get(ext) - if not ctype: - raise web.HTTPNotFound() - path = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, name) - else: - m = _KX_UI_TRANSLATION_RE.match(name) - if not m: - raise web.HTTPNotFound() - lang = m.group(1) - ctype = "application/json" - cache_control = "no-store" - path = os.path.join(_WEB_BASE, "web", "translations", f"{lang}.json") - - try: - raw = pathlib.Path(path).read_text(encoding="utf-8") - except OSError: - raise web.HTTPNotFound() - if name == "app.js": - raw = raw.replace("'__VERSION__'", f"'{self._read_version()}'") - return web.Response( - text=raw, - content_type=ctype, - headers={"Cache-Control": cache_control}, - ) - - async def handle_index(self, request): - try: - tpl = self._load_index_template_cached() - except OSError: - p = self._theme_index_path() - log.error("Web UI theme file missing or unreadable: %s (theme: %s)", p, self._ui_theme) - return web.Response( - text="
KX-Bridge: index.html not found.\nExpected:\n"
-                + html.escape(p, quote=True)
-                + "
", - status=500, - content_type="text/html; charset=utf-8", - ) - page = tpl.replace("__UI_ASSETS_VER__", self._ui_asset_cache_buster()) - - # Embed CSS + JS INLINE instead of just linking. OrcaSlicer's - # embedded device tab webview does NOT load external /") - except OSError: - pass - - _inline_css("lib/gridstack.min.css", '') - _inline_js("lib/gridstack-all.min.js", '') - _inline_css("style.css", '') - _inline_js("app.js", '', version_sub=True) - - return web.Response(text=page, 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("Connected manually") - 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_restart(self, request): - log.info("Restart requested via API") - response = web.json_response({"status": "restarting"}) - asyncio.get_event_loop().call_later(0.3, self._restart_bridge) - return response - - 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)) # global slot index - 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) - box_id, local_slot = self._global_to_box_slot(index) - loop = asyncio.get_event_loop() - self._state["last_ams_set_error"] = False - # Remembered so a later state="failed" report (which carries no slot - # info of its own, see _on_multicolor_box) can be logged alongside the - # request that triggered it - otherwise the failure is unattributable. - self._last_ams_set_request = {"global": index, "box": box_id, "local_slot": local_slot, "type": mat, "color": color} - # setInfo goes via the web/printer topic (like tempature/set). Verified via - # Workbench-Vue mqtt_setInfo verifiziert — via slicer/printer/ wurden - # slot changes are ignored by the printer and overwritten with the old - # material on the next multiColorBox/report. - def _send(): - self.client.publish_web( - "multiColorBox", "setInfo", - {"multi_color_box": [{"id": box_id, "slots": [{"index": local_slot, "type": mat, "color": color}]}]}, - ) - log.info(f"setInfo (web) global={index} box={box_id} local_slot={local_slot} type={mat} color={color}") - await loop.run_in_executor(None, _send) - # Optimistisches Update: cached slot sofort anpassen (Drucker echoed - # gleich via multiColorBox/report — falls er den Befehl ignoriert, - # the report overwrites it again). - for s in self._ams_slots: - if s.get("global_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)) - if feed_type == 1: - self._pending_load_slot = slot_index - # Feed-out (type=2): if no slot was explicitly chosen, use the last loaded one - if feed_type == 2 and self._ams_loaded_slot >= 0: - slot_index = self._ams_loaded_slot - box_id, local_slot = self._global_to_box_slot(slot_index) - loop = asyncio.get_event_loop() - def _send(): - resp = self.client.publish( - "multiColorBox", "feedFilament", - {"multi_color_box": [{"id": box_id, "feed_status": {"slot_index": local_slot, "type": feed_type}}]}, - timeout=5 - ) - log.info(f"feedFilament type={feed_type} global_slot={slot_index} box={box_id} local_slot={local_slot} loaded_slot={self._ams_loaded_slot} → {resp}") - await loop.run_in_executor(None, _send) - return web.json_response({"result": "ok"}) - - async def handle_api_ace_auto_feed(self, request): - try: - body = await request.json() - except Exception: - body = {} - - ace_id_raw = body.get("ace_id", None) - on_raw = body.get("on", None) - if ace_id_raw is None or on_raw is None: - return web.json_response({"error": "ace_id and on are required"}, status=400) - try: - ace_id = int(ace_id_raw) - on = int(bool(on_raw)) - except Exception: - return web.json_response({"error": "invalid parameters"}, status=400) - if not (0 <= ace_id <= 3): - return web.json_response({"error": "ace_id must be 0-3"}, status=400) - - payload = {"multi_color_box": [{"id": ace_id, "auto_feed": on}]} - loop = asyncio.get_event_loop() - # Fire-and-forget: setAutoFeed ACK arrives via multiColorBox/report callback. - # Waiting for a response on that busy push topic causes false "code:0" rejections. - await loop.run_in_executor( - None, - lambda: self.client.publish("multiColorBox", "setAutoFeed", payload, timeout=0) - ) - self._ace_auto_feed[ace_id] = on - self._state_dirty = True - return web.json_response({"result": "ok", "ace_id": ace_id, "auto_feed": on}) - - async def handle_api_ace_dry(self, request): - try: - body = await request.json() - except Exception: - body = {} - - action = str(body.get("action", "start")).lower() - if action not in ("start", "stop"): - return web.json_response({"error": "action must be 'start' or 'stop'"}, status=400) - - ace_ids = [i for i in self._ace_box_ids if 0 <= i <= 3] - if not ace_ids: - ace_ids = sorted({ - int(s.get("box_id", -1)) - for s in self._ams_slots - if 0 <= int(s.get("box_id", -1)) <= 3 - }) - if not ace_ids and self._state.get("filament_mode") != "toolhead": - ace_ids = [0] - if not ace_ids: - return web.json_response({"error": "ACE not detected"}, status=400) - - ace_id_raw = body.get("ace_id", None) - if ace_id_raw is not None: - try: - ace_id = int(ace_id_raw) - except Exception: - return web.json_response({"error": "ace_id must be an integer"}, status=400) - if ace_id not in ace_ids: - return web.json_response({"error": f"ACE {ace_id + 1} not detected"}, status=400) - ace_ids = [ace_id] - - if action == "start": - target_temp = int(body.get("target_temp", 45)) - duration = int(body.get("duration", 240)) - target_temp = max(30, min(80, target_temp)) - duration = max(10, min(24 * 60, duration)) - humidity = (self._state.get("ace_drying") or {}).get("humidity") - current_temp = (self._state.get("ace_drying") or {}).get("current_temp") - drying_status = { - "status": 1, - "target_temp": target_temp, - "duration": duration, - "remain_time": duration, - } - ui_state = { - "status": 1, - "target_temp": target_temp, - "duration": duration, - "remain_time": duration, - "humidity": humidity, - "current_temp": current_temp, - } - else: - drying_status = {"status": 0} - humidity = (self._state.get("ace_drying") or {}).get("humidity") - current_temp = (self._state.get("ace_drying") or {}).get("current_temp") - ui_state = { - "status": 0, - "target_temp": 0, - "duration": 0, - "remain_time": 0, - "humidity": humidity, - "current_temp": current_temp, - } - - payload = { - "multi_color_box": [ - {"id": bid, "drying_status": dict(drying_status)} - for bid in ace_ids - ] - } - - loop = asyncio.get_event_loop() - - def _send(): - return self.client.publish("multiColorBox", "setDry", payload, timeout=0) - # Fire-and-forget: setDry ACK arrives via multiColorBox/report callback. - # Waiting for a response on that busy push topic causes false "code:0" rejections. - await loop.run_in_executor(None, _send) - - self._state["ace_drying"] = ui_state - self._state_dirty = True - return web.json_response({"result": "ok"}) - - async def handle_api_axis(self, request): - try: - body = await request.json() - except Exception: - body = {} - - loop = asyncio.get_event_loop() - action = str(body.get("action", "")).lower() - - 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: tempature/set via the `web/printer` topic with a `type` field. - # Confirmed by live sniffing the Anycubic Slicer Next on 2026-05-29: - # topic = web/printer/.../tempature - # data = {"type": 0|1|2, "target_hotbed_temp": B, "target_nozzle_temp": N} - # type values (from Workbench Vue): 0=nozzle, 1=bed, 2=both. - # Ohne `type` ODER auf `slicer/printer`-Topic → Systemfehler am Drucker. - if nozzle is not None and bed is not None: - t, n, b = 2, int(float(nozzle)), int(float(bed)) - elif nozzle is not None: - t, n, b = 0, int(float(nozzle)), 0 - elif bed is not None: - t, n, b = 1, 0, int(float(bed)) - else: - return web.json_response({"result": "ok"}) - await loop.run_in_executor(None, lambda: self.client.publish_web( - "tempature", "set", - {"type": t, "target_nozzle_temp": n, "target_hotbed_temp": b}, - )) - 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"Camera 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 - )) - # Prevents the auto-start guard from restarting the camera during the - # laufenden Drucks wieder einschaltet (State-Flicker-Problem). - self._camera_user_stopped = True - return web.json_response({"result": "ok"}) - - async def handle_api_camera_reset(self, request): - """Reset the backoff counter and restart ffmpeg immediately. - Useful after a 429 lock (Retry-After expired) or after a printer restart.""" - self.camera_cache.reset() - url = self._state.get("camera_url", "") - if not url: - log.warning("Camera reset requested but no camera_url is known yet (waiting for printer status)") - return web.json_response({ - "result": "no_url", - "message": "No camera URL known yet - wait for the next printer status update, or start a print/enable the camera first.", - }) - self.camera_cache.set_url(url) - await self.camera_cache.ensure_running() - return web.json_response({"result": "ok", "url": url}) - - async def handle_api_camera_snapshot(self, request): - """Last JPEG frame from the CameraCache - instant from RAM, - no separate ffmpeg instance anymore (prevents the single-client 429 at the - printer and is ~1 s faster).""" - url = self._state.get("camera_url", "") - if not url: - return web.Response(status=503, text="No camera URL known") - self.camera_cache.set_url(url) - await self.camera_cache.ensure_running() - # Initial warmup: wait up to 5s for the first frame - deadline = time.time() + 5.0 - while not self.camera_cache.latest_jpeg and time.time() < deadline: - await asyncio.sleep(0.1) - jpeg = self.camera_cache.latest_jpeg - if not jpeg: - return web.Response(status=503, text="No frame in cache yet") - # If the last frame is older than 10 s -> the cache ffmpeg is probably - # no longer running stably; deliver anyway but with a stale header. - age = time.time() - self.camera_cache.latest_jpeg_ts - headers = {"Cache-Control": "no-cache"} - if age > 10: - headers["X-Frame-Age"] = f"{age:.1f}" - return web.Response(body=jpeg, content_type="image/jpeg", headers=headers) - - async def handle_camera_stream(self, request): - """MJPEG live view, served as multipart/x-mixed-replace. - - Fed from the central CameraCache fanout (same pattern as - handle_camera_h264) instead of spawning a dedicated ffmpeg process - per HTTP client. The printer's camera server only tolerates a very - limited number of concurrent connections (see CameraCache docstring) - - previously every consumer of this endpoint (dashboard, OrcaSlicer, - moonraker-obico, a second browser tab, ...) opened its own separate - connection, so two simultaneous viewers could already exhaust the - printer's connection limit and cause intermittent "stream - unavailable" failures. Now all consumers share one connection. - """ - url = self._state.get("camera_url", "") - if not url: - return web.Response(status=503, text="No camera URL known") - self.camera_cache.set_url(url) - await self.camera_cache.ensure_running() - - q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=8) - self.camera_cache.mjpeg_subscribers.add(q) - - # Wait for the first frame BEFORE resp.prepare() - once prepare() sends - # the response headers the status is committed to 200, so a stalled - # source (Issue #99) must be caught here to actually return a 503 - # instead of hanging the client forever with no frame ever arriving. - try: - first_frame = await asyncio.wait_for(q.get(), timeout=5.0) - except asyncio.TimeoutError: - self.camera_cache.mjpeg_subscribers.discard(q) - return web.Response(status=503, text="No frame in cache yet") - - 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) - try: - frame = first_frame - while True: - 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 (ConnectionResetError, asyncio.CancelledError): - break - except Exception: - break - frame = await q.get() - except Exception as e: - log.warning(f"Camera stream interrupted: {e}") - finally: - self.camera_cache.mjpeg_subscribers.discard(q) - - return resp - - async def handle_camera_h264(self, request): - """H.264 passthrough as MPEG-TS, fed from the central - CameraCache fanout. Allows multiple parallel consumers without an - additional FLV connection to the printer (single-client limit).""" - url = self._state.get("camera_url", "") - if not url: - return web.Response(status=503, text="No camera URL known") - self.camera_cache.set_url(url) - await self.camera_cache.ensure_running() - - q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=64) - self.camera_cache.h264_subscribers.add(q) - - resp = web.StreamResponse(headers={ - "Content-Type": "video/mp2t", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - }) - await resp.prepare(request) - try: - while True: - chunk = await q.get() - try: - await resp.write(chunk) - except (ConnectionResetError, asyncio.CancelledError): - break - except Exception as e: - log.warning(f"H.264-Stream unterbrochen: {e}") - finally: - self.camera_cache.h264_subscribers.discard(q) - return resp - - async def handle_serve_file(self, request): - """Serves uploaded G-code files from the temp directory (for printer 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"Printer downloading file: {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 - # Slicer time + thumbnail are only transient in state (set during upload). - # After a browser reload or an OrcaSlicer direct print (file did not come - # through the UI upload) they are missing -> restore from the GCode store via the - # laufenden Dateinamens nachladen. - slicer_time = s["slicer_time"] - thumbnail = self._thumbnail_b64 - fname = s.get("filename", "") - if fname and (not slicer_time or not thumbnail): - try: - gf = self._store.get_file_by_name(fname) - if gf: - if not slicer_time and gf.get("est_print_time_sec"): - slicer_time = int(gf["est_print_time_sec"]) - if not thumbnail and gf.get("thumbnail_b64"): - thumbnail = gf["thumbnail_b64"] - except Exception: - pass - 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"], - "z_mm": self._estimate_current_z(), - "filename": s["filename"], - "slicer_time": slicer_time, - "camera_url": s["camera_url"], - "fan_speed": s["fan_speed"], - "print_speed_mode": s["print_speed_mode"], - "auto_leveling": getattr(self._args, "auto_leveling", 1), - "vibration_compensation": getattr(self._args, "vibration_compensation", 0), - "camera_on_print": getattr(self._args, "camera_on_print", 0), - "web_upload_warning": getattr(self._args, "web_upload_warning", 1), - "light_on": s["light_on"], - "light_brightness": s["light_brightness"], - "ams_slots": self._ams_slots, - "ams_loaded_slot": self._ams_loaded_slot, - "filament_mode": s.get("filament_mode", self._filament_mode), - "ace_drying": s.get("ace_drying", {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None}), - "ace_units": list(self._ace_box_ids), - "ace_auto_feed": dict(self._ace_auto_feed), - "ace_dry_presets": self._ace_dry_presets, - "thumbnail": thumbnail, - "connection_error": s["connection_error"], - "file_ready": s["file_ready"], - "print_start_dialog": s.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)), - "version": self._read_version(), - "pause_msg": s.get("pause_msg", ""), - "error_code": s.get("error_code", 0), - "storage_total_mb": s.get("storage_total_mb", 0), - "storage_used_mb": s.get("storage_used_mb", 0), - }) - - 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} - }) - - # mainsail/presets: Obico asks for temperature presets. The schema is evaluated in - # find_all_thermal_presets as data['value']['presets'].values(), - # so we need at least {presets: {}} to avoid a crash. - if namespace == "mainsail": - if key == "presets": - return web.json_response({ - "result": {"namespace": "mainsail", "key": "presets", - "value": {"presets": {}}} - }) - return web.json_response({ - "result": {"namespace": "mainsail", "key": key, "value": {}} - }) - - # obico namespace: in-memory KV store for plugin settings (key=printer_id etc.) - if namespace == "obico": - store = self._moonraker_kv_store.setdefault("obico", {}) - if key and key in store: - return web.json_response({ - "result": {"namespace": "obico", "key": key, "value": store[key]} - }) - return web.json_response({ - "result": {"namespace": "obico", "key": key, "value": store if not key else None} - }) - - return web.json_response( - {"error": {"code": 404, "message": f"Namespace '{namespace}' not found"}}, - status=404 - ) - - async def handle_moonraker_database_post(self, request): - """POST /server/database/item — KV-Store-Write (von moonraker-obico verwendet). - moonraker-obico sends namespace/key/value as form-urlencoded POST params.""" - # Versuche JSON, fallback auf form-data, fallback auf Query-Params - namespace = "" - key = "" - value = None - try: - data = await request.json() - if isinstance(data, dict): - namespace = data.get("namespace", "") - key = data.get("key", "") - value = data.get("value") - except Exception: - try: - form = await request.post() - namespace = form.get("namespace", "") or "" - key = form.get("key", "") or "" - value = form.get("value") - except Exception: - pass - if not namespace: - namespace = request.rel_url.query.get("namespace", "") - if not key: - key = request.rel_url.query.get("key", "") - if namespace and key: - store = self._moonraker_kv_store.setdefault(namespace, {}) - store[key] = value - return web.json_response({ - "result": {"namespace": namespace, "key": key, "value": value} - }) - return web.json_response({"error": {"code": 400, "message": "namespace + key required"}}, status=400) - - async def handle_database_list(self, request): - """OrcaSlicer checks which namespaces exist to detect the MMU type.""" - return web.json_response({"result": {"namespaces": ["lane_data", "mainsail", "obico"]}}) - - 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"): - data = resp["data"] - self._head_tools_model = int(data.get("head_tools_model", self._head_tools_model)) - boxes = data.get("multi_color_box") or [] - if boxes: - self._update_ace_drying_state(data, boxes) - self._filament_mode = self._detect_filament_mode(boxes, self._head_tools_model) - self._state["filament_mode"] = self._filament_mode - global_slots, global_loaded = self._aggregate_slots(boxes, self._filament_mode) - activity_map = self._slot_activity_map(boxes, global_loaded) - for s in global_slots: - s["activity"] = activity_map.get(s.get("global_index"), "") - if global_slots: - self._ams_slots = global_slots - self._ams_loaded_slot = global_loaded - return self._ams_slots - - # ─── Settings ──────────────────────────────────────────────────────────── - - def _find_config_path(self) -> pathlib.Path: - """Returns the path to config.ini.""" - if hasattr(env_loader, "find_config_path"): - return env_loader.find_config_path() - # Fallback for the old 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_name": self._state.get("printer_name", ""), - "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, - "power_on_url": getattr(self._args, "power_on_url", "") or "", - "power_off_url": getattr(self._args, "power_off_url", "") or "", - "power_status_url": getattr(self._args, "power_status_url", "") or "", - "default_ams_slot": getattr(self._args, "default_ams_slot", "auto"), - "auto_leveling": getattr(self._args, "auto_leveling", 1), - "vibration_compensation": getattr(self._args, "vibration_compensation", 0), - "camera_on_print": getattr(self._args, "camera_on_print", 0), - "web_upload_warning": getattr(self._args, "web_upload_warning", 1), - "delete_printer_file_after_print": getattr(self._args, "delete_printer_file_after_print", 0), - "print_start_dialog": getattr(self._args, "print_start_dialog", 1), - "poll_interval": getattr(self._args, "poll_interval", 3), - "verbose_http_log": getattr(self._args, "verbose_http_log", 0), - "filament_profiles": {str(k): v for k, v in self._filament_profiles.items()}, - "visible_vendors": self._visible_vendors, - "ace_dry_presets": self._ace_dry_presets, - "spoolman_server": getattr(self._args, "spoolman_server", "") or "", - "spoolman_sync_rate": getattr(self._args, "spoolman_sync_rate", 0), - }) - - async def handle_api_settings_post(self, request): - import configparser - try: - data = await request.json() - except Exception: - return self._json_cors({"error": "invalid json"}, status=400) - config_path = self._find_config_path() - config_path.parent.mkdir(parents=True, exist_ok=True) - - # Read the existing config.ini (comments are lost, but values are kept) - cfg = configparser.ConfigParser(interpolation=None) - if config_path.is_file(): - cfg.read(config_path, encoding="utf-8") - - # Sections sicherstellen - for section in ("connection", "print", "bridge", "ace_dry_presets", "spoolman"): - 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("connection", "power_on_url", str(data.get("power_on_url", getattr(self._args, "power_on_url", "") or "")).strip()) - cfg.set("connection", "power_off_url", str(data.get("power_off_url", getattr(self._args, "power_off_url", "") or "")).strip()) - cfg.set("connection", "power_status_url", str(data.get("power_status_url", getattr(self._args, "power_status_url", "") or "")).strip()) - 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)))) - cfg.set("print", "vibration_compensation", str(int(bool(data.get("vibration_compensation", getattr(self._args, "vibration_compensation", 0)))))) - cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0)))))) - cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1)))))) - cfg.set("print", "delete_printer_file_after_print", str(int(bool(data.get("delete_printer_file_after_print", getattr(self._args, "delete_printer_file_after_print", 0)))))) - cfg.set("print", "print_start_dialog", str(int(bool(data.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)))))) - if "poll_interval" in data: - try: - pi = max(1, min(60, int(data["poll_interval"]))) - except (TypeError, ValueError): - pi = 3 - cfg.set("bridge", "poll_interval", str(pi)) - elif not cfg.has_option("bridge", "poll_interval"): - cfg.set("bridge", "poll_interval", "3") - verbose_http_log = int(bool(data.get("verbose_http_log", getattr(self._args, "verbose_http_log", 0)))) - cfg.set("bridge", "verbose_http_log", str(verbose_http_log)) - _set_verbose_http_log(bool(verbose_http_log)) - self._args.verbose_http_log = verbose_http_log - printer_name = str(data.get("printer_name", "")).strip() - if printer_name: - cfg.set("bridge", "printer_name", printer_name) - elif cfg.has_option("bridge", "printer_name"): - cfg.remove_option("bridge", "printer_name") - - # Spoolman - if "spoolman_server" in data: - cfg.set("spoolman", "server", str(data["spoolman_server"]).strip()) - if "spoolman_sync_rate" in data: - try: - sr = max(0, int(data["spoolman_sync_rate"])) - except (TypeError, ValueError): - sr = 30 - cfg.set("spoolman", "sync_rate", str(sr)) - - incoming_presets = data.get("ace_dry_presets") if isinstance(data, dict) else None - presets = self._sanitize_ace_dry_presets(incoming_presets if isinstance(incoming_presets, dict) else self._ace_dry_presets) - for key, val in presets.items(): - cfg.set("ace_dry_presets", f"{key}_temp", str(val["temp"])) - cfg.set("ace_dry_presets", f"{key}_duration_sec", str(val["duration_sec"])) - if key.startswith("custom_"): - cfg.set("ace_dry_presets", f"{key}_name", str(val.get("name", key.replace("_", " ").title()))) - self._ace_dry_presets = presets - - with open(config_path, "w", encoding="utf-8") as f: - f.write("# KX-Bridge Konfigurationsdatei\n\n") - cfg.write(f) - log.info(f"Settings saved to {config_path}") - # Send the response, then restart - response = web.json_response({"status": "restarting"}) - asyncio.get_event_loop().call_later(0.3, self._restart_bridge) - return response - - async def handle_kx_printer_add(self, request): - """Adds a printer: fetches credentials via IP, writes [printer_N], restarts.""" - try: - body = await request.json() - except Exception: - return self._json_cors({"error": "invalid json"}, status=400) - ip = str(body.get("printer_ip", "")).strip().split(":")[0] - name = str(body.get("name", "")).strip() - if not ip: - return self._json_cors({"error": "printer_ip required"}, status=400) - try: - creds = await _kx_fetch_credentials(ip) - except Exception as e: - return self._json_cors({"error": f"printer unreachable or error: {e}"}, status=502) - - import configparser - config_path = self._find_config_path() - cfg = configparser.ConfigParser(interpolation=None) - if config_path.is_file(): - cfg.read(config_path, encoding="utf-8") - - # Vorhandene [printer_N]-Sektionen + belegte http_ports ermitteln - n = 1 - existing_ports: set[int] = set() - while cfg.has_section(f"printer_{n}"): - p = cfg[f"printer_{n}"] - if p.get("http_port"): - try: - existing_ports.add(int(p["http_port"])) - except ValueError: - pass - n += 1 - - # No [printer_N], but a populated [connection]? -> migrate as printer_1 - # (empty [connection] = no existing printer -> don't migrate, the new one becomes printer_1) - if n == 1 and cfg.has_section("connection") and (cfg["connection"].get("printer_ip") or "").strip(): - c = cfg["connection"] - cfg.add_section("printer_1") - cfg.set("printer_1", "name", self._state.get("printer_name") or "Kobra X") - for k in ("printer_ip", "mqtt_port", "username", "password", "mode_id", "device_id"): - if c.get(k): - cfg.set("printer_1", k, c.get(k)) - cfg.set("printer_1", "http_port", "7125") - existing_ports.add(7125) - n = 2 - - # Create the new printer as [printer_n], pick a free port - new_port = 7125 + (n - 1) - while new_port in existing_ports: - new_port += 1 - sec = f"printer_{n}" - cfg.add_section(sec) - cfg.set(sec, "name", name or creds["model"]) - cfg.set(sec, "printer_ip", creds["printer_ip"]) - cfg.set(sec, "mqtt_port", "9883") - cfg.set(sec, "username", creds["username"]) - cfg.set(sec, "password", creds["password"]) - cfg.set(sec, "mode_id", creds["mode_id"]) - cfg.set(sec, "device_id", creds["device_id"]) - cfg.set(sec, "http_port", str(new_port)) - - config_path.parent.mkdir(parents=True, exist_ok=True) - with open(config_path, "w", encoding="utf-8") as f: - f.write("# KX-Bridge Konfigurationsdatei\n\n") - cfg.write(f) - log.info(f"Printer '{name or creds['model']}' added as {sec} (port {new_port})") - response = self._json_cors({"status": "restarting", "section": sec, "http_port": new_port}) - asyncio.get_event_loop().call_later(0.5, self._restart_bridge) - return response - - async def handle_kx_printer_remove(self, request): - """Removes a printer from config.ini, then restarts. - - - Multi mode: [printer_N] is deleted, the rest renumbered (printer_3 -> printer_2), - printer_1 bekommt immer http_port 7125. - - Single mode (no [printer_N], only [connection]): pid "1" clears the [connection] block - → Bridge startet im Offline-Modus auf 7125, UI bleibt erreichbar. - - When the last [printer_N] is removed: all gone -> also the "empty" state. - """ - pid = str(request.match_info.get("pid", "")).strip() - if not pid: - return self._json_cors({"error": "printer id required"}, status=400) - - import configparser - config_path = self._find_config_path() - cfg = configparser.ConfigParser(interpolation=None) - if config_path.is_file(): - cfg.read(config_path, encoding="utf-8") - - has_printer_sections = cfg.has_section("printer_1") - target = f"printer_{pid}" - - if has_printer_sections: - if not cfg.has_section(target): - return self._json_cors({"error": f"{target} not found"}, status=404) - # Collect all [printer_N] (except the one being deleted), renumber - kept = [] - n = 1 - while cfg.has_section(f"printer_{n}"): - if str(n) != pid: - kept.append(dict(cfg[f"printer_{n}"])) - cfg.remove_section(f"printer_{n}") - n += 1 - for i, sec_data in enumerate(kept, start=1): - sec = f"printer_{i}" - cfg.add_section(sec) - for k, v in sec_data.items(): - cfg.set(sec, k, v) - cfg.set(sec, "http_port", str(7125 + i - 1)) - remaining = len(kept) - # Was that the last printer? Then also clear [connection] -> truly "no printer" - if remaining == 0 and cfg.has_section("connection"): - for k in ("printer_ip", "username", "password", "device_id"): - cfg.set("connection", k, "") - else: - # Single mode: only pid "1" is valid (pseudo entry from handle_kx_printers) - if pid != "1": - return self._json_cors({"error": "no printer with this ID"}, status=404) - # Clear [connection] values -> bridge starts without a printer - if cfg.has_section("connection"): - for k in ("printer_ip", "username", "password", "device_id"): - cfg.set("connection", k, "") - remaining = 0 - - config_path.parent.mkdir(parents=True, exist_ok=True) - with open(config_path, "w", encoding="utf-8") as f: - f.write("# KX-Bridge Konfigurationsdatei\n\n") - cfg.write(f) - log.info(f"Printer {target} removed ({remaining} remaining)") - response = self._json_cors({"status": "restarting", "removed": target, "remaining": remaining}) - asyncio.get_event_loop().call_later(0.5, self._restart_bridge) - return response - - def _restart_bridge(self): - log.info("Restarting bridge...") - # config_loader caches config.ini values in os.environ ("only if not set"). - # On restart, environ must be cleaned, otherwise the new process reads - # the old values instead of the modified config.ini. Keys are derived - # from config_loader.CONFIG_ENV_MAPPING (single source of truth) so a - # newly added setting can never be forgotten here again. - try: - import config_loader as _cl - _restart_env_keys = set(_cl.CONFIG_ENV_MAPPING.keys()) | {"FILE_READY_DIALOG"} - except Exception: - _restart_env_keys = () - for _k in _restart_env_keys: - os.environ.pop(_k, None) - - in_docker = os.path.exists("/.dockerenv") or os.environ.get("KX_IN_DOCKER") - if in_docker: - # Docker/systemd: exiting the process is enough - the supervisor restarts (fresh environ) - log.info("Container environment detected – exiting for supervisor restart") - os._exit(0) - - frozen = getattr(sys, "frozen", False) - - # Linux: os.execv replaces the process image directly - clean even with PyInstaller onefile - # (subprocess+exit would fail there on the deleted _MEIxxxx temp directory). - if sys.platform != "win32": - exe = sys.executable - try: - if frozen: - os.execv(exe, [exe] + sys.argv[1:]) - else: - os.execv(exe, [exe] + sys.argv) - except Exception as e: - log.error(f"Restart (execv) failed: {e} - please restart the bridge manually") - os._exit(1) - - # Windows: os.execv is broken there (new PID, old process returns) -> subprocess - cmd = ([sys.executable] + sys.argv[1:]) if frozen else ([sys.executable] + sys.argv) - try: - subprocess.Popen(cmd, cwd=os.getcwd(), - creationflags=(subprocess.DETACHED_PROCESS - | subprocess.CREATE_NEW_PROCESS_GROUP)) - except Exception as e: - log.error(f"Restart failed: {e} - please restart the bridge manually") - os._exit(0) - - # ─── Update ────────────────────────────────────────────────────────────── - - # limit=1 would only ever see the single newest release regardless of type - - # if that happens to be a nightly/dev prerelease (the common case, since - # those publish far more often than stable), the stable_releases filter - # below finds nothing and update checks fail with "no stable releases - # found" even though older stable releases exist (Issue #104). - STABLE_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=20" - NIGHTLY_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=5&pre-release=true" - 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: - # PyInstaller onefile unpacks VERSION (via kx-bridge.spec datas) to - # sys._MEIPASS - therefore use _WEB_BASE instead of _BASE. - for base in (pathlib.Path(_WEB_BASE), 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) - only numeric parts before the first '-'""" - 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: streams log entries live to the 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): - """Returns all buffered log entries as plaintext for download.""" - header = (f"# KX-Bridge Log | Version {self._read_version()} | " - f"{time.strftime('%Y-%m-%d %H:%M:%S')} | {len(_log_buffer)} entries\n") - lines = [f"[{e['ts']}] {e['lvl']:<7} {e['name']}: {e['msg']}" for e in _log_buffer] - text = header + "\n".join(lines) + "\n" - fname = f"kx-bridge-log_{time.strftime('%Y%m%d-%H%M%S')}.txt" - return web.Response( - body=text.encode("utf-8"), - content_type="text/plain", - headers={"Content-Disposition": f'attachment; filename="{fname}"'}, - ) - - async def handle_api_update_check(self, request): - current = self._read_version() - is_nightly = "nightly" in current - is_dev = "-dev+" in current - if is_nightly: - api_url = self.NIGHTLY_RELEASE_API - elif is_dev: - api_url = self.DEV_RELEASE_API - else: - api_url = 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": "no releases found"}, status=404) - - if is_nightly: - # Find the newest prerelease with a nightly tag - nightly_releases = [r for r in releases if r.get("prerelease") and "nightly" in r.get("tag_name", "")] - if not nightly_releases: - return web.json_response({"error": "no nightly releases found"}, status=404) - data = nightly_releases[0] - tag = data.get("tag_name", "") - # Tag-Format: "nightly-0.9.27-nightly4", current: "0.9.27-nightly4" - tag_version = tag[len("nightly-"):] if tag.startswith("nightly-") else tag - update_available = tag_version != current - latest = tag - return web.json_response({ - "current": current, - "latest": latest, - "update_available": update_available, - "tag": tag, - "docker_only": True, - "changelog": data.get("body", ""), - }) - elif 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": "no dev releases found"}, status=404) - data = dev_releases[0] - else: - # Stable: only take non-prereleases - stable_releases = [r for r in releases if not r.get("prerelease")] - if not stable_releases: - return web.json_response({"error": "no stable releases found"}, status=404) - data = stable_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, - "docker_only": False, - "changelog": data.get("body", ""), - }) - except Exception as e: - return web.json_response({"error": str(e)}, status=502) - - # Bridge Python modules the self-update must include. If only the - # main file is replaced, the new version may crash with ModuleNotFoundError. - # Note: since the theme system, the frontend lives under web/themes// - # (no flat .py anymore); theme files are currently NOT included in the - # self-update - theme changes arrive via Docker image/binary updates. - _UPDATE_FILES = [ - "kobrax_moonraker_bridge.py", - "kobrax_client.py", - "config_loader.py", - "env_loader.py", - ] - - async def handle_api_update_apply(self, request): - try: - data = await request.json() - except Exception: - return web.json_response({"error": "invalid json"}, status=400) - new_tag = data.get("tag", "") - if "nightly" in self._read_version(): - return web.json_response( - {"error": "nightly updates are delivered via Docker: " - "docker compose pull && docker compose up -d"}, status=400) - if getattr(sys, "frozen", False): - return web.json_response( - {"error": "self-update is not supported in binary mode - " - "please download the new binary/Docker image."}, status=400) - if not new_tag: - return web.json_response({"error": "missing tag"}, status=400) - - app_dir = pathlib.Path(__file__).resolve().parent - try: - # Phase 1: ALLE Dateien herunterladen (in .new), nichts ersetzen. - downloaded: list[tuple[pathlib.Path, bytes]] = [] - async with aiohttp.ClientSession() as session: - for fname in self._UPDATE_FILES: - url = f"{self.GITEA_RAW_BASE}/{new_tag}/{fname}" - async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: - if resp.status != 200: - # _web_assets.py etc. may not exist in older tags - - # the main file is mandatory, optional ones may be missing. - if fname == "kobrax_moonraker_bridge.py": - return web.json_response( - {"error": f"Download {fname}: HTTP {resp.status}"}, status=502) - log.warning(f"Update: {fname} not found in release ({resp.status}) – skipped") - continue - downloaded.append((app_dir / fname, await resp.read())) - # Phase 2: replace atomically (only after a complete, successful download) - for path, content in downloaded: - tmp = path.with_suffix(path.suffix + ".new") - tmp.write_bytes(content) - os.replace(tmp, path) - self._write_version(new_tag.lstrip("v")) - log.info(f"Update to {new_tag} installed ({len(downloaded)} files), restarting...") - 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): - # Minimal 1x1 ICO so the browser doesn't log a 404 - 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") - - # ------------------------------------------------------------------------- - # Klipper G-code script emulation for moonraker-obico - # ------------------------------------------------------------------------- - - async def _exec_gcode_script(self, script: str) -> str: - """Maps a Klipper or Marlin G-code line to an MQTT command - for the Kobra X. Supports: - - PAUSE / M25, RESUME / M24, CANCEL_PRINT / M0/M1/M524/ABORT - - M104 S → Nozzle-Temperatur - - M140 S → Bett-Temperatur - - SET_HEATER_TEMPERATURE HEATER=extruder TARGET=200 (Klipper) - - SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=60 (Klipper) - Unknown scripts are acknowledged with 'ok' (Obico e.g. sends G28 - for homing, which the bridge silently ignores).""" - if not script: - return "ok" - s = script.strip().upper() - loop = asyncio.get_event_loop() - - def _parse_marlin_temp(line: str) -> int | None: - """Extract the temperature value from 'M104 S200' or 'M140 S60'.""" - try: - return int(line.split("S", 1)[1].split()[0]) - except Exception: - return None - - def _parse_klipper_set_heater(line: str) -> tuple[str | None, int | None]: - """Extract heater + target from 'SET_HEATER_TEMPERATURE HEATER=extruder TARGET=143'. - Heater ID + target. Heater is 'extruder' or - 'heater_bed', target is int. Returns (None,None) on error.""" - heater = None - target = None - for part in line.split(): - if part.startswith("HEATER="): - heater = part.split("=", 1)[1].strip().lower() - elif part.startswith("TARGET="): - try: - target = int(float(part.split("=", 1)[1])) - except Exception: - pass - return heater, target - - async def _set_temps(nozzle: int | None, bed: int | None): - """Sets nozzle/bed temperature via the correct MQTT path - - printing: print/update with taskid, idle: tempature/set with both.""" - is_printing = self._state.get("print_state") in ("printing", "paused") - if is_printing: - taskid = self._state.get("taskid", "") - if nozzle is not None: - await loop.run_in_executor(None, lambda: self.client.publish_web( - "print", "update", - {"taskid": taskid, "settings": {"target_nozzle_temp": int(nozzle)}}, - )) - if bed is not None: - await loop.run_in_executor(None, lambda: self.client.publish_web( - "print", "update", - {"taskid": taskid, "settings": {"target_hotbed_temp": int(bed)}}, - )) - else: - # Idle: tempature/set via the web/printer topic with a type field - # (Live-Sniff 2026-05-29). type: 0=Nozzle, 1=Bed, 2=beide. - if nozzle is not None and bed is not None: - t, n, b = 2, int(nozzle), int(bed) - elif nozzle is not None: - t, n, b = 0, int(nozzle), 0 - elif bed is not None: - t, n, b = 1, 0, int(bed) - else: - return - await loop.run_in_executor(None, lambda: self.client.publish_web( - "tempature", "set", - {"type": t, "target_nozzle_temp": n, "target_hotbed_temp": b}, - )) - - try: - if s in ("PAUSE", "M25"): - await loop.run_in_executor(None, self.client.pause_print) - elif s in ("RESUME", "M24"): - await loop.run_in_executor(None, self.client.resume_print) - elif s in ("CANCEL_PRINT", "M0", "M1", "M524", "ABORT"): - await loop.run_in_executor(None, self.client.stop_print) - elif s.startswith("M104 "): - t = _parse_marlin_temp(s) - if t is not None: - log.info(f"gcode.script: Nozzle-Target {t}°C (M104)") - await _set_temps(t, None) - elif s.startswith("M140 "): - t = _parse_marlin_temp(s) - if t is not None: - log.info(f"gcode.script: Bed-Target {t}°C (M140)") - await _set_temps(None, t) - elif s.startswith("SET_HEATER_TEMPERATURE"): - heater, target = _parse_klipper_set_heater(s) - if target is not None and heater: - if heater == "extruder": - log.info(f"gcode.script: Nozzle-Target {target}°C (Klipper)") - await _set_temps(target, None) - elif heater in ("heater_bed", "bed"): - log.info(f"gcode.script: Bed-Target {target}°C (Klipper)") - await _set_temps(None, target) - else: - log.debug(f"gcode.script: unbekannter Heater '{heater}' ignoriert") - else: - # Unbekanntes Script: stillschweigend OK quittieren. - log.debug(f"gcode.script ignored: {s[:60]}") - except Exception as e: - log.warning(f"gcode.script {s[:30]}: {e}") - return "ok" - - async def handle_printer_gcode_script(self, request): - """HTTP POST /printer/gcode/script — Klipper-G-Code-Wrapper (siehe _exec_gcode_script).""" - script = "" - if request.method == "POST": - try: - body = await request.json() - if isinstance(body, dict): - script = body.get("script", "") or "" - except Exception: - pass - if not script: - script = request.rel_url.query.get("script", "") - result = await self._exec_gcode_script(script) - return web.json_response({"result": result}) - # ------------------------------------------------------------------------- # WebSocket handler # -------------------------------------------------------------------------