"""
kobrax_moonraker_bridge.py - Moonraker-compatible HTTP/WebSocket bridge for the Anycubic Kobra X

Emulates the Moonraker/Klipper API so OrcaSlicer can control the Kobra X directly.

Verwendung:
  python kobrax_moonraker_bridge.py --printer-ip 192.168.178.94

OrcaSlicer-Konfiguration:
  Drucker-Typ: Klipper  |  Host: 127.0.0.1  |  Port: 7125

────────────────────────────────────────────────────────────────────────────
Copyright (C) 2026 viewit (KX-Bridge contributors)

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License v3.0 as published
by the Free Software Foundation. See the LICENSE file in the project root
or <https://www.gnu.org/licenses/gpl-3.0.html> for the full text.

This program is distributed WITHOUT ANY WARRANTY; without even the implied
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

Reverse-engineering of the Anycubic Kobra X MQTT protocol was carried out
for interoperability purposes (§69e UrhG / EU Software Directive Art. 6).
This project is not affiliated with Anycubic. See NOTICE.md for details.
"""

import argparse
import sqlite3
import uuid
try:
    import config_loader as env_loader
except ImportError:
    import env_loader
import asyncio
import hashlib
import copy
import json
import logging
import os
import pathlib
import re
import subprocess
import sys
import tempfile
import time
import threading
import html
from urllib.parse import quote

# For PyInstaller binaries everything sits next to sys.executable, otherwise next to __file__
_BASE = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _BASE)
# Read-only web assets (themes) are embedded in the onefile binary via --add-data under
# sys._MEIPASS entpackt; im Script-/Docker-Modus liegen sie neben dieser Datei.
_WEB_BASE = getattr(sys, "_MEIPASS", _BASE)
from kobrax_client import KobraXClient


try:
    import imageio_ffmpeg
    def _find_ffmpeg() -> str:
        return imageio_ffmpeg.get_ffmpeg_exe()
except ImportError:
    def _find_ffmpeg() -> str:
        exe_name = "ffmpeg.exe" if sys.platform == "win32" else "ffmpeg"
        local = os.path.join(_BASE, exe_name)
        if os.path.isfile(local):
            return local
        return "ffmpeg"

try:
    from aiohttp import web
    import aiohttp
except ImportError:
    print("Error: aiohttp is not installed. Run: pip install aiohttp")
    sys.exit(1)

try:
    import base64 as _base64
    from Crypto.Cipher import AES as _AES
    from Crypto.Util.Padding import unpad as _unpad
    _HAS_CRYPTO = True
except ImportError:
    _HAS_CRYPTO = False


def _kx_generate_signature(token: str, ts: int, nonce: str) -> str:
    first = hashlib.md5(token[:16].encode()).hexdigest()
    return hashlib.md5((first + str(ts) + nonce).encode()).hexdigest()


def _kx_decrypt_info(encrypted_b64: str, key: str, iv: str) -> dict:
    cipher = _AES.new(key.encode(), _AES.MODE_CBC, iv.encode())
    raw = _base64.b64decode(encrypted_b64)
    return json.loads(_unpad(cipher.decrypt(raw), _AES.block_size).decode())


async def _kx_fetch_credentials(ip: str, port: int = 18910) -> dict:
    """Fetches + decrypts printer credentials via HTTP /info + /ctrl.

    Raises an exception on network/decrypt errors. Algorithm from
    tools/fetch_credentials.py (AES-256-CBC, Key=token[16:32], IV=ctrl-token).
    """
    if not _HAS_CRYPTO:
        raise RuntimeError("pycryptodome is not installed")
    import random, string
    nonce = "".join(random.choice(string.ascii_letters + string.digits) for _ in range(6))
    timeout = aiohttp.ClientTimeout(total=10)
    async with aiohttp.ClientSession() as s:
        async with s.get(f"http://{ip}:{port}/info", timeout=timeout) as r:
            r.raise_for_status()
            info = await r.json()
        token = info["token"]
        ts = int(time.time() * 1000)
        sign = _kx_generate_signature(token, ts, nonce)
        params = {"ts": ts, "nonce": nonce, "sign": sign, "did": "random"}
        async with s.post(f"http://{ip}:{port}/ctrl", params=params, timeout=timeout) as r:
            r.raise_for_status()
            data = await r.json()
    result = _kx_decrypt_info(data["data"]["info"], token[16:32], data["data"]["token"])
    if "error" in result:
        raise RuntimeError(result.get("error", "decrypt failed"))
    return {
        "printer_ip": result.get("ip", ip),
        "username":   result.get("username", ""),
        "password":   result.get("password", ""),
        "device_id":  result.get("deviceId", ""),
        "mode_id":    str(result.get("modeId", "20030")),
        "model":      result.get("modelName", "Anycubic Kobra"),
    }

logging.basicConfig(level=logging.INFO,
                    format="[%(asctime)s] %(levelname)-5s %(name)s: %(message)s",
                    datefmt="%H:%M:%S")
log = logging.getLogger("bridge")
# aiohttp logs one INFO line per HTTP request (access log) — with 2s frontend
# polling that drowns out the bridge's own logs by default. Toggleable at
# runtime via the verbose_http_log setting (see handle_api_settings_post).
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)


def _set_verbose_http_log(enabled: bool):
    logging.getLogger("aiohttp.access").setLevel(logging.INFO if enabled else logging.WARNING)

# Web UI: subdirectory under web/themes/<name>/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/<name>
_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$")

# Ring buffer for the browser log stream (last 200 entries)
import collections as _collections
_log_buffer: "_collections.deque[dict]" = _collections.deque(maxlen=500)
_log_sse_queues: "list[asyncio.Queue]" = []

class _BrowserLogHandler(logging.Handler):
    """Sends log records to the ring buffer and all open SSE queues."""
    _fmt = logging.Formatter(datefmt="%H:%M:%S")

    def emit(self, record: logging.LogRecord):
        msg = record.getMessage()
        # Pass exceptions with traceback through to the browser (otherwise the
        # user only sees "Error: X" without context).
        if record.exc_info:
            try:
                msg += "\n" + self._fmt.formatException(record.exc_info)
            except Exception:
                pass
        entry = {
            "ts":    self._fmt.formatTime(record, "%H:%M:%S"),
            "lvl":   record.levelname,
            "name":  record.name,
            "msg":   msg,
        }
        _log_buffer.append(entry)
        for q in list(_log_sse_queues):
            try:
                q.put_nowait(entry)
            except Exception:
                pass

_browser_handler = _BrowserLogHandler()
logging.getLogger().addHandler(_browser_handler)

KOBRA_TO_KLIPPER_STATE = {
    "free":          "standby",
    "busy":          "printing",
    "printing":      "printing",
    "preheating":    "printing",
    "auto_leveling": "printing",
    "checking":      "printing",
    "updated":       "printing",
    "init":          "printing",
    "pausing":       "paused",
    "paused":        "paused",
    "resuming":      "printing",
    "resumed":       "printing",
    "stopping":      "printing",
    "stoped":        "standby",
    "finished":      "complete",
    "failed":        "error",
    "canceled":      "standby",
}

MOONRAKER_VERSION = "v0.9.3-1"
KLIPPER_VERSION   = "v0.12.0-1"


def _parse_gcode_estimated_time(data: bytes) -> int:
    """Reads the estimated print time from GCode (OrcaSlicer + PrusaSlicer).
    Returns seconds, 0 when not found.
    PrusaSlicer writes the time into the header (first 16KB),
    OrcaSlicer writes it at the end of the file (last 16KB)."""
    import re
    # Search the beginning + end of the file (OrcaSlicer writes the time at the end)
    search_text = (data[:16384] + data[-65536:]).decode("utf-8", errors="ignore")
    # OrcaSlicer:  ; total estimated time: 9m 20s
    # PrusaSlicer: ; estimated printing time (normal mode) = 1h 9m 20s
    m = (re.search(r";\s*total estimated time:\s*(.*)", search_text) or
         re.search(r";\s*estimated printing time \(normal mode\)\s*=\s*(.*)", search_text))
    if not m:
        return 0
    parts = re.findall(r"(\d+)\s*([hms])", m.group(1))
    secs = 0
    for val, unit in parts:
        if unit == "h":   secs += int(val) * 3600
        elif unit == "m": secs += int(val) * 60
        elif unit == "s": secs += int(val)
    if secs:
        log.info(f"Slicer estimate: {secs}s ({m.group(1).strip()})")
    return secs


def _parse_gcode_layer_heights(data: bytes) -> tuple[float, float]:
    """Reads (layer_height, initial_layer_height) from the OrcaSlicer/PrusaSlicer
    GCode header. Both are stored as a config block at the end of the GCode.

    Beispiel-Zeilen:
      ; layer_height = 0.2
      ; initial_layer_print_height = 0.2

    Returns (0.0, 0.0) when not found - the caller decides what to do
    (typisch: keinen Z-Wert anzeigen)."""
    import re
    head = data[:16384].decode("utf-8", errors="ignore")
    tail = data[-65536:].decode("utf-8", errors="ignore")
    search = head + "\n" + tail
    def _grab(pat):
        m = re.search(pat, search)
        if not m:
            return 0.0
        try:
            return float(m.group(1))
        except Exception:
            return 0.0
    layer_h   = _grab(r";\s*layer_height\s*=\s*([0-9.]+)")
    first_h   = (_grab(r";\s*initial_layer_print_height\s*=\s*([0-9.]+)") or
                 _grab(r";\s*first_layer_height\s*=\s*([0-9.]+)") or
                 layer_h)
    return layer_h, first_h


def _extract_thumbnail(data: bytes) -> str:
    """Extracts the base64 PNG thumbnail from GCode (OrcaSlicer format)."""
    try:
        marker = b"; thumbnail begin"
        end_marker = b"; thumbnail end"
        start = data.find(marker)
        if start == -1:
            return ""
        start = data.find(b"\n", start) + 1
        end = data.find(end_marker, start)
        if end == -1:
            return ""
        lines = data[start:end].split(b"\n")
        b64 = b"".join(
            line[2:].strip() if line.startswith(b"; ") else line.strip()
            for line in lines
        )
        return b64.decode("ascii")
    except Exception:
        return ""


def _extract_filament_info(data: bytes) -> list[dict]:
  """Reads filament colors/materials incl. tool order from Orca/Prusa GCode.

  Gibt Liste von {slot_index, color_hex, material} in Tool-/Paint-Reihenfolge
  (T0, T1, ...).
  Searches both the start and the end of the file since Orca can insert
  large thumbnail blocks, pushing the metadata into the tail.
  """
  try:
    head = data[:131072]
    tail = data[-131072:] if len(data) > 131072 else b""
    header = (head + b"\n" + tail).decode("utf-8", errors="ignore")
    colors, materials = [], []
    paint_count_hint = 0
    tool_filament_order = []
    for line in header.splitlines():
      if re.match(r"^\s*;\s*filament_colour\s*=", line):
        val = line.split("=", 1)[-1].strip()
        colors = [c.strip().lstrip("#") for c in val.split(";") if c.strip()]
      elif re.match(r"^\s*;\s*filament_multi_colour\s*=", line) and not colors:
        val = line.split("=", 1)[-1].strip()
        colors = [c.strip().lstrip("#") for c in val.split(";") if c.strip()]
      elif re.match(r"^\s*;\s*filament_type\s*=", line):
        val = line.split("=", 1)[-1].strip()
        parts = [m.strip() for m in re.split(r"[;,]", val) if m.strip()]
        materials = parts
        paint_count_hint = max(paint_count_hint, len(parts))
      elif re.match(r"^\s*;\s*filament_density\s*:", line):
        val = line.split(":", 1)[-1].strip()
        parts = [x.strip() for x in re.split(r"[;,]", val) if x.strip()]
        paint_count_hint = max(paint_count_hint, len(parts))
      elif re.match(r"^\s*;\s*filament_diameter\s*:", line):
        val = line.split(":", 1)[-1].strip()
        parts = [x.strip() for x in re.split(r"[;,]", val) if x.strip()]
        paint_count_hint = max(paint_count_hint, len(parts))
      elif re.match(r"^\s*;\s*filament\s*:", line):
        raw = line.split(":", 1)[-1]
        parsed = []
        for p in [x.strip() for x in raw.split(",") if x.strip()]:
          try:
            parsed.append(int(p))
          except Exception:
            pass
        if parsed:
          tool_filament_order = parsed
    total_paints = max(len(colors), len(materials), paint_count_hint)
    if tool_filament_order:
      total_paints = max(total_paints, max(tool_filament_order))
    if total_paints <= 0:
      return []

    # Keep full paint list visible; mark paints referenced by Orca tool order as used.
    if len(colors) < total_paints:
      colors.extend(["FFFFFF"] * (total_paints - len(colors)))
    if len(materials) < total_paints:
      materials.extend(["PLA"] * (total_paints - len(materials)))
    # Prefer actual tool-change commands from the GCode body.
    # This avoids forwarding paints that are present in metadata but never used.
    used_paints_zero_based = set()
    try:
      for m in re.finditer(br"(?m)^[ \t]*T([0-9]+)\b", data):
        used_paints_zero_based.add(int(m.group(1)))
    except Exception:
      used_paints_zero_based = set()

    # Fallback for slicers that only provide paint usage in header metadata.
    used_paints_from_header = set()
    for n in tool_filament_order:
      try:
        # Orca/Prusa filament: list is typically 1-based.
        used_paints_from_header.add(max(0, int(n) - 1))
      except Exception:
        pass

    result = []
    for i in range(total_paints):
      hex_color = colors[i] if i < len(colors) else "FFFFFF"
      result.append({
        "slot_index": i,
        "color_hex":  "#" + hex_color.upper() if hex_color else "#FFFFFF",
        "material":   materials[i] if i < len(materials) else "PLA",
        "is_used":    (i in used_paints_zero_based) if used_paints_zero_based else ((i in used_paints_from_header) if used_paints_from_header else True),
      })
    return result
  except Exception:
    return []


class GCodeStore:
    """Persistenter GCode-Store pro Bridge-Instanz (SQLite)."""

    def __init__(self, data_dir: str):
        os.makedirs(data_dir, exist_ok=True)
        self._gcode_dir = os.path.join(data_dir, "gcodes")
        os.makedirs(self._gcode_dir, exist_ok=True)
        db_path = os.path.join(data_dir, "kx-bridge.db")
        self._conn = sqlite3.connect(db_path, check_same_thread=False)
        self._conn.row_factory = sqlite3.Row
        self._lock = threading.Lock()
        self._init_schema()

    def _init_schema(self):
        with self._lock:
            self._conn.executescript("""
                CREATE TABLE IF NOT EXISTS gcode_files (
                    id TEXT PRIMARY KEY,
                    filename TEXT NOT NULL,
                    path TEXT NOT NULL,
                    size_bytes INTEGER NOT NULL,
                    uploaded_at TEXT NOT NULL,
                    thumbnail_b64 TEXT,
                    est_print_time_sec INTEGER,
                    filament_used_mm REAL,
                    layer_count INTEGER,
                    gcode_filaments TEXT,
                    objects_skip_parts TEXT,
                    svg_image TEXT,
                    web_unverified INTEGER NOT NULL DEFAULT 0
                );
                CREATE TABLE IF NOT EXISTS print_jobs (
                    id TEXT PRIMARY KEY,
                    gcode_file_id TEXT NOT NULL,
                    printer_id TEXT NOT NULL,
                    started_at TEXT NOT NULL,
                    ended_at TEXT,
                    status TEXT NOT NULL,
                    duration_sec INTEGER,
                    filament_assignments TEXT,
                    abort_reason TEXT
                );
            """)
            # Migration: add gcode_filaments column for older databases
            try:
                self._conn.execute("ALTER TABLE gcode_files ADD COLUMN gcode_filaments TEXT")
                self._conn.commit()
            except Exception:
                pass
            # Migration: columns objects_skip_parts + svg_image (part-skip feature, v0.9.10)
            # Plus layer_height / first_layer_height (Obico Z height, v0.9.18)
            for col, typ in (
                ("objects_skip_parts", "TEXT"),
                ("svg_image", "TEXT"),
                ("layer_height", "REAL"),
                ("first_layer_height", "REAL"),
            ):
                try:
                    self._conn.execute(f"ALTER TABLE gcode_files ADD COLUMN {col} {typ}")
                    self._conn.commit()
                except Exception:
                    pass
            # Migration: flag for web uploads (warning before print)
            try:
                self._conn.execute("ALTER TABLE gcode_files ADD COLUMN web_unverified INTEGER NOT NULL DEFAULT 0")
                self._conn.commit()
            except Exception:
                pass

    def save_file(self, file_id: str, filename: str, data: bytes,
                  est_time_sec: int = 0, thumbnail_b64: str = "",
                  gcode_filaments: list | None = None,
                  web_unverified: bool = False,
                  layer_height: float = 0.0,
                  first_layer_height: float = 0.0) -> str:
        """Saves a GCode file to disk and DB. Returns the path."""
        safe_name = os.path.basename(filename)
        path = os.path.join(self._gcode_dir, safe_name)
        with open(path, "wb") as f:
            f.write(data)
        now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
        with self._lock:
            filaments_json = json.dumps(gcode_filaments) if gcode_filaments else None
            self._conn.execute(
                """INSERT OR REPLACE INTO gcode_files
                   (id, filename, path, size_bytes, uploaded_at, thumbnail_b64, est_print_time_sec, gcode_filaments, web_unverified, layer_height, first_layer_height)
                   VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
                (file_id, filename, path, len(data), now, thumbnail_b64 or None, est_time_sec or None, filaments_json, 1 if web_unverified else 0, layer_height or None, first_layer_height or None)
            )
            self._conn.commit()
        return path

    def list_files(self) -> list:
        with self._lock:
            rows = self._conn.execute(
                "SELECT * FROM gcode_files ORDER BY uploaded_at DESC"
            ).fetchall()
        return [dict(r) for r in rows]

    def get_file(self, file_id: str) -> dict | None:
        with self._lock:
            row = self._conn.execute(
                "SELECT * FROM gcode_files WHERE id=?", (file_id,)
            ).fetchone()
        return dict(row) if row else None

    def get_file_by_name(self, filename: str) -> dict | None:
        with self._lock:
            row = self._conn.execute(
                "SELECT * FROM gcode_files WHERE filename=? ORDER BY uploaded_at DESC LIMIT 1",
                (filename,)
            ).fetchone()
        return dict(row) if row else None

    def update_file_objects(self, filename: str, objects: list, svg: str = "") -> None:
        """Saves the object list + optional SVG for a file (matched via filename)."""
        if not filename:
            return
        with self._lock:
            self._conn.execute(
                "UPDATE gcode_files SET objects_skip_parts=?, svg_image=? "
                "WHERE filename=?",
                (json.dumps(objects), svg or "", filename),
            )
            self._conn.commit()

    def update_file_filaments(self, file_id: str, gcode_filaments: list | None) -> None:
      """Updates parsed GCode filaments for an existing DB entry."""
      with self._lock:
        self._conn.execute(
          "UPDATE gcode_files SET gcode_filaments=? WHERE id=?",
          (json.dumps(gcode_filaments) if gcode_filaments else None, file_id),
        )
        self._conn.commit()

    def clear_web_unverified(self, file_id: str) -> bool:
        with self._lock:
            cur = self._conn.execute(
                "UPDATE gcode_files SET web_unverified=0 WHERE id=?",
                (file_id,),
            )
            self._conn.commit()
        return cur.rowcount > 0

    def delete_file(self, file_id: str) -> bool:
        row = self.get_file(file_id)
        if not row:
            return False
        try:
            os.remove(row["path"])
        except OSError:
            pass
        with self._lock:
            self._conn.execute("DELETE FROM gcode_files WHERE id=?", (file_id,))
            self._conn.commit()
        return True

    def start_job(self, gcode_file_id: str, printer_id: str,
                  filament_assignments: list | None = None) -> str:
        job_id = str(uuid.uuid4())
        now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
        assignments_json = json.dumps(filament_assignments) if filament_assignments else None
        with self._lock:
            self._conn.execute(
                """INSERT INTO print_jobs
                   (id, gcode_file_id, printer_id, started_at, status, filament_assignments)
                   VALUES (?,?,?,?,'printing',?)""",
                (job_id, gcode_file_id, printer_id, now, assignments_json)
            )
            self._conn.commit()
        return job_id

    def finish_job(self, job_id: str, status: str = "completed",
                   abort_reason: str = "") -> None:
        now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
        with self._lock:
            row = self._conn.execute(
                "SELECT started_at FROM print_jobs WHERE id=?", (job_id,)
            ).fetchone()
            duration = None
            if row:
                try:
                    import calendar
                    start = time.strptime(row["started_at"], "%Y-%m-%dT%H:%M:%SZ")
                    duration = int(time.time() - calendar.timegm(start))
                except Exception:
                    pass
            self._conn.execute(
                """UPDATE print_jobs SET ended_at=?, status=?, duration_sec=?, abort_reason=?
                   WHERE id=?""",
                (now, status, duration, abort_reason or None, job_id)
            )
            self._conn.commit()

    def list_jobs(self, limit: int = 50, offset: int = 0) -> list:
        with self._lock:
            rows = self._conn.execute(
                """SELECT j.*, f.filename, f.thumbnail_b64
                   FROM print_jobs j
                   LEFT JOIN gcode_files f ON j.gcode_file_id = f.id
                   ORDER BY j.started_at DESC LIMIT ? OFFSET ?""",
                (limit, offset)
            ).fetchall()
        return [dict(r) for r in rows]


class CameraCache:
    """Zentraler Kamera-Demuxer.

    Keeps ONE ffmpeg process per output type open that reads the FLV stream
    from the printer and produces:
      - MJPEG @ 2fps -> last frame in RAM for /api/camera/snapshot
      - MPEG-TS (-c:v copy) -> fanout to all /api/camera/h264 subscribers
      - MJPEG @ 15fps/640px -> fanout to all /api/camera/stream subscribers
        (the live-view used by the dashboard AND by every Moonraker-compatible
        client, since server.webcams.list advertises this same stream_url)

    Damit:
      * Only ONE FLV connection to the printer per output type (solves the
        single-client limit / 429) - previously /api/camera/stream opened a
        brand-new, uncached ffmpeg + printer connection per HTTP client, which
        competed with the cached jpeg/h264 connections for the printer's very
        limited number of concurrent camera clients and caused intermittent
        "stream unavailable" failures.
      * Snapshots are instant (memory read, no ffmpeg spawn per request)
      * Multiple parallel H.264/MJPEG consumers possible (plugin + web UI + ...)

    Lazy start on the first consumer, auto-restart on ffmpeg crash.
    """

    JPEG_SOI = b"\xff\xd8"
    JPEG_EOI = b"\xff\xd9"
    TS_CHUNK = 65536

    def __init__(self):
        self._url: str = ""
        self.latest_jpeg: bytes = b""
        self.latest_jpeg_ts: float = 0.0
        self.h264_subscribers: "set[asyncio.Queue[bytes]]" = set()
        self.mjpeg_subscribers: "set[asyncio.Queue[bytes]]" = set()
        self._proc_jpeg: "asyncio.subprocess.Process | None" = None
        self._proc_h264: "asyncio.subprocess.Process | None" = None
        self._proc_mjpeg: "asyncio.subprocess.Process | None" = None
        self._task_jpeg: "asyncio.Task | None" = None
        self._task_h264: "asyncio.Task | None" = None
        self._task_mjpeg: "asyncio.Task | None" = None
        self._lock = asyncio.Lock()
        self._fail_count_jpeg: int = 0
        self._fail_count_h264: int = 0
        self._fail_count_mjpeg: int = 0

    def set_url(self, url: str):
        # A changed URL means the printer rotated its stream token (typically
        # after a reboot). Running ffmpeg processes still hold the stale URL
        # and will never pick it up on their own - they only re-read self._url
        # at the top of their outer loop, which they never reach while blocked
        # in a stdout read on the old, now-silent connection. Tear them down;
        # the next ensure_running() respawns them against the new URL.
        changed = bool(url and self._url and url != self._url)
        self._url = url
        if changed:
            self.reset()

    def reset(self):
        """Reset backoff counters and forcefully tear down any running
        ffmpeg loops - including cancelling their background tasks.

        Only killing the ffmpeg subprocess is not enough: the owning task
        might currently be sitting in `await asyncio.sleep(delay)` from a
        previous exponential backoff (up to 300s) after an earlier failure.
        Resetting the fail-count doesn't wake it up early, so a user
        clicking "reset" could see nothing happen for minutes. Cancelling
        the task guarantees an immediate, clean restart on the next
        ensure_running() call.
        """
        self._fail_count_jpeg = 0
        self._fail_count_h264 = 0
        self._fail_count_mjpeg = 0
        for task in (self._task_jpeg, self._task_h264, self._task_mjpeg):
            if task is not None and not task.done():
                task.cancel()
        for proc in (self._proc_jpeg, self._proc_h264, self._proc_mjpeg):
            if proc is not None:
                try:
                    proc.kill()
                except Exception:
                    pass
        self._task_jpeg = self._task_h264 = self._task_mjpeg = None
        self._proc_jpeg = self._proc_h264 = self._proc_mjpeg = None

    async def ensure_running(self):
        # NOTE: we check the *task* state, not self._proc_* - the process
        # handle is only assigned later, inside the task body, once ffmpeg
        # has actually been spawned. Checking self._proc_* here left a race
        # window: two callers arriving before the newly-created task got a
        # chance to run would both see "no process yet" and each spawn a
        # duplicate ffmpeg + duplicate printer connection, silently
        # orphaning the older one (whichever task's coroutine runs last
        # overwrites the shared self._proc_* reference, so nobody keeps a
        # handle to kill the earlier orphaned process). Task creation is
        # synchronous, so checking self._task_* here is race-free.
        if self._task_jpeg is None or self._task_jpeg.done():
            self._task_jpeg = asyncio.create_task(self._run_jpeg_loop())
        if self._task_h264 is None or self._task_h264.done():
            self._task_h264 = asyncio.create_task(self._run_h264_loop())
        if self._task_mjpeg is None or self._task_mjpeg.done():
            self._task_mjpeg = asyncio.create_task(self._run_mjpeg_loop())

    def _input_args(self, url: str) -> list[str]:
        args = ["-fflags", "nobuffer", "-flags", "low_delay",
                 # Bail out if the source goes silent. A printer reboot or
                 # network loss leaves the TCP connection ESTABLISHED with no
                 # data and no FIN, so a passive stdout read blocks forever
                 # without this (Issue #99). Value is microseconds.
                 "-timeout", "10000000"]
        if url.lower().startswith("rtsp://"):
            args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
        else:
            # The printer's FLV source occasionally emits non-monotonic container
            # timestamps (PTS jumps of days) while the video data itself stays
            # valid. Without this flag ffmpeg's realtime pacing breaks on such a
            # jump and the stream stalls after ~15-30 min (Issue #90).
            args += ["-use_wallclock_as_timestamps", "1",
                     "-probesize", "500000", "-analyzeduration", "500000"]
        return args

    async def _run_jpeg_loop(self):
        """Keeps an ffmpeg process alive that writes MJPEG@2fps into the cache."""
        while True:
            url = self._url
            if not url:
                await asyncio.sleep(2.0)
                continue
            try:
                self._proc_jpeg = await asyncio.create_subprocess_exec(
                    _find_ffmpeg(), "-loglevel", "warning",
                    *self._input_args(url), "-i", url,
                    "-vf", "fps=2",
                    "-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3",
                    "-flush_packets", "1", "pipe:1",
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                )
            except Exception as e:
                log.warning(f"CameraCache: ffmpeg-jpeg start failed: {e}")
                await asyncio.sleep(3.0)
                continue

            buf = b""
            rc = None
            try:
                while True:
                    chunk = await self._proc_jpeg.stdout.read(self.TS_CHUNK)
                    if not chunk:
                        break
                    buf += chunk
                    # extract complete JPEG frames
                    while True:
                        start = buf.find(self.JPEG_SOI)
                        if start == -1:
                            buf = b""
                            break
                        end = buf.find(self.JPEG_EOI, start + 2)
                        if end == -1:
                            buf = buf[start:]
                            break
                        self.latest_jpeg = buf[start:end + 2]
                        self.latest_jpeg_ts = time.time()
                        buf = buf[end + 2:]
            except Exception as e:
                log.debug(f"CameraCache: jpeg-loop unterbrochen: {e}")
            finally:
                # Kill + wait - otherwise the child process lingers as a zombie and
                # asyncio reports "Unknown child pid ..." on the next reaper tick.
                if self._proc_jpeg is not None:
                    try:
                        self._proc_jpeg.kill()
                    except Exception:
                        pass
                    try:
                        await self._proc_jpeg.wait()
                    except Exception:
                        pass
                    rc = self._proc_jpeg.returncode
                    if rc:
                        try:
                            err = await self._proc_jpeg.stderr.read(500)
                            if err:
                                log.warning(f"CameraCache: ffmpeg-jpeg stderr: {err.decode(errors='replace').strip()}")
                        except Exception:
                            pass
                self._proc_jpeg = None
            if rc:
                self._fail_count_jpeg += 1
                delay = min(2.0 * (2 ** self._fail_count_jpeg), 300.0)
                log.warning(f"CameraCache: ffmpeg-jpeg exit {rc}, retry in {delay:.0f}s (Versuch {self._fail_count_jpeg})")
                await asyncio.sleep(delay)
            else:
                self._fail_count_jpeg = 0
                await asyncio.sleep(2.0)

    async def _run_h264_loop(self):
        """Keeps an ffmpeg process alive that fans out MPEG-TS to all subscribers."""
        while True:
            url = self._url
            if not url:
                await asyncio.sleep(2.0)
                continue
            try:
                self._proc_h264 = await asyncio.create_subprocess_exec(
                    _find_ffmpeg(), "-loglevel", "warning",
                    *self._input_args(url), "-i", url,
                    "-c:v", "copy", "-an",
                    "-f", "mpegts", "pipe:1",
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                )
            except Exception as e:
                log.warning(f"CameraCache: ffmpeg-h264 start failed: {e}")
                await asyncio.sleep(3.0)
                continue

            rc = None
            try:
                while True:
                    chunk = await self._proc_h264.stdout.read(self.TS_CHUNK)
                    if not chunk:
                        break
                    # Fanout: non-blocking per subscriber; slow clients
                    # get their oldest chunk dropped (queue full -> drop).
                    for q in list(self.h264_subscribers):
                        if q.full():
                            try:
                                q.get_nowait()
                            except Exception:
                                pass
                        try:
                            q.put_nowait(chunk)
                        except Exception:
                            pass
            except Exception as e:
                log.debug(f"CameraCache: h264-loop unterbrochen: {e}")
            finally:
                if self._proc_h264 is not None:
                    try:
                        self._proc_h264.kill()
                    except Exception:
                        pass
                    try:
                        await self._proc_h264.wait()
                    except Exception:
                        pass
                    rc = self._proc_h264.returncode
                    if rc:
                        try:
                            err = await self._proc_h264.stderr.read(500)
                            if err:
                                log.warning(f"CameraCache: ffmpeg-h264 stderr: {err.decode(errors='replace').strip()}")
                        except Exception:
                            pass
                self._proc_h264 = None
            if rc:
                self._fail_count_h264 += 1
                delay = min(2.0 * (2 ** self._fail_count_h264), 300.0)
                log.warning(f"CameraCache: ffmpeg-h264 exit {rc}, retry in {delay:.0f}s (Versuch {self._fail_count_h264})")
                await asyncio.sleep(delay)
            else:
                self._fail_count_h264 = 0
                await asyncio.sleep(2.0)

    async def _run_mjpeg_loop(self):
        """Keeps an ffmpeg process alive that fans out MJPEG@15fps/640px
        (complete JPEG frames) to all /api/camera/stream subscribers."""
        while True:
            url = self._url
            if not url:
                await asyncio.sleep(2.0)
                continue
            try:
                proc = await asyncio.create_subprocess_exec(
                    _find_ffmpeg(), "-loglevel", "warning",
                    *self._input_args(url), "-i", url,
                    "-vf", "fps=15,scale=640:-1",
                    "-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3",
                    "-flush_packets", "1", "pipe:1",
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                )
                self._proc_mjpeg = proc
            except Exception as e:
                log.warning(f"CameraCache: ffmpeg-mjpeg start failed: {e}")
                await asyncio.sleep(3.0)
                continue

            buf = b""
            rc = None
            try:
                while True:
                    chunk = await proc.stdout.read(self.TS_CHUNK)
                    if not chunk:
                        break
                    buf += chunk
                    # extract complete JPEG frames and fan them out whole
                    # (so every subscriber gets clean multipart boundaries,
                    # not arbitrary byte chunks like the h264/mpegts fanout)
                    while True:
                        start = buf.find(self.JPEG_SOI)
                        if start == -1:
                            buf = b""
                            break
                        end = buf.find(self.JPEG_EOI, start + 2)
                        if end == -1:
                            buf = buf[start:]
                            break
                        frame = buf[start:end + 2]
                        buf = buf[end + 2:]
                        for q in list(self.mjpeg_subscribers):
                            if q.full():
                                try:
                                    q.get_nowait()
                                except Exception:
                                    pass
                            try:
                                q.put_nowait(frame)
                            except Exception:
                                pass
            except Exception as e:
                log.debug(f"CameraCache: mjpeg-loop unterbrochen: {e}")
            finally:
                # NOTE: cleanup operates on the local `proc` reference, not
                # on self._proc_mjpeg. If this task got cancelled (e.g. by
                # reset()) a new task may already have started and assigned
                # its own process to self._proc_mjpeg by the time we reach
                # here - killing that shared attribute instead of our own
                # local proc would kill the WRONG (newer) process.
                try:
                    proc.kill()
                except Exception:
                    pass
                try:
                    await proc.wait()
                except Exception:
                    pass
                rc = proc.returncode
                if rc:
                    try:
                        err = await proc.stderr.read(500)
                        if err:
                            log.warning(f"CameraCache: ffmpeg-mjpeg stderr: {err.decode(errors='replace').strip()}")
                    except Exception:
                        pass
                if self._proc_mjpeg is proc:
                    self._proc_mjpeg = None
            if rc:
                self._fail_count_mjpeg += 1
                delay = min(2.0 * (2 ** self._fail_count_mjpeg), 300.0)
                log.warning(f"CameraCache: ffmpeg-mjpeg exit {rc}, retry in {delay:.0f}s (Versuch {self._fail_count_mjpeg})")
                await asyncio.sleep(delay)
            else:
                self._fail_count_mjpeg = 0
                await asyncio.sleep(2.0)


class SpoolmanClient:
    """Thin synchronous HTTP client for Spoolman filament tracking.

    Designed to be called from daemon threads (poll loop, _on_print callbacks).
    Uses requests (already in requirements) so no event-loop dependency.
    """

    def __init__(self, server_url: str, sync_rate: int = 0):
        self.server_url = server_url.rstrip("/")
        self.sync_rate = sync_rate

    def _req(self, method: str, path: str, **kwargs):
        import requests
        r = requests.request(method, f"{self.server_url}{path}", timeout=5, **kwargs)
        r.raise_for_status()
        return r.json()

    def health_check(self) -> bool:
        try:
            self._req("GET", "/api/v1/health")
            return True
        except Exception:
            return False

    def list_spools(self) -> list:
        return self._req("GET", "/api/v1/spool")

    def use_filament(self, spool_id: int, use_length_mm: float) -> None:
        """Report consumed filament length in mm. Spoolman converts to weight
        using the spool's filament profile density."""
        self._req("PUT", f"/api/v1/spool/{spool_id}/use",
                  json={"use_length": round(use_length_mm, 2)})


class KobraXBridge:
    def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None):
        self.client = client
        self._args = args
        self._printer_id = printer_id
        self._all_bridges = all_bridges if all_bridges is not None else {}
        self.ws_clients: set[web.WebSocketResponse] = set()
        # In-memory KV store for Moonraker /server/database/item (moonraker-obico,
        # mainsail presets etc.). Not persistent - does not survive a restart.
        self._moonraker_kv_store: dict[str, dict] = {}
        # Slot -> Orca filament profile mapping (from config.ini [filament_profiles]).
        # Format: {slot_idx: {"id": "OGFL01", "vendor": "Polymaker"}}.
        # Used in _build_lane_data so OrcaSlicer shows the concrete
        # brand ("PolyTerra PLA - Polymaker") instead of just "Generic PLA".
        try:
            import config_loader as _cl
            self._filament_profiles: dict[int, dict] = _cl.list_filament_profiles(self._printer_id)
        except Exception:
            self._filament_profiles = {}
        # Vendor visibility filter for the slot profile dropdown (Issue #41 option A).
        # Empty list = all vendors visible (backwards compatible).
        try:
            import config_loader as _cl
            self._visible_vendors: list[str] = _cl.list_visible_vendors(self._printer_id)
        except Exception:
            self._visible_vendors = []
        self._last_state: dict = {}
        self._last_ams_set_request: dict | None = None
        self._state = {
          "nozzle_temp":        0.0,
          "nozzle_target":      0.0,
          "bed_temp":           0.0,
          "bed_target":         0.0,
          "print_state":        "standby",
          "kobra_state":        "free",
          "filename":           "",
          "slicer_time":        0,
          "progress":           0.0,
          "print_duration":     0,
          "remain_time":        0,
          "curr_layer":         0,
          "total_layers":       0,
          # Layer heights for the currently running file (parsed from the
          # GCode header). Set in the upload path + in _fetch_from_store.
          # Obico uses currentZ from gcode_position[2] - the bridge computes
          # currentZ from curr_layer + these values in build_print_payload.
          "layer_height":       0.0,
          "first_layer_height": 0.0,
          "printer_name":       env_loader.get("BRIDGE_PRINTER_NAME", "Anycubic Kobra X"),
          "firmware_version":   "unknown",
          "upload_url":         "",
          "camera_url":         "",
          "fan_speed":          0,
          "light_on":           False,
          "light_brightness":   80,
          "taskid":             "-1",
          "print_speed_mode":   2,
          "connection_error":   "",
          "file_ready":         "",
          "filament_mismatch":  None,
          "print_start_dialog": getattr(args, "print_start_dialog", 1),
          "filament_mode":      "toolhead",
          "supplies_usage":     0,
          "ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None},
          "error_code":         0,
          "pause_msg":          "",
        }
        self._ams_slots: list[dict] = []       # flat global list; each entry has global_index + box_id
        self._ams_loaded_slot: int = -1        # global slot index of currently loaded slot
        self._pending_load_slot: int = -1      # global slot index requested via /api/ams/feed type=1
        self._ace_box_ids: list[int] = []      # detected ACE unit IDs (0..3)
        self._ace_auto_feed: dict[int, int] = {}   # per-box auto_feed state (0/1)
        self._head_tools_model: int = -1
        self._filament_mode: str = "toolhead"
        self._last_uploaded_file: str = ""
        # Pending waiters for a specific file/report `action` (e.g. "listLocal",
        # "deleteBatch"). publish()'s own return value for these actions is just
        # a generic immediate ACK skeleton (code=0, all fields empty) - the real
        # answer arrives later via the file/report callback (_on_file), same
        # as the existing fileDetails fire-and-forget pattern. Format:
        # {action: {"event": threading.Event(), "result": dict|None}}.
        self._file_action_waiters: dict[str, dict] = {}
        # Thumbnail cache for files on the printer's own storage (filename ->
        # base64 PNG string, "" if the file has no embedded thumbnail).
        # In-memory only - not persisted, cleared on restart.
        self._printer_thumbnail_cache: dict[str, str] = {}
        self._store = store if store is not None else GCodeStore(args.data_dir)
        self._serve_dir_path: str = self._store._gcode_dir
        self._current_job_id: str = ""
        self._camera_autostarted: bool = False
        self._camera_user_stopped: bool = False  # user manually stopped the camera during a print
        self.camera_cache: CameraCache = CameraCache()
        self._loop: "asyncio.AbstractEventLoop | None" = None
        self._prev_kobra_state: str = ""
        self._notified_print_started: bool = False  # verhindert Mehrfach-Trigger pro Druck (analog _camera_autostarted)
        self._print_active: bool = False
        self._notify_every_minutes: int = 0
        self._notify_every_layers: int = 0
        self._notifications_paused: bool = False
        self._last_progress_notif_time: float = 0.0
        self._last_progress_notif_layer: int = 0
        try:
            import config_loader as _cl2
            self._notification_urls: list[dict] = _cl2.list_notification_urls()
            _np = _cl2._find_config_file()
            if _np:
                import configparser as _cparser
                _ncfg = _cparser.ConfigParser()
                _ncfg.read(_np, encoding="utf-8")
                self._notify_every_minutes = _ncfg.getint("notifications", "notify_every_minutes", fallback=0)
                self._notify_every_layers  = _ncfg.getint("notifications", "notify_every_layers",  fallback=0)
                self._notifications_paused = _ncfg.getboolean("notifications", "paused", fallback=False)
        except Exception:
            self._notification_urls = []

        self._thumbnail_b64: str = ""
        self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config()

        # Part skip: most recent skip list reported by the printer (v0.9.10)
        self._skip_state: dict = {"objects": [], "skipped": [], "ts": 0}
        # Pre-Print-Skip: pending until printer enters printing state
        self._pending_preprint_skip: list[str] = []
        self._pending_preprint_skip_deadline: float = 0.0

        # Spoolman filament tracking
        _sm_url = (getattr(args, "spoolman_server", "") or "").strip()
        self._spoolman: SpoolmanClient | None = (
            SpoolmanClient(_sm_url, getattr(args, "spoolman_sync_rate", 0))
            if _sm_url else None
        )
        # Persistierte Spool-Zuordnung (AMS-Slot → Spoolman-Spool) je Drucker laden.
        # Fix: this used to reference `config_loader`, but the module alias is
        # `env_loader` (line 32) -> NameError swallowed by the bare `except`,
        # so persistence never loaded. Now via the local import + per printer.
        try:
            import config_loader as _cl
            self._spoolman_slot_spools: dict[int, int] = _cl.list_spool_map(self._printer_id)
        except Exception as _e:
            log.warning("Spoolman: failed to load slot map: %s", _e)
            self._spoolman_slot_spools = {}  # {ams_slot_idx: spoolman_spool_id}
        self._spoolman_slot_usage: dict[int, float] = {}   # per-slot accumulated mm this print
        self._spoolman_slot_reported: dict[int, float] = {}  # per-slot mm already sent to Spoolman
        self._spoolman_last_usage: float = 0.0   # supplies_usage at last attribution tick
        self._spoolman_last_sync: float = 0.0

        # Validate theme name (no special characters or umlauts)
        raw_theme = (getattr(args, "ui_theme", None) or "default").strip()
        if not _UI_THEME_NAME_RE.match(raw_theme):
            log.warning("Invalid UI theme name %r – using default", raw_theme)
            raw_theme = "default"
        self._ui_theme = raw_theme
        self._index_tpl_cache: str | None = None
        self._index_tpl_cache_key: tuple[str, float] | None = None

        # Register MQTT push callbacks
        client.callbacks["tempature/report"]      = self._on_temp
        client.callbacks["print/report"]          = self._on_print
        client.callbacks["info/report"]           = self._on_info
        client.callbacks["file/report"]           = self._on_file
        client.callbacks["multiColorBox/report"]  = self._on_multicolor_box
        client.callbacks["light/report"]          = self._on_light
        client.callbacks["skip/report"]           = self._on_skip

        # Reachability is rechecked periodically (not just once at boot) so the
        # UI status dot reflects the printer's/Spoolman's actual current state
        # instead of freezing on the boot-time result.
        self._spoolman_reachable: bool = False
        self._spoolman_last_health_check: float = 0.0
        if self._spoolman:
            def _check():
                ok = self._spoolman.health_check()
                self._spoolman_reachable = ok
                self._spoolman_last_health_check = time.time()
                log.info(f"Spoolman: {'OK' if ok else 'unreachable'} at {self._spoolman.server_url}")
            threading.Thread(target=_check, daemon=True, name="spoolman-health").start()

    # ── Spoolman helpers ──────────────────────────────────────────────────────

    def _spoolman_filament_mm(self) -> float:
        """Total filament_used_mm for the current print file from the GCode DB."""
        filename = self._state.get("filename", "")
        if not filename:
            return 0.0
        try:
            gf = self._store.get_file_by_name(filename)
            return float(gf.get("filament_used_mm") or 0.0) if gf else 0.0
        except Exception:
            return 0.0

    def _spoolman_attribute_tick(self, activity_map: dict) -> None:
        """Attribute the supplies_usage delta since last tick to the active slot.

        Skips attribution during loading/unloading transitions (tool changes +
        purges) to avoid charging the wrong spool for purge material."""
        if not self._spoolman or not self._spoolman_slot_spools:
            return
        if self._state.get("print_state") != "printing":
            return
        current = self._state.get("supplies_usage", 0)
        delta = current - self._spoolman_last_usage
        self._spoolman_last_usage = current
        if delta <= 0:
            return
        loaded = self._ams_loaded_slot
        if loaded < 0:
            return
        if activity_map.get(loaded):
            return
        self._spoolman_slot_usage[loaded] = self._spoolman_slot_usage.get(loaded, 0.0) + delta

    def _spoolman_unreported(self) -> dict[int, float]:
        """Return {slot_idx: mm} of usage not yet reported to Spoolman.

        Falls back to equal split of total supplies_usage when per-slot
        attribution data is absent (e.g. single-extruder with no AMS)."""
        total_used = self._state.get("supplies_usage", 0)
        if self._spoolman_slot_usage:
            return {
                slot: self._spoolman_slot_usage.get(slot, 0.0)
                       - self._spoolman_slot_reported.get(slot, 0.0)
                for slot in self._spoolman_slot_spools
            }
        n = len(self._spoolman_slot_spools)
        already = sum(self._spoolman_slot_reported.values())
        per = (total_used - already) / n if n else 0.0
        return {slot: per for slot in self._spoolman_slot_spools}

    def _spoolman_report(self, unreported: dict[int, float], min_mm: float = 0.1) -> None:
        """Fire-and-forget report of unreported mm to each mapped spool."""
        sm = self._spoolman
        for slot_idx, mm in unreported.items():
            if mm < min_mm:
                continue
            spool_id = self._spoolman_slot_spools.get(slot_idx)
            if not spool_id:
                continue
            self._spoolman_slot_reported[slot_idx] = (
                self._spoolman_slot_reported.get(slot_idx, 0.0) + mm
            )
            def _send(sid=spool_id, length=mm):
                try:
                    sm.use_filament(sid, length)
                    log.info(f"Spoolman: {length:.1f} mm → spool {sid}")
                except Exception as e:
                    log.warning(f"Spoolman: report failed (spool {sid}): {e}")
            threading.Thread(target=_send, daemon=True, name="spoolman-report").start()

    def _spoolman_notify_end(self):
        """Report remaining filament on print end."""
        if not self._spoolman or not self._spoolman_slot_spools:
            return
        self._spoolman_report(self._spoolman_unreported())

    def _spoolman_sync_midprint(self):
        """Report incremental filament usage during a print (sync_rate interval)."""
        if not self._spoolman or not self._spoolman_slot_spools:
            return
        self._spoolman_report(self._spoolman_unreported(), min_mm=10.0)

    # ── Spoolman API handlers ─────────────────────────────────────────────────

    async def handle_kx_spoolman_status(self, request):
        """GET /kx/spoolman/status"""
        return self._json_cors({
            "configured":   bool(self._spoolman),
            "reachable":    self._spoolman_reachable if self._spoolman else False,
            "server":       self._spoolman.server_url if self._spoolman else "",
            "sync_rate":    self._spoolman.sync_rate if self._spoolman else 0,
            "slot_spools":  {str(k): v for k, v in self._spoolman_slot_spools.items()},
        })

    async def handle_kx_spoolman_spools(self, request):
        """GET /kx/spoolman/spools — proxied from Spoolman."""
        if not self._spoolman:
            return self._json_cors({"error": "Spoolman not configured"}, status=503)
        try:
            spools = await asyncio.get_event_loop().run_in_executor(
                None, self._spoolman.list_spools
            )
            return self._json_cors({"spools": spools})
        except Exception as e:
            log.warning(f"Spoolman: list_spools failed: {e}")
            return self._json_cors({"error": str(e)}, status=502)

    async def handle_kx_spoolman_set_active(self, request):
        """POST /kx/spoolman/active-spool
        Body: {"slot_map": {"0": 42, "2": 17}}  — AMS slot index → Spoolman spool ID."""
        try:
            data = await request.json()
        except Exception:
            return self._json_cors({"error": "invalid JSON"}, status=400)
        slot_map = data.get("slot_map") or data.get("slot_spools") or {}
        self._spoolman_slot_spools = {
            int(k): int(v) for k, v in slot_map.items()
            if str(v).isdigit() and int(v) > 0
        }
        # Persist per printer (own [spoolman_<id>] section) so the
        # assignment survives bridge restarts and two AMS units don't overwrite each other.
        # (Previously: NameError on `config_loader` -> nothing was ever saved.)
        try:
            import config_loader as _cl
            _cl.save_spool_map(self._spoolman_slot_spools, self._printer_id)
        except Exception as _e:
            log.warning("Spoolman: failed to save slot map: %s", _e)
        self._spoolman_slot_usage = {}
        self._spoolman_slot_reported = {}
        self._spoolman_last_usage = 0.0
        return self._json_cors({"slot_spools": {str(k): v for k, v in self._spoolman_slot_spools.items()}})

    def _default_ace_dry_presets(self) -> dict[str, dict]:
        return {
            "pla": {"temp": 45, "duration_sec": 4 * 3600},
        "pla_plus": {"temp": 45, "duration_sec": 4 * 3600},
        "petg": {"temp": 50, "duration_sec": 4 * 3600},
        "tpu": {"temp": 55, "duration_sec": 4 * 3600},
        "abs_asa": {"temp": 45, "duration_sec": 8 * 3600},
        "pa_pc": {"temp": 55, "duration_sec": 12 * 3600},
        "custom_1": {"name": "Custom 1", "temp": 45, "duration_sec": 4 * 3600},
        "custom_2": {"name": "Custom 2", "temp": 45, "duration_sec": 4 * 3600},
        "custom_3": {"name": "Custom 3", "temp": 45, "duration_sec": 4 * 3600},
        }

    def _sanitize_ace_dry_presets(self, presets: dict) -> dict[str, dict]:
        out = self._default_ace_dry_presets()
        for key in list(out.keys()):
            src = presets.get(key) if isinstance(presets, dict) else None
            if not isinstance(src, dict):
                continue
            try:
                t = int(src.get("temp", out[key]["temp"]))
            except Exception:
                t = out[key]["temp"]
            try:
                d = int(src.get("duration_sec", out[key]["duration_sec"]))
            except Exception:
                d = out[key]["duration_sec"]
            out[key]["temp"] = max(30, min(80, t))
            out[key]["duration_sec"] = max(10 * 60, min(24 * 3600, d))
            if key.startswith("custom_"):
                name = str(src.get("name", out[key].get("name", key.replace("_", " ").title()))).strip()
                out[key]["name"] = name or out[key].get("name", "Custom")
        return out

    def _load_ace_dry_presets_config(self) -> dict[str, dict]:
        import configparser
        defaults = self._default_ace_dry_presets()
        cfg_path = self._find_config_path()
        if not cfg_path.is_file():
            return defaults
        cfg = configparser.ConfigParser()
        cfg.read(cfg_path, encoding="utf-8")
        sec = "ace_dry_presets"
        if not cfg.has_section(sec):
            return defaults
        out = {}
        for key, d in defaults.items():
            temp_k = f"{key}_temp"
            dur_k = f"{key}_duration_sec"
            try:
                temp = int(cfg.get(sec, temp_k, fallback=str(d["temp"])))
            except Exception:
                temp = d["temp"]
            try:
                dur = int(cfg.get(sec, dur_k, fallback=str(d["duration_sec"])))
            except Exception:
                dur = d["duration_sec"]
            out[key] = {
                "temp": max(30, min(80, temp)),
                "duration_sec": max(10 * 60, min(24 * 3600, dur)),
            }
            if key.startswith("custom_"):
              name_k = f"{key}_name"
              name = cfg.get(sec, name_k, fallback=str(d.get("name", key.replace("_", " ").title()))).strip()
              out[key]["name"] = name or str(d.get("name", "Custom"))
        return out

    # -------------------------------------------------------------------------
    # Notifications (apprise)
    # -------------------------------------------------------------------------

    async def _capture_notification_image(self, timeout: float = 15.0) -> bytes:
        """Liefert einen aktuellen JPEG-Frame für Bild-Benachrichtigungen.

        Falls die Kamera nicht aktiv streamt, wird sie kurz eingeschaltet, ein
        Frame abgegriffen und – sofern wir sie selbst gestartet haben – wieder
        ausgeschaltet (toggle on → snapshot → toggle off).
        """
        loop = asyncio.get_event_loop()
        # 1) Bereits ein frischer Frame im Cache (z. B. WebUI schaut zu)? → direkt nutzen.
        if self.camera_cache.latest_jpeg and (time.time() - self.camera_cache.latest_jpeg_ts) < 3.0:
            return self.camera_cache.latest_jpeg

        started_here = False
        try:
            # 2) Kamera am Drucker einschalten, falls sie nicht ohnehin läuft.
            if not self._camera_autostarted:
                try:
                    await loop.run_in_executor(None, self.client.start_camera)
                    started_here = True
                    log.debug("Kamera für Bild-Benachrichtigung temporär gestartet")
                except Exception as e:
                    log.warning(f"Kamera-Start für Benachrichtigung fehlgeschlagen: {e}")
                # rtspUrl aktualisieren (kommt im info/report mit, wenn Kamera streamt)
                try:
                    info = await loop.run_in_executor(None, self.client.query_info)
                    if info:
                        self._on_info(info)
                except Exception:
                    pass

            url = self._state.get("camera_url", "")
            if not url:
                return b""
            self.camera_cache.set_url(url)
            await self.camera_cache.ensure_running()

            # 3) Auf einen frischen Frame warten (ffmpeg + RTSP-Warmup).
            ref_ts = time.time()
            deadline = ref_ts + timeout
            while time.time() < deadline:
                if self.camera_cache.latest_jpeg and self.camera_cache.latest_jpeg_ts >= ref_ts:
                    return self.camera_cache.latest_jpeg
                await asyncio.sleep(0.3)
            # Kein frischer Frame – notfalls den letzten vorhandenen nehmen.
            return self.camera_cache.latest_jpeg
        finally:
            # 4) Wieder ausschalten, wenn wir sie nur für den Snapshot gestartet
            #    haben (nicht während eines laufenden Drucks mit Auto-Kamera).
            if started_here and not self._camera_autostarted:
                try:
                    await loop.run_in_executor(None, self.client.stop_camera)
                    log.debug("Kamera nach Bild-Benachrichtigung wieder gestoppt")
                except Exception as e:
                    log.debug(f"Kamera-Stop nach Benachrichtigung fehlgeschlagen: {e}")

    def _notify(self, event: str, filename: str = ""):
        if self._notifications_paused:
            return
        matching = [e for e in self._notification_urls
                    if e.get("enabled", True) and event in e.get("events", [])]
        if not matching:
            return
        printer_name = self._state.get("printer_name", "KX-Bridge")
        titles = {
            "started":   "Print Started ▶",
            "finished":  "Print Finished ✓",
            "failed":    "Print Failed ✗",
            "cancelled": "Print Cancelled",
            "paused":    "Print Paused ⏸",
            "progress":  "Print Progress",
        }
        title = titles.get(event, "KX-Bridge")
        if event == "progress":
            pct   = int(self._state.get("progress", 0) * 100)
            curr  = self._state.get("curr_layer", 0)
            total = self._state.get("total_layers", 0)
            rem   = self._state.get("remain_time", 0)
            rem_str = f"{rem // 3600}h {(rem % 3600) // 60}m" if rem else ""
            parts = [f"{pct}%"]
            if total:
                parts.append(f"Layer {curr}/{total}")
            if rem_str:
                parts.append(f"Remaining: {rem_str}")
            if filename:
                parts.append(filename)
            body = "  •  ".join(parts) if parts else printer_name
        else:
            body = filename if filename else printer_name
            if filename and printer_name:
                body = f"{filename}\n{printer_name}"

        urls_plain = [e["url"] for e in matching if not e.get("include_image")]
        urls_image = [e["url"] for e in matching if e.get("include_image")]

        def _send():
            import apprise, os, tempfile
            try:
                if urls_plain:
                    ap = apprise.Apprise()
                    for u in urls_plain:
                        ap.add(u)
                    ap.notify(title=title, body=body)
                if urls_image:
                    ap2 = apprise.Apprise()
                    for u in urls_image:
                        ap2.add(u)
                    attach = None
                    tmppath = None
                    jpeg = b""
                    if self._loop is not None:
                        try:
                            fut = asyncio.run_coroutine_threadsafe(
                                self._capture_notification_image(timeout=15.0), self._loop)
                            jpeg = fut.result(timeout=30.0) or b""
                        except Exception as e:
                            log.warning(f"Kamera-Snapshot für Benachrichtigung fehlgeschlagen: {e}")
                            jpeg = self.camera_cache.latest_jpeg
                    else:
                        jpeg = self.camera_cache.latest_jpeg
                    if jpeg:
                        try:
                            with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
                                f.write(jpeg)
                                tmppath = f.name
                            attach = apprise.AppriseAttachment()
                            attach.add(tmppath)
                        except Exception as e:
                            log.warning(f"Kamera-Anhang fehlgeschlagen: {e}")
                            attach = None
                    else:
                        log.debug("Bild-Benachrichtigung ohne Anhang (kein Kamera-Frame verfügbar)")
                    ap2.notify(title=title, body=body, attach=attach)
                    if tmppath:
                        try:
                            os.unlink(tmppath)
                        except Exception:
                            pass
                log.info(f"Benachrichtigung '{event}' gesendet ({len(matching)} URL(s))")
            except Exception as e:
                log.warning(f"Benachrichtigung fehlgeschlagen: {e}")

        threading.Thread(target=_send, daemon=True).start()

    def _check_progress_notifications(self):
        if self._state.get("kobra_state") != "printing":
            return
        if not any("progress" in e.get("events", []) for e in self._notification_urls
                   if e.get("enabled", True)):
            return
        if self._notify_every_minutes == 0 and self._notify_every_layers == 0:
            return
        now = time.monotonic()
        curr_layer = self._state.get("curr_layer", 0)
        fired = False
        if self._notify_every_minutes > 0:
            if now - self._last_progress_notif_time >= self._notify_every_minutes * 60:
                fired = True
        if not fired and self._notify_every_layers > 0 and curr_layer > 0:
            if curr_layer - self._last_progress_notif_layer >= self._notify_every_layers:
                fired = True
        if fired:
            self._last_progress_notif_time = now
            self._last_progress_notif_layer = curr_layer
            self._notify("progress", self._state.get("filename", ""))

    # -------------------------------------------------------------------------
    # MQTT callbacks (called from reader thread)
    # -------------------------------------------------------------------------

    def _on_temp(self, payload: dict):
        d = payload.get("data") or {}
        self._state["nozzle_temp"]   = float(d.get("curr_nozzle_temp", 0))
        self._state["nozzle_target"] = float(d.get("target_nozzle_temp", 0))
        self._state["bed_temp"]      = float(d.get("curr_hotbed_temp", 0))
        self._state["bed_target"]    = float(d.get("target_hotbed_temp", 0))
        self._push_status_update()

    def _on_print(self, payload: dict):
        d = payload.get("data") or {}
        kobra_state = payload.get("state", "")
        self._state["print_state"]    = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "printing")
        if kobra_state:
            self._state["kobra_state"] = kobra_state

        # Automatically switch on the camera at print start (settings option).
        # Centralized here so it covers all print start paths (OrcaSlicer + UI).
        # _camera_autostarted verhindert Mehrfach-Trigger pro Druck.
        if kobra_state == "printing":
            if (getattr(self._args, "camera_on_print", 0)
                    and not self._camera_autostarted
                    and not self._camera_user_stopped):
                self._camera_autostarted = True
                try:
                    self.client.start_camera()
                    log.info("Camera switched on automatically at print start")
                except Exception as e:
                    log.warning(f"Camera auto-start failed: {e}")
        elif kobra_state in ("free", "finished", "stoped", "canceled"):
            self._camera_autostarted = False
            self._camera_user_stopped = False  # release for the next print
            self._notified_print_started = False
        
        if kobra_state in ("pause", "paused"):
            pause_msg = payload.get("msg", "")
            if pause_msg:
                error_code = payload.get("code", 0)
                self._state["error_code"] = error_code
                self._state["pause_msg"] = pause_msg
                log.warning(f"Printer paused: [{error_code}] {pause_msg}")
        elif kobra_state in ("resuming", "resumed", "printing", "finished", "stoped", "canceled"):
            self._state["error_code"] = 0
            self._state["pause_msg"] = ""

        # Job-History: Druckstart erkennen
        if kobra_state == "printing" and not self._current_job_id:
            filename = d.get("filename", self._state.get("filename", ""))
            if filename:
                gf = self._store.get_file_by_name(filename)
                if gf:
                    self._current_job_id = self._store.start_job(
                        gcode_file_id=gf["id"],
                        printer_id=self._printer_id,
                    )
                    log.info(f"Job started: {self._current_job_id} for {filename}")
            self._spoolman_slot_usage = {}
            self._spoolman_slot_reported = {}
            self._spoolman_last_usage = 0.0
            self._spoolman_last_sync = 0.0

        # Job-History: Druckende erkennen
        if kobra_state in ("finished",) and self._current_job_id:
            self._store.finish_job(self._current_job_id, status="completed")
            log.info(f"Job abgeschlossen: {self._current_job_id}")
            self._spoolman_notify_end()
            self._current_job_id = ""
        elif kobra_state in ("stoped", "canceled") and self._current_job_id:
            self._store.finish_job(self._current_job_id, status="cancelled")
            log.info(f"Job abgebrochen: {self._current_job_id}")
            self._spoolman_notify_end()
            self._current_job_id = ""

        # Benachrichtigungen bei Statuswechsel (filename vor Cleanup holen)
        if kobra_state and kobra_state != self._prev_kobra_state:
            _notif_filename = d.get("filename", self._state.get("filename", ""))
            if kobra_state == "printing":
                # "printing" is re-entered many times within the same job (after
                # pause/resume, auto-leveling, filament checks, ...), each of which
                # would otherwise re-trigger a "started" notification mid-print.
                if not self._notified_print_started:
                    self._notified_print_started = True
                    self._notify("started", _notif_filename)
            elif kobra_state == "finished":
                self._notify("finished", _notif_filename)
                self._notified_print_started = False
            elif kobra_state == "failed":
                self._notify("failed", _notif_filename)
                self._notified_print_started = False
            elif kobra_state in ("stoped", "canceled"):
                self._notify("cancelled", _notif_filename)
                self._notified_print_started = False
            elif kobra_state in ("pause", "paused"):
                self._notify("paused", _notif_filename)
            self._prev_kobra_state = kobra_state

        # Terminal states (successful finish AND stop/cancel) must leave the
        # same clean end state - a "finished" print used to only clear
        # file_ready (Issue #29), leaving progress/filename/duration/layer
        # fields stuck at the last job's values until the *next* print
        # happened to overwrite them (Issue #102).
        if kobra_state in ("finished", "stoped", "canceled"):
            self._state["progress"] = 0.0
            self._state["filename"] = ""
            self._state["file_ready"] = ""
            self._state["print_duration"] = 0
            self._state["remain_time"] = 0
            self._state["slicer_time"] = 0
            self._state["layer_height"] = 0.0
            self._state["first_layer_height"] = 0.0
            self._state["supplies_usage"] = 0
            self._state["curr_layer"] = 0
            self._state["total_layers"] = 0
            self._thumbnail_b64 = ""
        else:
            # Only adopt the payload's filename outside terminal states - the
            # printer often still reports the just-finished job's filename in
            # the same "finished"/"stoped"/"canceled" message that triggered
            # the reset above, which would otherwise immediately undo it.
            self._state["filename"] = d.get("filename", self._state["filename"])
        # Pre-print phases (leveling/preheating/checking) report their own
        # "progress" - passing it through would make display_status.progress/
        # virtual_sdcard.progress jump non-monotonically once real printing
        # starts and the value resets (Issue #102).
        if "progress" in d and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"):
            self._state["progress"]   = float(d["progress"]) / 100.0
        if "print_time" in d:
            self._state["print_duration"] = int(d["print_time"]) * 60
        if "remain_time" in d:
            self._state["remain_time"] = int(d["remain_time"]) * 60
        if "curr_layer" in d:
            self._state["curr_layer"] = d["curr_layer"]
        if "total_layers" in d:
            self._state["total_layers"] = d["total_layers"]
        if "taskid" in d:
            self._state["taskid"] = str(d["taskid"])
        if "supplies_usage" in d:
            self._state["supplies_usage"] = int(d["supplies_usage"])
        settings = d.get("settings") or {}
        if "print_speed_mode" in settings:
            self._state["print_speed_mode"] = int(settings["print_speed_mode"])
        self._push_status_update()

    def _on_info(self, payload: dict):
        d = payload.get("data") or {}
        # Only adopt the MQTT name if no custom name is set (env or per-printer config)
        if not env_loader.get("BRIDGE_PRINTER_NAME") and not getattr(self, "_name_locked", False):
            self._state["printer_name"] = d.get("printerName", self._state["printer_name"])
        self._state["firmware_version"] = d.get("version", self._state["firmware_version"])
        # The real print state lives in info/report inside the nested
        # project.state ("printing"/"paused"/...). The top-level data.state is only
        # the device state ("busy"/"free") and would swallow "paused".
        project = d.get("project") or {}
        proj_state = project.get("state", "")
        kobra_state = proj_state or d.get("state", "")
        if kobra_state:
            self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "standby")
            self._state["kobra_state"] = kobra_state
            # Hide the upload banner after the print ends (Issue #29) - the state also
            # arrives via info/report (project.state) depending on the printer, not only print/report.
            # Layer fields must reset here too (Issue #102) - info/report is the
            # only source for curr_layer/total_layers on some printers, and they
            # otherwise stay stuck at the last job's values indefinitely.
            if kobra_state in ("finished", "stoped", "canceled"):
                self._state["file_ready"] = ""
                self._state["curr_layer"] = 0
                self._state["total_layers"] = 0
            # Camera auto-start here as well (OrcaSlicer often reports the start via info/report).
            # The _camera_autostarted guard prevents a double start with _on_print.
            if kobra_state == "printing":
                if (getattr(self._args, "camera_on_print", 0)
                        and not self._camera_autostarted
                        and not self._camera_user_stopped):
                    self._camera_autostarted = True
                    try:
                        self.client.start_camera()
                        log.info("Camera switched on automatically at print start")
                    except Exception as e:
                        log.warning(f"Camera auto-start failed: {e}")
            elif kobra_state in ("free", "finished", "stoped", "canceled"):
                self._camera_autostarted = False
                self._camera_user_stopped = False  # release for the next print
                self._print_active = False
        if project:
            if "filename" in project:
                self._state["filename"] = project["filename"]
            # Same non-monotonic-progress guard as _on_print (Issue #102).
            if "progress" in project and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"):
                self._state["progress"] = float(project["progress"]) / 100.0
            if "print_time" in project:
                self._state["print_duration"] = int(project["print_time"]) * 60
            if "remain_time" in project:
                self._state["remain_time"] = int(project["remain_time"]) * 60
            if "curr_layer" in project:
                self._state["curr_layer"] = project["curr_layer"]
            if "total_layers" in project:
                self._state["total_layers"] = project["total_layers"]
        t = d.get("temp") or {}
        if t:
            self._state["nozzle_temp"]   = float(t.get("curr_nozzle_temp", 0))
            self._state["nozzle_target"] = float(t.get("target_nozzle_temp", 0))
            self._state["bed_temp"]      = float(t.get("curr_hotbed_temp", 0))
            self._state["bed_target"]    = float(t.get("target_hotbed_temp", 0))
        urls = d.get("urls") or {}
        if urls.get("fileUploadurl"):
            self._state["upload_url"] = urls["fileUploadurl"]
        if urls.get("rtspUrl"):
            self._state["camera_url"] = urls["rtspUrl"]
            self.camera_cache.set_url(urls["rtspUrl"])
        fan = d.get("fan_speed_pct")
        if fan is not None:
            self._state["fan_speed"] = int(fan)
        speed_mode = d.get("print_speed_mode")
        if speed_mode is not None:
            self._state["print_speed_mode"] = int(speed_mode)
        self._push_status_update()

    def _on_skip(self, payload: dict):
        """skip/report-Callback (Part-Skip-Feature, v0.9.10).

        The printer ALWAYS reports the list of already-skipped objects here
        (objects_skip_parts), whether on query_obj or after skip/start.
        The full object list comes from file/report.
        """
        d = payload.get("data") or {}
        skipped = d.get("objects_skip_parts") or d.get("skipped") or d.get("skipped_parts") or []
        # While a pre-print skip is still pending, ignore empty early reports
        # so the UI doesn't snap back before the printer confirms the skip.
        now = time.time()
        if (not skipped and self._pending_preprint_skip
                and now <= self._pending_preprint_skip_deadline):
            return

        # During an active print, skip states are effectively monotonic.
        # Some firmware reports come back empty/partial in between;
        # those must not remove already-confirmed skip objects from the UI.
        existing_skipped = [str(n) for n in (self._skip_state.get("skipped") or []) if n]
        existing_set = set(existing_skipped)
        incoming_skipped = [str(n) for n in (skipped or []) if n]
        incoming_set = set(incoming_skipped)
        active_print = self._state.get("print_state") in ("printing", "paused")
        if active_print and existing_set:
            if not incoming_set:
                skipped = list(existing_skipped)
            elif not incoming_set.issuperset(existing_set):
                merged = list(existing_skipped)
                for n in incoming_skipped:
                    if n not in existing_set:
                        merged.append(n)
                skipped = merged

        # Release the pending lock once the printer confirms the requested objects
        if self._pending_preprint_skip and set(skipped) >= set(self._pending_preprint_skip):
            self._pending_preprint_skip = []
            self._pending_preprint_skip_deadline = 0.0
        self._skip_state = {
            "skipped":  list(skipped),
            "ts":       int(time.time()),
        }
        if payload.get("state") == "done" or payload.get("code") == 200:
            log.info(f"Skip response: state={payload.get('state')} code={payload.get('code')} skipped={skipped}")

    def _wait_for_file_action(self, action: str, send_fn, timeout: float = 8.0) -> dict | None:
        """Sends a file/* MQTT request (via send_fn, which must call
        self.client.publish(..., timeout=0) fire-and-forget) and blocks the
        calling thread until a matching file/report with this `action`
        arrives via _on_file, or the timeout elapses.

        Needed because the printer's publish() return value for actions like
        listLocal/deleteBatch is just a generic immediate ACK skeleton
        (code=0, empty fields) - the real response is a separate, later
        file/report message, same as the existing fileDetails pattern.
        Must be called from a worker thread (e.g. via run_in_executor), not
        the asyncio event loop, since it blocks on a threading.Event.
        """
        event = threading.Event()
        waiter = {"event": event, "result": None}
        self._file_action_waiters[action] = waiter
        try:
            send_fn()
            event.wait(timeout)
            return waiter["result"]
        finally:
            if self._file_action_waiters.get(action) is waiter:
                del self._file_action_waiters[action]

    def _on_file(self, payload: dict):
        # Deliver to any pending listLocal/deleteBatch waiter first (see
        # _wait_for_file_action) - these actions carry no file_details/
        # thumbnail payload of their own, so this doesn't interfere with the
        # handling below.
        action = payload.get("action") or ""
        waiter = self._file_action_waiters.get(action)
        if waiter is not None:
            waiter["result"] = payload
            waiter["event"].set()

        d = payload.get("data") or {}
        details = d.get("file_details") or {}
        thumb = details.get("thumbnail") or details.get("png_image") or ""
        file_name = d.get("filename") or details.get("filename") or self._last_uploaded_file
        active_print = self._state.get("print_state") in ("printing", "paused")
        current_print_file = self._state.get("filename") or ""
        # Uploads during a running print must not overwrite the active
        # progress preview.
        if thumb and (not active_print or (file_name and file_name == current_print_file)):
            self._thumbnail_b64 = thumb
            log.info(f"Thumbnail received: {len(thumb)} base64 chars")
        # Part-Skip: Objekt-Liste + optionales SVG (v0.9.10)
        objs = details.get("objects_skip_parts") or []
        svg  = details.get("svg_image") or ""
        if objs:
            filename = file_name
            if filename:
                try:
                    self._store.update_file_objects(filename, objs, svg)
                    log.info(f"Skip objects for {filename}: {len(objs)} ({'with SVG' if svg else 'no SVG'})")
                except Exception as e:
                    log.warning(f"update_file_objects failed: {e}")
        self._push_status_update()

    def _apply_preprint_skip_after_start(self, names: list[str], retries: int = 20, delay_s: float = 0.75):
        """Sends the skip command only after the printer switched to the printing state.

        Before that, the command goes nowhere (no active print).
        """
        wanted = [str(n) for n in (names or []) if isinstance(n, str) and n]
        if not wanted:
            return False
        for i in range(max(1, int(retries))):
            try:
                if self._state.get("print_state") not in ("printing", "paused"):
                    time.sleep(max(0.1, float(delay_s)))
                    continue
                resp = self.client.skip_objects(wanted)
                if resp is not None:
                    log.info(f"Pre-Print skip applied ({len(wanted)} objects) on attempt {i+1}/{retries}")
                    self._pending_preprint_skip = []
                    self._pending_preprint_skip_deadline = 0.0
                    return True
            except Exception as e:
                log.debug(f"Pre-Print skip attempt {i+1}/{retries} failed: {e}")
            time.sleep(max(0.1, float(delay_s)))
        log.warning(f"Pre-Print skip could not be confirmed after {retries} attempts")
        self._pending_preprint_skip = []
        self._pending_preprint_skip_deadline = 0.0
        return False

    @staticmethod
    def _detect_filament_mode(boxes: list, head_tools_model: int = -1) -> str:
        """Detect active filament topology mode.

        Modes:
        - toolhead: only toolhead slots
        - ace_direct: ACE channels directly mapped, no toolhead box present.
          Covers one unit (Kobra X) as well as multiple daisy-chained units
          (Kobra S1 with 2+ ACE Pro, Issue #95) — each unit contributes a
          block of 4 global slots at box_id * 4.
        - ace_hub: toolhead + ACE via hub (slot 4 as hub path)
        """
        toolhead = any(b.get("id") == -1 for b in boxes)
        ace = any(b.get("id", -1) >= 0 for b in boxes)
        if ace and toolhead:
            return "ace_hub"
        if ace:
            return "ace_direct"
        return "toolhead"

    @staticmethod
    def _aggregate_slots(boxes: list, mode: str = "toolhead") -> tuple:
        """Aggregate multi_color_box list into a flat global slot list."""
        toolhead = next((b for b in boxes if b.get("id") == -1), None)
        ace_boxes = sorted(
            [b for b in boxes if b.get("id", -1) >= 0],
            key=lambda b: b["id"]
        )

        global_slots: list = []
        global_loaded: int = -1

        if mode == "toolhead":
            if toolhead:
                for local_idx, s in enumerate(toolhead.get("slots") or []):
                    s = dict(s)
                    s["global_index"] = local_idx
                    s["box_id"] = -1
                    global_slots.append(s)
                loaded = toolhead.get("loaded_slot", -1)
                if loaded >= 0:
                    global_loaded = loaded
            return global_slots, global_loaded

        if mode == "ace_direct":
            # One or more ACE units, no toolhead buffer (Kobra X: 1 unit,
            # Kobra S1: up to 2+ units, Issue #95). Global index =
            # box_id * 4 + local slot, so the numbering matches
            # _global_to_box_slot's //4-%4 fallback and stays stable
            # regardless of report order.
            for ace in ace_boxes:
                ace_id = int(ace["id"])
                base = ace_id * 4
                for local_idx, s in enumerate((ace.get("slots") or [])[:4]):
                    s = dict(s)
                    s["global_index"] = base + local_idx
                    s["box_id"] = ace_id
                    global_slots.append(s)
                ace_loaded = ace.get("loaded_slot", -1)
                if 0 <= ace_loaded < 4:
                    global_loaded = base + ace_loaded
            return global_slots, global_loaded

        # ace_hub
        if toolhead:
            for local_idx, s in enumerate((toolhead.get("slots") or [])[:3]):
                s = dict(s)
                s["global_index"] = local_idx
                s["box_id"] = -1
                global_slots.append(s)
            th_loaded = toolhead.get("loaded_slot", -1)
            if 0 <= th_loaded <= 2:
                global_loaded = th_loaded

        for ace in ace_boxes:
            ace_id = ace["id"]
            base = 3 + ace_id * 4
            for local_idx, s in enumerate(ace.get("slots") or []):
                s = dict(s)
                s["global_index"] = base + local_idx
                s["box_id"] = ace_id
                global_slots.append(s)
            ace_loaded = ace.get("loaded_slot", -1)
            if ace_loaded >= 0:
                global_loaded = base + ace_loaded

        return global_slots, global_loaded

    def _global_to_box_slot(self, global_index: int) -> tuple:
        """Convert a global slot index to (box_id, local_slot_index)."""
        for s in self._ams_slots:
            if s.get("global_index") == global_index:
                return s.get("box_id", -1), s.get("index", global_index)

        ace_present = any(s.get("box_id", -1) >= 0 for s in self._ams_slots)
        if self._filament_mode == "ace_direct" and ace_present:
            return global_index // 4, global_index % 4
        if not ace_present or global_index < 3:
            return -1, global_index
        offset = global_index - 3
        return offset // 4, offset % 4

    def _slot_to_print_ams_index(self, global_index: int) -> int:
      """Convert UI/global slot index to printer print/start ams_index.

      In ace_hub mode, print/start uses global channel numbering where
      toolhead channels occupy 1..3 and ACE0 starts at index 4.
      """
      idx = int(global_index)
      if self._filament_mode == "ace_hub":
        box_id, local_slot = self._global_to_box_slot(idx)
        if box_id >= 0:
          return 4 + box_id * 4 + int(local_slot)
        return idx
      return idx

    def _slot_usable_for_print(self, global_index: int) -> bool:
        """Whether a global slot can be used for current filament mode."""
        slot = next((s for s in self._ams_slots if int(s.get("global_index", -1)) == int(global_index)), None)
        if not slot:
            return False
        if int(slot.get("status", 0)) != 5:
            return False

        box_id = int(slot.get("box_id", -1))
        if self._filament_mode == "ace_hub":
          # In hub mode, toolhead channels (0..2) and ACE channels are both printable.
          return box_id == -1 or box_id >= 0
        if self._filament_mode == "ace_direct":
            return box_id >= 0
        return box_id == -1

    def _loaded_slots_for_print(self) -> list[tuple[int, dict]]:
        """Loaded slots filtered for current filament mode."""
        loaded = [
            (int(s.get("global_index", i)), s)
            for i, s in enumerate(self._ams_slots)
        if s.get("status") == 5 and self._slot_usable_for_print(int(s.get("global_index", i)))
        ]
        return loaded

    def _select_loaded_slots_for_print(self, warn_on_empty_default: bool = False) -> list[tuple[int, dict]]:
        """Return loaded slots, honoring default_ams_slot when configured."""
        default_slot = getattr(self._args, "default_ams_slot", "auto")
        all_loaded = self._loaded_slots_for_print()
        if default_slot == "auto":
            return all_loaded

        try:
            slot_idx = int(default_slot)
        except ValueError:
            return all_loaded

        selected = [(i, s) for i, s in all_loaded if i == slot_idx]
        if selected:
            return selected

        if warn_on_empty_default:
            log.warning(f"Default slot {slot_idx} is empty - falling back to auto")
        return all_loaded

    @staticmethod
    def _slot_color_rgba(slot: dict) -> list[int]:
        color = slot.get("color", [255, 255, 255])
        if isinstance(color, list) and len(color) >= 3:
            return [int(color[0]), int(color[1]), int(color[2]), 255]
        return [255, 255, 255, 255]

    def _build_auto_ams_box_mapping(
        self,
        warn_on_empty_default: bool = False,
        loaded_slots: list[tuple[int, dict]] | None = None,
    ) -> list[dict]:
        """Build print mapping from currently loaded slots (no explicit dialog assignments)."""
        loaded = loaded_slots
        if loaded is None:
            loaded = self._select_loaded_slots_for_print(warn_on_empty_default=warn_on_empty_default)
        if not loaded:
            return []
        loaded_map = {gidx: s for gidx, s in loaded}
        max_idx = max(loaded_map.keys())
        # The printer interprets ams_box_mapping as an ordered list (entry N = TN).
        # Missing slots must be inserted as placeholders, otherwise everything shifts.
        # A placeholder must NOT reference a physically empty tray: the printer
        # rejects such an entry even for a tool the GCode never calls (printing
        # Filament 4 with the slot below it empty fails; all-full works). Point
        # gap placeholders at a definitely-loaded tray instead of the gap's own
        # (empty) index.
        fallback_gidx = max_idx  # highest loaded slot -> loaded + printable
        fallback_slot = loaded_map[fallback_gidx]
        fallback_ams = self._slot_to_print_ams_index(fallback_gidx)
        result = []
        for i in range(max_idx + 1):
            if i in loaded_map:
                s = loaded_map[i]
                result.append({
                    "paint_index": i,
                    "ams_index": self._slot_to_print_ams_index(i),
                    "paint_color": [255, 255, 255, 255],
                    "ams_color": self._slot_color_rgba(s),
                    "material_type": s.get("type", "PLA"),
                })
            else:
                result.append({
                    "paint_index": i,
                    "ams_index": fallback_ams,
                    "paint_color": [255, 255, 255, 255],
                    "ams_color": self._slot_color_rgba(fallback_slot),
                    "material_type": fallback_slot.get("type", "PLA"),
                })
        return result

    def _build_assigned_ams_box_mapping(self, assignments: list) -> tuple[list[dict], int, int]:
        """Build print mapping from UI filament assignments.

        Returns (mapping, unused_count, invalid_count).
        """
        slot_by_global_index = {
            int(s.get("global_index", i)): s
            for i, s in enumerate(self._ams_slots)
        }
        ams_box_mapping: list[dict] = []
        unused_count = 0
        invalid_count = 0

        for i, a in enumerate(assignments):
            try:
                if a.get("is_used") is False:
                    unused_count += 1
                    continue
                global_slot = int(a["slot_index"])
            except (ValueError, TypeError, KeyError):
                invalid_count += 1
                continue

            if global_slot < 0:
                unused_count += 1
                continue
            if not self._slot_usable_for_print(global_slot):
                invalid_count += 1
                continue

            slot = slot_by_global_index.get(global_slot, {})
            ams_box_mapping.append({
                # Preserve slicer paint indices (can be sparse when paint 0 is unused).
                "paint_index": a.get("paint_index", i),
                "ams_index": self._slot_to_print_ams_index(global_slot),
                "paint_color": a.get("paint_color", [255, 255, 255, 255]),
                "ams_color": self._slot_color_rgba(slot),
                "material_type": slot.get("type", a.get("material", "PLA")),
            })

        return ams_box_mapping, unused_count, invalid_count

    def _box_local_to_global(self, box_id: int, local_slot: int, boxes: list) -> int:
        """Convert (box_id, local slot) to global slot index for current topology."""
        if box_id == -1:
            return local_slot
        if self._filament_mode == "ace_direct":
            # Multi-ACE (Issue #95): each unit occupies its own block of 4.
            # Identical to the old `return local_slot` for a single unit (id 0).
            return box_id * 4 + local_slot
        return 3 + box_id * 4 + local_slot

    def _slot_activity_map(self, boxes: list, global_loaded: int = -1) -> dict:
        """Build {global_slot_index: loading|unloading} from feed_status data."""
        # Note: all boxes are considered — the old primary_ace_id filter (skip
        # every ACE box except the first in ace_direct mode) is gone since the
        # slot aggregation now handles multiple ACE units (Issue #95).
        activity: dict = {}
        for box in boxes:
            fs = box.get("feed_status") or {}
            current_status = int(fs.get("current_status", -1))
            local_slot = int(fs.get("slot_index", -1))
            feed_type = int(fs.get("type", -1))
            if current_status in (-1, 10, 11) or local_slot < 0:
                continue
            box_slots = box.get("slots") or []
            if local_slot >= len(box_slots) or (box_slots[local_slot] or {}).get("status") != 5:
                continue
            if feed_type == 1:
                act = "loading"
            elif feed_type == 2:
                act = "unloading"
            else:
                continue
            global_slot = self._box_local_to_global(int(box.get("id", -1)), local_slot, boxes)
            if feed_type == 1 and self._pending_load_slot >= 0 and global_slot != self._pending_load_slot:
                # Ignore transient firmware-reported loading slots that differ from the requested target.
                if global_loaded >= 0 and global_loaded != self._pending_load_slot:
                    activity[global_loaded] = "unloading"
                continue
            if feed_type == 1 and global_loaded >= 0 and global_slot != global_loaded:
                # During a slot swap the firmware reports the target slot immediately,
                # while the previously loaded slot is still being unloaded first.
                activity[global_loaded] = "unloading"
            activity[global_slot] = act
        return activity

    def _on_multicolor_box(self, payload: dict):
        if payload.get("state") == "failed":
            req = getattr(self, "_last_ams_set_request", None)
            log.warning(
                f"multiColorBox setInfo rejected by printer: request={req}  raw_response={payload.get('data')}"
            )
            self._state["last_ams_set_error"] = True
            return
        data = payload.get("data") or {}
        if not isinstance(data, dict):
            log.warning(f"multiColorBox/report: unexpected data shape: {data!r}")
            return
        boxes = data.get("multi_color_box") or []
        if not boxes:
            return
        self._state["last_ams_set_error"] = False
        self._head_tools_model = int(data.get("head_tools_model", self._head_tools_model))
        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)
        self._ams_loaded_slot = global_loaded
        self._update_ace_drying_state(data, boxes)
        for box in boxes:
            bid = int(box.get("id", -1))
            if 0 <= bid <= 3 and "auto_feed" in box:
                self._ace_auto_feed[bid] = int(box["auto_feed"])
        if self._pending_load_slot >= 0 and global_loaded == self._pending_load_slot:
          self._pending_load_slot = -1
        activity_map = self._slot_activity_map(boxes, global_loaded)
        for s in global_slots:
            s["activity"] = activity_map.get(s.get("global_index"), "")

        # Tip forming: after feed-in (status=10) or feed-out (status=11)
        # the original slicer automatically sends type=3 (extruder retract).
        # Check ALL boxes so ACE-triggered events are handled correctly.
        for box in boxes:
            fs = box.get("feed_status") or {}
            current_status = fs.get("current_status")
            slot_index = fs.get("slot_index", 0)
            box_id = box.get("id", -1)
            if current_status in (10, 11):
                def _tip_form(bi=box_id, si=slot_index, cs=current_status):
                    import time; time.sleep(2)
                    self.client.publish(
                        "multiColorBox", "feedFilament",
                        {"multi_color_box": [{"id": bi, "feed_status": {"slot_index": si, "type": 3}}]},
                        timeout=0
                    )
                    log.info(f"Tip forming (type=3) after status={cs} box={bi} slot={si}")
                threading.Thread(target=_tip_form, daemon=True).start()

        if global_slots:
            self._ams_slots = global_slots
            log.info(f"AMS slots received: {len(global_slots)}, loaded_slot={self._ams_loaded_slot}")
            self._push_status_update()

    def _update_ace_drying_state(self, data: dict, boxes: list):
        """Extract ACE drying state from multiColorBox report/getInfo payloads."""
        ace_ids = sorted({int(b.get("id", -1)) for b in boxes if int(b.get("id", -1)) >= 0})
        self._ace_box_ids = [i for i in ace_ids if 0 <= i <= 3]

        def _num_from(src: dict, keys: tuple[str, ...], default=None):
            for k in keys:
                v = src.get(k)
                if v is not None:
                    try:
                        return float(v)
                    except Exception:
                        return default
            return default

        def _humidity_from(src: dict, default=None):
            return _num_from(src, ("humidity", "current_humidity", "cur_humidity", "relative_humidity", "humidity_value"), default)

        def _current_temp_from(src: dict, default=None):
            return _num_from(src, ("current_temp", "cur_temp", "temperature", "temp", "drying_temp", "chamber_temp"), default)

        def _minutes_from(src: dict, key: str, default=0):
          raw = src.get(key, default)
          try:
            value = int(float(raw))
          except Exception:
            return int(default)
          # Some firmware payloads report dryer times in seconds while the UI uses minutes.
          if value > (24 * 60):
            return max(0, int(round(value / 60.0)))
          return max(0, value)

        per_unit: list[dict] = []
        for box in boxes:
            bid = int(box.get("id", -1))
            if bid < 0:
                continue

            bs = box.get("drying_status") or box.get("drying_settings")
            bs = bs if isinstance(bs, dict) else {}
            hu = _humidity_from(bs, _humidity_from(box))
            ct = _current_temp_from(bs, _current_temp_from(box))

            if bs or hu is not None or ct is not None:
                per_unit.append({
                    "id": bid,
                    "status": int(bs.get("status", 0)),
                    "target_temp": int(bs.get("target_temp", 0)),
                "duration": _minutes_from(bs, "duration", 0),
                "remain_time": _minutes_from(bs, "remain_time", 0),
                    "humidity": hu,
                    "current_temp": ct,
                })

        src = data.get("drying_status") or data.get("drying_settings")
        if not isinstance(src, dict):
            for box in boxes:
                if int(box.get("id", -1)) < 0:
                    continue
                cand = box.get("drying_status") or box.get("drying_settings")
                if isinstance(cand, dict):
                    src = cand
                    break

        if isinstance(src, dict):
          cur = self._state.get("ace_drying") or {}
          active = [u for u in per_unit if u.get("status", 0)]
          primary = active[0] if active else (per_unit[0] if per_unit else {})
          self._state["ace_drying"] = {
            "status": int(src.get("status", cur.get("status", 0))),
            "target_temp": int(src.get("target_temp", cur.get("target_temp", 0))),
            "duration": _minutes_from(src, "duration", cur.get("duration", 0)),
            "remain_time": _minutes_from(src, "remain_time", cur.get("remain_time", 0)),
            "humidity": _humidity_from(src, primary.get("humidity", cur.get("humidity"))),
            "current_temp": _current_temp_from(src, primary.get("current_temp", cur.get("current_temp"))),
            "units": per_unit,
          }
        elif per_unit:
            active = [u for u in per_unit if u.get("status", 0)]
            primary = active[0] if active else per_unit[0]
            self._state["ace_drying"] = {
                "status": int(primary.get("status", 0)),
                "target_temp": int(primary.get("target_temp", 0)),
                "duration": int(primary.get("duration", 0)),
                "remain_time": int(primary.get("remain_time", 0)),
                "humidity": primary.get("humidity"),
                "current_temp": primary.get("current_temp"),
                "units": per_unit,
            }

    def _on_light(self, payload: dict):
        d = payload.get("data") or {}
        self._state["light_on"]         = bool(d.get("status", 0))
        self._state["light_brightness"] = int(d.get("brightness", 80))
        self._push_status_update()

    # OrcaSlicer filament preset IDs (MoonrakerPrinterAgent.cpp mapping)
    # Default mapping per material type when the user has not set a slot
    # profile override. For the Kobra X we prefer Anycubic's own
    # filament IDs from the `@Anycubic Kobra X 0.4 nozzle` profiles - those
    # are printer-specific is_compatible and are picked up by OrcaSlicer directly
    # matched. Library fallbacks (OGF*) only for material types without
    # Kobra X-specific Anycubic profile - their @system profiles have
    # `compatible_printers: []` (= compatible with all printers).
    _TRAY_INFO_IDX = {
        # Anycubic-eigene Kobra-X-Profile
        "PLA":        "GFPLA",
        "PLA+":       "GFPLA+",
        "PLA SILK":   "GFPLA Silk",
        "PLA-SILK":   "GFPLA Silk",
        "PLASILK":    "GFPLA Silk",
        "SILK PLA":   "GFPLA Silk",
        "PLA MATTE":  "GFPLA",
        "PLA-MATTE":  "GFPLA",
        "PLA MARBLE": "GFPLA",
        "PLA WOOD":   "GFPLA",
        "PETG":       "GFPETG",
        "PETG+":      "GFPETG",
        "ABS":        "GFABS",
        "ASA":        "GFASA",
        "TPU":        "GFTPU 95A",
        "TPE":        "GFTPU 95A",
        "PVA":        "GFPVA",
        # Kein Anycubic-Kobra-X-Profil → Library-Fallback
        "PLA-CF":     "OGFL98",
        "PLA CF":     "OGFL98",
        "PETG-CF":    "OGFG98",
        "PETG CF":    "OGFG98",
        "PA":         "OGFN99",
        "PA-CF":      "OGFN98",
        "PA CF":      "OGFN98",
        "PC":         "OGFC99",
        "HIPS":       "OGFS98",
    }

    # Normalizes material type strings to the canonical key for _TRAY_INFO_IDX
    # and _default_filament_name. PLA variants without an exact match fall
    # back to their base family (PLA+ -> PLA+, PLA Matte -> PLA, etc.).
    @staticmethod
    def _normalize_material(mat: str) -> str:
        m = mat.upper().strip().replace("-", " ").replace("_", " ")
        # Bekannte Varianten normalisieren
        _ALIASES = {
            "PLAPLUS": "PLA+", "PLA PLUS": "PLA+",
            "SILK PLA": "PLA SILK", "PLASILK": "PLA SILK",
            "PLA MATTE": "PLA MATTE", "PLA MARBLE": "PLA MARBLE",
            "PLA WOOD": "PLA WOOD",
            "TPE": "TPU",
            "PETG PLUS": "PETG+",
            "PA6": "PA", "PA12": "PA", "PA66": "PA",
        }
        if m in _ALIASES:
            return _ALIASES[m]
        return m

    @staticmethod
    def _material_family(mat: str) -> str:
        """Reduce a material to its base polymer family.

        PLA / PLA+ / PLA SILK / PLA MATTE -> "PLA"; PETG / PETG+ -> "PETG"; etc.
        Used by the stale-profile guard: only a change of *family* (e.g. PETG ->
        PLA) invalidates a saved slot profile — a change within the family
        (PLA -> PLA SILK) must not discard an otherwise valid profile.
        """
        if not mat:
            return ""
        m = KobraXBridge._normalize_material(mat)
        # Longer prefixes first so "PETG" is not swallowed by "PET".
        for fam in ("PETG", "PLA", "ABS", "ASA", "TPU", "PVA", "HIPS", "PA", "PC", "PET"):
            if m.startswith(fam):
                return fam
        return m

    def _parse_combined_rfid_type(self, raw_type: str) -> tuple[str, str]:
        """Split a combined ACE-RFID "VENDOR TYPE SERIAL" string (e.g.
        "GEEETECH PLA Bas", written via third-party RFID tools) into
        (vendor, material_family).

        Anycubic's ACE RFID system concatenates vendor + material + a
        truncated serial/variant into one `type` string for custom tags -
        unlike a normal spool report where `type` is just "PLA"/"PETG"/etc.
        Returns ("", "") when the first token isn't a known vendor (from the
        merged system+user filament library), which leaves plain type
        strings like "PLA" completely unaffected (Issue #101).
        """
        tokens = raw_type.split()
        if len(tokens) < 2:
            return "", ""
        first = tokens[0].strip().lower()
        vendors = {p.get("vendor", "").lower(): p.get("vendor", "") for p in self._load_orca_filaments()}
        vendor = vendors.get(first)
        if not vendor:
            return "", ""
        family = self._material_family(" ".join(tokens[1:]))
        if not family:
            return "", ""
        return vendor, family

    def _match_profile_by_vendor_family(self, vendor: str, family: str) -> dict:
        """Find an imported/system filament profile by (vendor, material
        family) - used to auto-resolve a combined ACE-RFID type string to
        the user's already-imported OrcaSlicer profile (Issue #101), since
        the exact profile `name` never appears verbatim in the truncated
        RFID string."""
        matches = [
            p for p in self._load_orca_filaments()
            if p.get("vendor", "").lower() == vendor.lower()
            and self._material_family(p.get("type", "")) == family
        ]
        if not matches:
            return {}
        if len(matches) > 1:
            log.debug(
                f"_match_profile_by_vendor_family: {len(matches)} profiles match "
                f"vendor={vendor!r} family={family!r}, using first: {matches[0].get('name')}"
            )
        return matches[0]

    def _profile_material(self, profile: dict) -> str:
        """Material type (e.g. "PETG") of a saved slot profile, resolved by
        (vendor, name) from the Orca filament library. Returns "" when the
        profile is not in the library — we do NOT guess in that case."""
        name = (profile or {}).get("name", "")
        if not name:
            return ""
        vendor = profile.get("vendor", "")
        for p in self._load_orca_filaments():
            if p.get("vendor") == vendor and p.get("name") == name:
                return p.get("type", "") or ""
        return ""

    def _effective_slot_profile(self, global_idx: int, ams_material: str) -> dict:
        """Saved slot-profile override — but only while its material *family*
        still matches the material currently loaded in the AMS.

        Non-destructive suppression (Option A): when the family no longer matches
        (e.g. a PETG profile but PLA loaded) we return {} → the slot falls back to
        the generic default. The override stays in config.ini and reactivates as
        soon as the matching material is loaded again. When the profile's family
        is unknown we do NOT suppress (fail-safe)."""
        profile = self._filament_profiles.get(global_idx) or {}
        if not profile.get("name"):
            return {}
        prof_fam = self._material_family(self._profile_material(profile))
        ams_fam  = self._material_family(ams_material)
        if prof_fam and ams_fam and prof_fam != ams_fam:
            return {}
        return profile

    def _build_lane_data(self) -> dict:
      """Builds BBL AMS JSON for OrcaSlicer DevFilaSystemParser::ParseV1_0.

      POSITION-FAITHFUL: every physical slot keeps its position (tray id =
      slot position). Empty slots are reported as placeholder trays, NOT
      filtered out/compacted - otherwise colors shift to wrong positions
      (e.g. slot 1=yellow, 2=empty, 3=red -> red must not land on position 2).
      """
      slots = self._ams_slots
      total = len(slots)
      if total == 0:
        return {"ams": [], "ams_exist_bits": "0", "tray_exist_bits": "0"}

      ams_count = (total + 3) // 4
      ams_exist_bits = 0
      tray_exist_bits = 0
      ams_array = []

      for ams_id in range(ams_count):
        ams_exist_bits |= (1 << ams_id)
        tray_array = []
        max_slot = min(3, total - ams_id * 4 - 1)
        for slot_id in range(max_slot + 1):
          slot_index = ams_id * 4 + slot_id
          slot = slots[slot_index] if slot_index < total else {}
          occupied = slot.get("status") == 5

          if occupied:
            tray_exist_bits |= (1 << slot_index)
            color_raw = slot.get("color", [255, 255, 255])
            if isinstance(color_raw, list) and len(color_raw) >= 3:
              color_hex = "{:02X}{:02X}{:02X}FF".format(
                int(color_raw[0]), int(color_raw[1]), int(color_raw[2])
              )
            elif isinstance(color_raw, str) and len(color_raw) >= 6:
              color_hex = color_raw[:6].upper() + "FF"
            else:
              color_hex = "FFFFFFFF"
            material = self._normalize_material(slot.get("type", "PLA"))
            # User override from config.ini [filament_profiles].slot_N_id
            # takes precedence over the default mapping by material type.
            # The vendor is sent along (tray_sub_brands + filament_vendor),
            # so a patched OrcaSlicer can match by brand + type +
            # color (analogous to SnapmakerPrinterAgent).
            # Two-layer resolution for the filament hint sent to OrcaSlicer:
            #   1. User-Wahl (config.ini [filament_profiles]) — exakte Kontrolle
            #   2. Generic fallback (_TRAY_INFO_IDX) per material type - no
            #      vendor hint; OrcaSlicer then picks its own generic preset
            # Stale-profile guard: only apply the override while its material
            # family still matches the loaded filament (PETG profile + PLA
            # loaded -> dropped).
            user_profile = self._effective_slot_profile(slot_index, material)
            if not user_profile.get("name"):
                # Third layer: auto-resolve a combined ACE-RFID "VENDOR TYPE
                # SERIAL" string (e.g. "GEEETECH PLA Bas", from third-party
                # RFID tools) against the user's already-imported profile
                # library, instead of falling through to the neutral Generic
                # fallback (Issue #101). Not persisted to config.ini - this
                # re-derives on every _build_lane_data() call, so a
                # differently-tagged spool loaded later isn't stuck with a
                # stale match.
                vendor_guess, family_guess = self._parse_combined_rfid_type(slot.get("type", ""))
                if vendor_guess:
                    auto_profile = self._match_profile_by_vendor_family(vendor_guess, family_guess)
                    if auto_profile.get("name"):
                        user_profile = auto_profile
                        material = family_guess
            if user_profile.get("name"):
                vendor    = user_profile.get("vendor", "")
                fila_name = user_profile.get("name", "")
                tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99")
            else:
                # Default: Library-Generic-Profil (siehe _default_filament_name) —
                # is compatible with all printers and guaranteed to be visible.
                # The user deliberately picks a concrete brand per slot if they
                # want one; the default stays neutral.
                fila_name = self._default_filament_name(material)
                vendor    = "Generic" if fila_name.startswith("Generic ") else ""
                tray_info_idx = self._lookup_filament_id(vendor, fila_name) or self._TRAY_INFO_IDX.get(material, "OGFL99")
            tray_array.append({
              "id": str(slot_id),
              "tag_uid": "0000000000000000",
              "tray_info_idx": tray_info_idx,
              "tray_type": material,
              "tray_color": color_hex,
              "tray_sub_brands": vendor,
              # OrcaSlicer-Empfangs-Patch PR #13719 erwartet `name` +
              # `vendor_name` pro Lane (Stufen-Matching: Vendor+Name → Name →
              # filament_id_by_type). We send both spellings so that
              # older patch variants + future upstream PRs are both
              # covered.
              "name":         fila_name,
              "vendor_name":  vendor,
              # Aliases for older patch variants (variant 2,
              # MoonrakerPrinterAgent.cpp): filament_id direkt (exakt),
              # otherwise resolve the preset name via find_preset().
              "filament_id":     tray_info_idx,
              "filament_vendor": vendor,
              "filament_name":   fila_name,
              "preset":          fila_name,
            })
          else:
            tray_array.append({
              "id": str(slot_id),
              "tag_uid": "0000000000000000",
              "tray_info_idx": "",
              "tray_type": "",
              "tray_color": "00000000",
              "tray_slot_placeholder": "1",
            })

        ams_array.append({"id": str(ams_id), "info": "0002", "tray": tray_array})

      return {
        "ams": ams_array,
        "ams_exist_bits": format(ams_exist_bits, "X"),
        "tray_exist_bits": format(tray_exist_bits, "X"),
      }

    @staticmethod
    def _layer_height_from_filename(fname: str) -> float:
        """OrcaSlicer-Default-Filename-Pattern: `<plate>_<material>_<layer>_<dur>.gcode`
        z.B. `adapter_e27_plate(01)_PLA_0.2_41m1s.gcode` → 0.2.

        Fallback when the GCode header was not parsed (e.g. file started directly
        on the slicer, or uploaded before v0.9.18). Returns 0.0 when the
        pattern does not match."""
        import re
        if not fname:
            return 0.0
        m = re.search(r"_(0\.\d+)_(\d+[hms])", fname)
        if not m:
            return 0.0
        try:
            return float(m.group(1))
        except Exception:
            return 0.0

    def _estimate_current_z(self) -> float:
        """Estimates the current Z height from curr_layer + layer heights.

        The printer provides no real Z position via MQTT, but Obico
        (moonraker-obico/printer.py:267) reads currentZ from `gcode_position[2]`.
        We back-compute it with the layer_height from the GCode header:
          z = first_layer_height + (curr_layer - 1) * layer_height

        Values are set in the upload path and only reset on print cancel/end
        (slot/color changes do not affect them). If the values are
        missing (e.g. because the print was started directly on the slicer
        without an upload through the bridge), they are reloaded once from
        the GCode store. Returns 0.0 when nothing is known - Obico then shows
        keinen Z-Wert."""
        s = self._state
        layer_h = float(s.get("layer_height") or 0.0)
        first_h = float(s.get("first_layer_height") or 0.0)
        fname = s.get("filename", "")
        if not layer_h and fname:
            try:
                gf = self._store.get_file_by_name(fname)
                if gf:
                    layer_h = float(gf.get("layer_height") or 0.0)
                    first_h = float(gf.get("first_layer_height") or layer_h)
            except Exception:
                pass
        if not layer_h and fname:
            # Last fallback: the OrcaSlicer default filename contains the layer height
            layer_h = self._layer_height_from_filename(fname)
            if layer_h and not first_h:
                first_h = layer_h
        if layer_h:
            # cache in state so not every build queries the store again
            s["layer_height"] = layer_h
            s["first_layer_height"] = first_h
        if not layer_h:
            return 0.0
        curr = int(s.get("curr_layer") or 0)
        if curr <= 0:
            return 0.0
        # Layer 1 = first_layer_height, Layer 2 = first + layer_h, …
        return round(first_h + max(0, curr - 1) * layer_h, 3)

    # -------------------------------------------------------------------------
    # WebSocket push
    # -------------------------------------------------------------------------

    # Static objects that never change at runtime. They are delivered once
    # via objects.query/subscribe, but NOT included in every
    # notify_status_update - otherwise Mobileraker's
    # ConfigFile.parse (expensive + strict) runs on every status tick and the app
    # hangs/crashes on refresh (Issue #48).
    _STATIC_STATUS_OBJECTS = ("configfile", "webhooks", "heaters", "history")

    def _push_status_update(self):
        if not self.ws_clients:
            return
        objs = self._build_printer_objects()
        live = {k: v for k, v in objs.items() if k not in self._STATIC_STATUS_OBJECTS}
        msg = {
            "jsonrpc": "2.0",
            "method":  "notify_status_update",
            "params": [live, time.time()],
        }
        text = json.dumps(msg)
        dead = set()
        for ws in self.ws_clients:
            try:
                asyncio.run_coroutine_threadsafe(ws.send_str(text), ws._loop)
            except Exception:
                dead.add(ws)
        self.ws_clients -= dead

    def _build_mmu_object(self) -> dict:
      # POSITIONSTREU: ein Gate je physischem Slot, in Reihenfolge. Leere Slots
      # get gate_status=0 (instead of being omitted) - otherwise the
      # Farben in OrcaSlicer auf falsche Gates (Slot 1=gelb, 2=leer, 3=rot →
      # red must not land on gate 1). gate_status 0=empty, 1=available.
      slots = sorted(
        ((int(s.get("global_index", i)), s) for i, s in enumerate(self._ams_slots)),
        key=lambda item: item[0],
      )
      if not slots:
        return {}

      _TEMP = {"PLA": 210, "PETG": 230, "ABS": 240, "ASA": 250,
           "TPU": 220, "PA": 260, "PC": 270, "HIPS": 220}
      num_gates = len(slots)
      gate_status, gate_material, gate_color, gate_temperature, gate_color_rgb = [], [], [], [], []
      gate_filament_name = []
      gate_spool_id = []
      for _global_index, slot in slots:
        occupied = slot.get("status") == 5
        gate_status.append(1 if occupied else 0)
        material = self._normalize_material(slot.get("type") or "PLA") if occupied else ""
        gate_material.append(material)
        c = slot.get("color", [0, 0, 0]) if occupied else [0, 0, 0]
        # Happy Hare expects gate_color as RRGGBB WITHOUT '#' (Klipper limitation).
        # Leerer Gate: leerer String + RGB [0,0,0].
        gate_color.append("{:02X}{:02X}{:02X}".format(*c[:3]) if occupied else "")
        gate_color_rgb.append([round(c[0]/255, 3), round(c[1]/255, 3), round(c[2]/255, 3)] if occupied else [0.0, 0.0, 0.0])
        gate_temperature.append(_TEMP.get(material, 210) if occupied else 0)
        # gate_filament_name from user override or material default for the
        # HH-Pfad in OrcaSlicer (fetch_hh_filament_info). Wenn Orca den
        # HH path (MMU detection), PR #13719 evaluates this field as a
        # preset name -> 'Anycubic PLA' matches the printer-specific
        # preset; an empty string previously led to Generic PLA.
        if occupied:
          # Stale-profile guard (see _effective_slot_profile): only apply the
          # override while its material family still matches the loaded filament.
          user_profile = self._effective_slot_profile(_global_index, material)
          fila_name = user_profile.get("name") or self._default_filament_name(material)
          gate_filament_name.append(fila_name)
        else:
          gate_filament_name.append("")
        # Spoolman spool ID per gate from the (printer-specific) slot map so
        # Happy Hare/OrcaSlicer can show the bound spool (-1 = none).
        gate_spool_id.append(self._spoolman_slot_spools.get(_global_index, -1) if occupied else -1)

      loaded_index_map = {global_index: idx for idx, (global_index, _) in enumerate(slots)}
      active_gate = loaded_index_map.get(int(self._ams_loaded_slot), -1)
      return {
        "num_gates":          num_gates,
        "enabled":            True,
        "gate_status":        gate_status,
        "gate_material":      gate_material,
        "gate_color":         gate_color,
        "gate_temperature":   gate_temperature,
        "gate_color_rgb":     gate_color_rgb,
        "gate_filament_name": gate_filament_name,
        "gate_spool_id":      gate_spool_id,
        "ttg_map":            list(range(num_gates)),
        "tool":               active_gate,
        "gate":               active_gate,
      }

    def _default_filament_name(self, material: str) -> str:
      """Default name for `gate_filament_name`/`name` in lane_data when no
      user override is set. Deliberate design decision: **always
      Generic <type>** as the default - the library profile is `compatible_printers:[]`
      (= compatible with every printer) and therefore guaranteed to be visible.

      OrcaSlicer then matches the neutral generic preset and the user
      can set a concrete brand per slot if they want to."""
      if not material:
        return ""
      mat = self._normalize_material(material)
      profs = self._load_orca_filaments()
      # Varianten-Mapping: Drucker meldet z.B. "PLA SILK", OrcaSlicer speichert
      # all variants under type=PLA with the variant name in the name field.
      _VARIANT_NAME = {
          "PLA SILK":   "Generic PLA Silk",
          "PLA MATTE":  "Generic PLA Matte",
          "PLA+":       "Generic PLA",
          "PLA-CF":     "Generic PLA-CF",
          "PETG-CF":    "Generic PETG-CF",
      }
      if mat in _VARIANT_NAME:
          target = _VARIANT_NAME[mat]
          for p in profs:
              if p.get("vendor") == "Generic" and p.get("name") == target:
                  return p["name"]
      def _match_type(p: dict) -> bool:
        pt = (p.get("type") or "").upper()
        return pt == mat or pt.startswith(mat + "-") or pt.startswith(mat + " ")
      # Library-Generic-Profil (immer is_visible+is_compatible)
      for p in profs:
        if p.get("vendor") == "Generic" and p.get("name", "").startswith("Generic ") and _match_type(p):
          return p.get("name", "")
      # If the library generic for this exotic material type is missing,
      # we return nothing - OrcaSlicer falls back to filament_id_by_type.
      return ""

    def _build_printer_objects(self) -> dict:
        s = self._state
        return {
            "extruder": {
                "temperature": s["nozzle_temp"],
                "target":      s["nozzle_target"],
                "power":       0.0,
            },
            "heater_bed": {
                "temperature": s["bed_temp"],
                "target":      s["bed_target"],
                "power":       0.0,
            },
            "print_stats": {
                "state":          s["print_state"],
                "filename":       s["filename"],
                "print_duration": s["print_duration"],
                "total_duration": s["print_duration"],
                "remain_time":    s["remain_time"],
                "info": {
                    "current_layer": s["curr_layer"],
                    "total_layer":   s["total_layers"],
                },
            },
            "display_status": {
                "progress": s["progress"],
                "message":  "",
            },
            "virtual_sdcard": {
                "progress":  s["progress"],
                "is_active": s["print_state"] == "printing",
                "file_path": s["filename"],
                # file_position approximiert: fraction × est_total_size.
                # The printer does not provide an exact value; Obico only uses it for display.
                "file_position": int(s["progress"] * 1_000_000) if s["progress"] else 0,
            },
            "toolhead": {
                "position":         [0, 0, 0, 0],
                "homed_axes":       "xyz",
                "print_time":       s["print_duration"],
                "estimated_print_time": s["print_duration"],
            },
            "mmu": self._build_mmu_object(),
            # -- Moonraker compatibility for moonraker-obico --
            "heaters": {
                "available_heaters": ["extruder", "heater_bed"],
                "available_sensors": [],
                "available_monitors": [],
            },
            "webhooks": {
                "state":         "ready",
                "state_message": "Printer is ready",
            },
            # speed_factor: 1=silent(0.5) / 2=standard(1.0) / 3=high(1.3) / 4=ultra(1.5)
            # Estimate the current Z height for Obico from curr_layer + layer heights
            # (the printer provides no real Z position via MQTT). gcode_position[2]
            # is the value moonraker-obico reads as currentZ in printer.py.
            "gcode_move": {
                "speed_factor":   {1: 0.5, 2: 1.0, 3: 1.3, 4: 1.5}.get(int(s.get("print_speed_mode") or 2), 1.0),
                "extrude_factor": 1.0,
                "speed":          0,
                "gcode_position": [0, 0, self._estimate_current_z(), 0],
                "absolute_coordinates": True,
                "absolute_extrude":     True,
                "homing_origin":  [0, 0, 0, 0],
                "position":       [0, 0, self._estimate_current_z(), 0],
            },
            # motion_report: Mobileraker reads the live velocity here
            # (live_velocity). The Kobra X MQTT provides NO real mm/s, only
            # a print_speed_mode (1-4). live_velocity therefore stays 0 - but the
            # object must exist, otherwise Mobileraker displays nothing
            # (motion_report used to be null). live_position mirrors the
            # estimated Z height (like gcode_move).
            "motion_report": {
                "live_position":          [0, 0, self._estimate_current_z(), 0],
                "live_velocity":          0.0,
                "live_extruder_velocity": 0.0,
            },
            "fan": {
                "speed": (int(s.get("fan_speed") or 0)) / 100.0,
                "rpm":   None,
            },
            # history (object): Obico subscribes to it as an object; the actual
            # /server/history/list endpoint delivers the real list separately.
            "history": {
                "job_totals": {
                    "total_jobs":  0,
                    "total_time":  0,
                    "total_print_time": 0,
                    "total_filament_used": 0.0,
                    "longest_job": 0,
                    "longest_print": 0,
                },
                "current_job": None,
            },
            # Pseudo Klipper macros for moonraker-obico:
            # - _OBICO_LAYER_CHANGE reports the current layer number. Obico uses this
            #   for "first layer scan" triggers and layer-aligned time-lapse frames.
            #   We feed this from the MQTT stream (s["curr_layer"]).
            # - TIMELAPSE_TAKE_FRAME signals that the current pause comes from the
            #   time-lapse (otherwise Obico would interpret the pause as a user
            #   pause). We set is_paused=False because our pauses are
            #   never time-lapse pauses.
            "gcode_macro _OBICO_LAYER_CHANGE": {
                "current_layer":         int(s.get("curr_layer") or 0),
                "first_layer_scanning":  False,
                "first_layer_scan_enabled": False,
            },
            "gcode_macro TIMELAPSE_TAKE_FRAME": {
                "is_paused": False,
            },
            # configfile stub - Mobileraker and other clients crash without
            # this object (Missing field: configFile). Values from the
            # decrypted avata_main.conf (ACCFG1.0 - Kobra X firmware).
            # Mobileraker (Issue #48) parses BOTH branches config + settings via
            # denselben ConfigFile.parse → ConfigExtruder.fromJson; ein leeres
            # config:{} crashed the non-nullable Dart parser. Therefore
            # config identisch zu settings gespiegelt.
            "configfile": self._klipper_configfile_stub(),
        }

    def _klipper_configfile_stub(self) -> dict:
        """Minimal Klipper configfile stub for Mobileraker/OctoApp (Issue #48).

        Mobileraker parses BOTH branches `config` and `settings` through the same
        ConfigFile.parse → ConfigExtruder.fromJson. Ein leeres `config: {}`
        crashed the non-nullable Dart parser, therefore `config` is
        mirrored identically to `settings`. Values from the decrypted
        avata_main.conf (ACCFG1.0 — Kobra X Firmware).
        """
        settings = {
            "printer": {
                "kinematics":              "cartesian",
                "max_velocity":            450,
                "max_accel":               10000,
                "max_z_velocity":          12,
                "max_z_accel":             100,
                "square_corner_velocity":  20.0,
            },
            "extruder": {
                "nozzle_diameter":    0.4,
                "filament_diameter":  1.75,
                "sensor_type":        "ATC Semitec 104GT-2",
                "min_temp":           0,
                "max_temp":           320,
                "min_extrude_temp":   10,
                # Mobileraker ConfigExtruder erwartet diese Felder non-nullable
                # (max_extrude_only_distance, max_power) or present as a key
                # (max_extrude_only_velocity/accel may be null). Missing =
                # Crash in ConfigExtruder.fromJson (Issue #48).
                "max_extrude_only_distance": 100.0,
                "max_power":                 1.0,
                "max_extrude_only_velocity": None,
                "max_extrude_only_accel":    None,
            },
            "heater_bed": {
                # Mobileraker ConfigHeaterBed: heater_pin, sensor_type, control
                # are non-nullable. Values are placeholders (the bridge does not know
                # the real pins - Anycubic firmware, no Klipper printer.cfg).
                "heater_pin":  "PA0",
                "sensor_type": "ATC Semitec 104GT-2",
                "control":     "pid",
                "min_temp":    0,
                "max_temp":    120,
            },
            # Fill stepper_* with non-nullable required fields (step_pin, dir_pin,
            # rotation_distance), otherwise ConfigStepper.fromJson crashes.
            "stepper_x": {"step_pin": "PA1", "dir_pin": "PA2", "rotation_distance": 40,
                          "position_min": -18.5, "position_max": 280},
            "stepper_y": {"step_pin": "PA3", "dir_pin": "PA4", "rotation_distance": 40,
                          "position_min": -6.5,  "position_max": 272.5},
            "stepper_z": {"step_pin": "PA5", "dir_pin": "PA6", "rotation_distance": 8,
                          "position_min": -4,    "position_max": 262},
            "virtual_sdcard": {"path": "/data/gcodes"},
            "pause_resume":   {},
            "display_status": {},
        }
        # config + settings must contain the same fields - Mobileraker
        # parses both. deepcopy so no client is affected by a shared reference
        # versehentlich beide Zweige mutiert.
        return {
            "config":   copy.deepcopy(settings),
            "settings": settings,
            "warnings": [],
            "save_config_pending": False,
            "save_config_pending_items": {},
        }

    # -------------------------------------------------------------------------
    # /kx/ API handlers (GCode Store, History, Filament)
    # -------------------------------------------------------------------------

    _CORS = {
        "Access-Control-Allow-Origin":  "*",
        "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
        "Access-Control-Allow-Headers": "Content-Type",
    }

    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/<id>/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/<idx>/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 <KX_DATA_DIR>/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 "",
            })
        return self._json_cors({"result": out})

    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,
        )

        return self._json_cors({"result": "ok", "filename": filename})

    # -------------------------------------------------------------------------
    # HTTP handlers
    # -------------------------------------------------------------------------

    async def handle_server_info(self, request):
        return web.json_response({
            "result": {
                "klippy_connected": True,
                "klippy_state":     "ready",
                "components":       ["file_manager", "job_state", "virtual_sdcard"],
                "failed_components":[],
                "registered_directories": ["gcodes"],
                "warnings":         [],
                "websocket_count":  len(self.ws_clients),
                "moonraker_version": MOONRAKER_VERSION,
                "api_version":      [1, 3, 0],
                "api_version_string": "1.3.0",
            }
        })

    async def handle_printer_info(self, request):
        s = self._state
        return web.json_response({
            "result": {
                "state":           "ready",
                "state_message":   "Printer is ready",
                "hostname":        "kobrax-bridge",
                "klipper_path":    "/home/pi/klipper",
                "python_path":     "/home/pi/klippy-env/bin/python",
                "log_file":        "/tmp/klippy.log",
                "config_file":     "/home/pi/printer.cfg",
                "software_version": KLIPPER_VERSION,
                "cpu_info":        s["printer_name"],
            }
        })

    async def handle_machine_system_info(self, request):
        return web.json_response({
            "result": {
                "system_info": {
                    "cpu_info": {"cpu_count": 4, "bits": "64bit", "processor": "armv7l",
                                 "cpu_desc": "Anycubic Kobra X Bridge", "serial_number": "",
                                 "hardware_desc": "", "model": "Kobra X Bridge",
                                 "total_memory": 524288, "memory_units": "kB"},
                    "sd_info": {},
                    "distribution": {"name": "Linux", "id": "linux", "version": "1.0",
                                     "version_parts": {}, "like": "", "codename": ""},
                    "available_services": [],
                    "service_state": {},
                    "python": {"version": list(sys.version_info[:3]), "version_string": sys.version},
                    "network": {},
                    "canbus": {},
                }
            }
        })

    async def handle_objects_query(self, request):
        objects = self._build_printer_objects()
        requested = []
        query = request.rel_url.query
        if "objects" in query:
            requested = [x.strip() for x in str(query.get("objects", "")).split(",") if x.strip()]
        elif query:
            requested = [k for k in query.keys() if k]

        filtered = {k: objects[k] for k in requested if k in objects} if requested else objects
        return web.json_response({"result": {"status": filtered, "eventtime": time.time()}})

    async def handle_objects_list(self, request):
        return web.json_response({
            "result": {
                "objects": list(self._build_printer_objects().keys())
            }
        })

    async def handle_objects_subscribe(self, request):
        return web.json_response({
            "result": {
                "status": self._build_printer_objects(),
                "eventtime": time.time(),
            }
        })

    async def handle_files_list(self, request):
        filename = self._state.get("filename", "")
        files = []
        if filename:
            files.append({
                "path":     filename,
                "modified": time.time(),
                "size":     0,
                "permissions": "rw",
            })
        return web.json_response({"result": files})

    def _build_file_metadata(self, filename: str) -> dict:
        """Builds the Moonraker file metadata for a file. Shared source
        for HTTP /server/files/metadata AND the WS RPC server.files.metadata
        (previously the WS path had its own broken logic with a non-existent
        existierenden Store-Methode → leere Antwort → Mobileraker fragte in
        endless loop, app hung on refresh, Issue #48).

        Liefert Mobileraker-kompatible Pflichtfelder: `filename`, `size`,
        `modified` are non-nullable in GCodeFile; `print_start_time` and the
        Slicer-Felder optional."""
        s = self._state
        # Live _state values are only relevant for the currently/last tracked
        # job's own file - using them as a starting point for a DIFFERENT
        # filename leaked the tracked job's layer count/time into unrelated
        # metadata queries (Issue #102). For any other filename, rely solely
        # on that file's own GCodeStore row.
        is_tracked_file = bool(filename) and filename == s.get("filename")
        layer_h = float(s.get("layer_height") or 0.0) if is_tracked_file else 0.0
        first_h = float(s.get("first_layer_height") or 0.0) if is_tracked_file else 0.0
        total_layers = int(s.get("total_layers") or 0) if is_tracked_file else 0
        est_time = int(s.get("slicer_time") or 0) if is_tracked_file else 0
        size_bytes = 0
        try:
            gf = self._store.get_file_by_name(filename) or {}
            if not layer_h:
                layer_h = float(gf.get("layer_height") or 0.0)
                first_h = float(gf.get("first_layer_height") or layer_h)
            if not total_layers:
                total_layers = int(gf.get("layer_count") or 0)
            if not est_time:
                est_time = int(gf.get("est_print_time_sec") or 0)
            size_bytes = int(gf.get("size_bytes") or 0)
        except Exception:
            pass
        if not layer_h:
            layer_h = self._layer_height_from_filename(filename)
            if layer_h and not first_h:
                first_h = layer_h
        object_height = round(first_h + max(0, total_layers - 1) * layer_h, 3) if (layer_h and total_layers) else 0.0
        return {
            "filename":           filename,
            # GCodeFile (Mobileraker) requires size as a non-nullable int.
            "size":               size_bytes or 1,
            "modified":           time.time(),
            "estimated_time":     est_time or None,
            "layer_height":       layer_h or None,
            "first_layer_height": first_h or None,
            "layer_count":        total_layers or None,
            "object_height":      object_height or None,
            "thumbnails":         [],
        }

    async def handle_files_metadata(self, request):
        """Moonraker /server/files/metadata — moonraker-obico + Mobileraker
        holen Datei-Metadaten (Slicer-Zeit, Layer, object_height).
        Logic in _build_file_metadata (shared with WS RPC)."""
        filename = request.rel_url.query.get("filename", "") or self._state.get("filename", "")
        if not filename:
            return web.json_response({"result": {}})
        return web.json_response({"result": self._build_file_metadata(filename)})

    # -- Moonraker stubs for moonraker-obico ----------------------------------
    async def handle_access_api_key(self, request):
        """Moonraker /access/api_key - we have no auth, return a dummy.
        moonraker-obico logs a WARNING otherwise."""
        return web.json_response({"result": "kx-bridge-no-auth-required"})

    async def handle_machine_update_status(self, request):
        """Moonraker /machine/update/status - Obico uses this to show installed plugins."""
        return web.json_response({
            "result": {
                "busy":         False,
                "github_rate_limit":     60,
                "github_requests_remaining": 60,
                "github_limit_reset_time":   time.time() + 3600,
                "version_info": {},
            }
        })

    async def handle_history_list(self, request):
        """Moonraker /server/history/list - job history from the GCodeStore.

        moonraker-obico only uses the last element (limit=1, order=desc)."""
        try:
            limit = int(request.rel_url.query.get("limit", "50"))
        except ValueError:
            limit = 50
        try:
            jobs = self._store.list_jobs(limit=limit) or []
        except Exception:
            jobs = []
        # Mapping to the Moonraker schema. Moonraker returns start_time as a Unix
        # timestamp (float), not an ISO string - moonraker-obico parses it with
        # int(start_time) and crashes otherwise.
        def _to_unix_ts(iso: str | None) -> float:
            if not iso:
                return 0.0
            try:
                from datetime import datetime
                # Format from GCodeStore: "2026-05-27T21:22:25Z"
                dt = datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ")
                return dt.replace(tzinfo=__import__("datetime").timezone.utc).timestamp()
            except Exception:
                return 0.0
        result_jobs = []
        for j in jobs:
            start_ts = _to_unix_ts(j.get("started_at"))
            dur = j.get("duration_sec") or 0
            result_jobs.append({
                "job_id":       j.get("id"),
                "exists":       True,
                "end_time":     (start_ts + dur) if start_ts and dur else None,
                "filament_used": 0.0,
                "filename":     j.get("filename", ""),
                "metadata":     {},
                "print_duration": dur,
                "status":       j.get("status") or "completed",
                "start_time":   start_ts,
                "total_duration": dur,
            })
        return web.json_response({"result": {"count": len(result_jobs), "jobs": result_jobs}})

    async def handle_webcams_list(self, request):
        """Moonraker /server/webcams/list - Obico fetches the webcam URLs here.

        When the client comes from another host (e.g. moonraker-obico on a
        separate server), it needs absolute URLs to reach the stream.
        A Host header with localhost/127.0.0.1 is replaced by the real LAN IP."""
        host_hdr = request.headers.get("Host", "") if request else ""
        host_name = (host_hdr or "").split(":")[0]
        port_part = f":{host_hdr.split(':')[1]}" if ":" in (host_hdr or "") else f":{self._args.port}"
        local_ip  = getattr(self, "_local_ip", None) or host_name
        if host_name in ("localhost", "127.0.0.1", ""):
            host_name = local_ip
        base         = f"http://{host_name}{port_part}"
        stream_url   = f"{base}/api/camera/stream"
        snapshot_url = f"{base}/api/camera/snapshot"
        return web.json_response({
            "result": {
                "webcams": [
                    {
                        "name":         "KX-Bridge",
                        "location":     "printer",
                        "service":      "mjpegstreamer",
                        "enabled":      True,
                        "icon":         "mdiWebcam",
                        "target_fps":   5,
                        "target_fps_idle": 2,
                        "stream_url":   stream_url,
                        "snapshot_url": snapshot_url,
                        "flip_horizontal": False,
                        "flip_vertical":   False,
                        "rotation":     0,
                        "aspect_ratio": "16:9",
                        "extra_data":   {},
                    }
                ]
            }
        })

    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<n>-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__", 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="<pre>KX-Bridge: index.html not found.\nExpected:\n"
                + html.escape(p, quote=True)
                + "</pre>",
                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 <link>/<script src>
        # (only the bare HTML) -> without inlining neither
        # a single button works there (Issue #29). It is equally correct in a normal browser.
        base = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme)

        # Inline vendored lib CSS/JS too — the OrcaSlicer webview loads no
        # external <link>/<script src>, so GridStack (and its stylesheet) must
        # be embedded like style.css/app.js. Order matters: GridStack's <script>
        # sits in <head>, before app.js, so it is defined when app.js inits.
        def _inline_css(rel_path: str, link_tag: str):
            nonlocal page
            try:
                data = pathlib.Path(os.path.join(base, rel_path)).read_text(encoding="utf-8")
                page = page.replace(link_tag, "<style>\n" + data + "\n</style>")
            except OSError:
                pass

        def _inline_js(rel_path: str, script_tag: str, version_sub: bool = False):
            nonlocal page
            try:
                data = pathlib.Path(os.path.join(base, rel_path)).read_text(encoding="utf-8")
                if version_sub:
                    data = data.replace("'__VERSION__'", f"'{self._read_version()}'")
                page = page.replace(script_tag, "<script>\n" + data + "\n</script>")
            except OSError:
                pass

        _inline_css("lib/gridstack.min.css", '<link rel="stylesheet" href="/kx/ui/lib/gridstack.min.css">')
        _inline_js("lib/gridstack-all.min.js", '<script src="/kx/ui/lib/gridstack-all.min.js"></script>')
        _inline_css("style.css", '<link rel="stylesheet" href="/kx/ui/style.css">')
        _inline_js("app.js", '<script src="/kx/ui/app.js"></script>', 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),
        })

    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,
            "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),
            "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),
            "notifications":           self._notification_urls,
            "notify_every_minutes":    self._notify_every_minutes,
            "notify_every_layers":     self._notify_every_layers,
            "notifications_paused":    self._notifications_paused,
        })

    async def handle_api_settings_post(self, request):
        import configparser
        data = await request.json()
        config_path = self._find_config_path()
        config_path.parent.mkdir(parents=True, exist_ok=True)

        # Read the existing config.ini (comments are lost, but values are kept)
        cfg = configparser.ConfigParser()
        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("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",      "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

        # Benachrichtigungen speichern
        if cfg.has_section("notifications"):
            cfg.remove_section("notifications")
        incoming_notifs = data.get("notifications")
        if isinstance(incoming_notifs, list):
            valid_events = {"started", "finished", "failed", "cancelled", "paused", "progress"}
            entries = []
            for entry in incoming_notifs:
                if not isinstance(entry, dict):
                    continue
                url = str(entry.get("url", "")).strip()
                if not url:
                    continue
                events = [e for e in entry.get("events", []) if e in valid_events]
                include_image = bool(entry.get("include_image", False))
                enabled = bool(entry.get("enabled", True))
                entries.append({"url": url, "events": events, "include_image": include_image, "enabled": enabled})
            cfg.add_section("notifications")
            for i, entry in enumerate(entries, 1):
                cfg.set("notifications", f"url_{i}", entry["url"])
                cfg.set("notifications", f"events_{i}", ",".join(entry["events"]))
                cfg.set("notifications", f"image_{i}", "true" if entry["include_image"] else "false")
                cfg.set("notifications", f"enabled_{i}", "true" if entry["enabled"] else "false")
            self._notification_urls = entries
        elif not cfg.has_section("notifications"):
            cfg.add_section("notifications")
        notify_every_minutes = max(0, int(data.get("notify_every_minutes") or 0))
        notify_every_layers  = max(0, int(data.get("notify_every_layers")  or 0))
        cfg.set("notifications", "notify_every_minutes", str(notify_every_minutes))
        cfg.set("notifications", "notify_every_layers",  str(notify_every_layers))
        self._notify_every_minutes = notify_every_minutes
        self._notify_every_layers  = notify_every_layers
        if "notifications_paused" in data:
            self._notifications_paused = bool(data.get("notifications_paused"))
        cfg.set("notifications", "paused", "true" if self._notifications_paused else "false")

        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_api_notifications_pause(self, request):
        """Toggle or set the global notifications-paused flag without restarting."""
        try:
            data = await request.json()
        except Exception:
            data = {}
        if isinstance(data, dict) and "paused" in data:
            self._notifications_paused = bool(data.get("paused"))
        else:
            self._notifications_paused = not self._notifications_paused
        try:
            import configparser
            config_path = self._find_config_path()
            config_path.parent.mkdir(parents=True, exist_ok=True)
            cfg = configparser.ConfigParser()
            if config_path.is_file():
                cfg.read(config_path, encoding="utf-8")
            if not cfg.has_section("notifications"):
                cfg.add_section("notifications")
            cfg.set("notifications", "paused", "true" if self._notifications_paused else "false")
            with open(config_path, "w", encoding="utf-8") as f:
                f.write("# KX-Bridge Konfigurationsdatei\n\n")
                cfg.write(f)
        except Exception as e:
            log.warning(f"Notif-Pause speichern fehlgeschlagen: {e}")
        log.info(f"Benachrichtigungen {'pausiert' if self._notifications_paused else 'aktiv'}")
        return self._json_cors({"status": "ok", "paused": self._notifications_paused})

    async def handle_api_notifications_test(self, request):
        try:
            data = await request.json()
        except Exception:
            return self._json_cors({"status": "error", "message": "invalid json"}, status=400)
        url = str(data.get("url", "")).strip()
        if not url:
            return self._json_cors({"status": "error", "message": "url required"}, status=400)
        printer_name = self._state.get("printer_name", "KX-Bridge")
        try:
            import apprise
            ap = apprise.Apprise()
            ap.add(url)
            ok = await asyncio.get_event_loop().run_in_executor(
                None,
                lambda: ap.notify(title="KX-Bridge Test", body=f"Test notification from {printer_name}"),
            )
            if ok is False:
                return self._json_cors({"status": "error", "message": "Notification failed (check URL)"})
            return self._json_cors({"status": "ok"})
        except ImportError:
            return self._json_cors({"status": "error", "message": "apprise not installed"}, status=500)
        except Exception as e:
            return self._json_cors({"status": "error", "message": str(e)}, status=500)

    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()
        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()
        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 ──────────────────────────────────────────────────────────────

    STABLE_RELEASE_API  = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=1"
    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/<name>/
    # (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):
        data = await request.json()
        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<temp>                         → Nozzle-Temperatur
        - M140 S<temp>                         → 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
    # -------------------------------------------------------------------------

    async def handle_websocket(self, request):
        ws = web.WebSocketResponse(heartbeat=30)
        await ws.prepare(request)
        ws._loop = asyncio.get_event_loop()
        self.ws_clients.add(ws)
        log.info(f"WS client connected ({len(self.ws_clients)} total)")

        # Send klippy_ready notification
        await ws.send_str(json.dumps({
            "jsonrpc": "2.0",
            "method":  "notify_klippy_ready",
            "params":  [],
        }))
        # Send initial status
        await ws.send_str(json.dumps({
            "jsonrpc": "2.0",
            "method":  "notify_status_update",
            "params":  [self._build_printer_objects(), time.time()],
        }))

        async for msg in ws:
            if msg.type == aiohttp.WSMsgType.TEXT:
                await self._handle_ws_rpc(ws, msg.data)
            elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE):
                break

        self.ws_clients.discard(ws)
        log.info(f"WS client disconnected ({len(self.ws_clients)} remaining)")
        return ws

    async def _handle_ws_rpc(self, ws: web.WebSocketResponse, raw: str):
        try:
            req = json.loads(raw)
        except Exception:
            return
        rpc_id = req.get("id")
        method  = req.get("method", "")
        log.info(f"WS RPC: {method}  params={str(req.get('params',''))[:120]}")
        params  = req.get("params") or {}
        if isinstance(params, list):
            params = params[0] if params else {}

        result = None
        error  = None

        try:
            if method in ("printer.info", "printer_info"):
                result = {
                    "state":           "ready",
                    "state_message":   "Printer is ready",
                    "hostname":        "kobrax-bridge",
                    "software_version": KLIPPER_VERSION,
                    "cpu_info":        self._state["printer_name"],
                    "klipper_path":    "/home/pi/klipper",
                    "python_path":     "/home/pi/klippy-env/bin/python",
                }
            elif method in ("server.info", "server_info"):
                result = {
                    "klippy_connected": True,
                    "klippy_state":     "ready",
                    "moonraker_version": MOONRAKER_VERSION,
                    "components":       [],
                    "failed_components": [],
                    "registered_directories": ["gcodes"],
                    "warnings":         [],
                }
            elif method in ("printer.objects.list",):
                result = {"objects": list(self._build_printer_objects().keys())}
            elif method in ("printer.objects.query", "printer.objects.get"):
                objects = params.get("objects", {})
                all_objs = self._build_printer_objects()
                if objects:
                    filtered = {k: all_objs.get(k, {}) for k in objects}
                else:
                    filtered = all_objs
                result = {"status": filtered, "eventtime": time.time()}
            elif method == "printer.objects.subscribe":
                objects = params.get("objects", {})
                all_objs = self._build_printer_objects()
                if objects:
                    filtered = {k: all_objs.get(k, {}) for k in objects}
                else:
                    filtered = all_objs
                result = {"status": filtered, "eventtime": time.time()}
            elif method == "printer.print.start":
                filename = params.get("filename", self._last_uploaded_file)
                loop = asyncio.get_event_loop()
                resp = await loop.run_in_executor(
                    None, lambda: self.client.publish("print", "start",
                        {"filename": filename, "use_ams": False}, timeout=15.0)
                )
                result = "ok" if resp else "timeout"
            elif method == "printer.print.pause":
                loop = asyncio.get_event_loop()
                await loop.run_in_executor(None, self.client.pause_print)
                result = "ok"
            elif method == "printer.print.resume":
                loop = asyncio.get_event_loop()
                await loop.run_in_executor(None, self.client.resume_print)
                result = "ok"
            elif method == "printer.print.cancel":
                loop = asyncio.get_event_loop()
                await loop.run_in_executor(None, self.client.stop_print)
                result = "ok"
            elif method == "machine.system_info":
                result = {"system_info": {"cpu_info": {"cpu_desc": "Kobra X Bridge"}}}
            elif method == "server.files.list":
                result = []
            # ── moonraker-obico passthru-Targets ──
            elif method == "printer.gcode.script":
                script = (params.get("script") or "").strip().upper() if isinstance(params, dict) else ""
                result = await self._exec_gcode_script(script)
            elif method in ("server.connection.identify",):
                # Obico identifies itself on connect. Connection ID doesn't matter.
                result = {"connection_id": 1}
            elif method == "connection.register_remote_method":
                # Obico registriert obico_remote_event-Callback. Wir akzeptieren leer.
                result = "ok"
            elif method == "server.webcams.list":
                # WS variant: absolute URL with the real LAN IP instead of localhost
                _lip = getattr(self, "_local_ip", None) or "127.0.0.1"
                _base = f"http://{_lip}:{self._args.port}"
                result = {"webcams": [{
                    "name": "KX-Bridge", "location": "printer", "service": "mjpegstreamer",
                    "enabled": True,
                    "stream_url":   f"{_base}/api/camera/stream",
                    "snapshot_url": f"{_base}/api/camera/snapshot",
                    "flip_horizontal": False, "flip_vertical": False, "rotation": 0,
                    "target_fps": 5, "aspect_ratio": "16:9",
                }]}
            elif method == "server.history.list":
                # Reuse the HTTP handler logic (Moonraker schema with Unix TS).
                try:
                    jobs = self._store.list_jobs(limit=50) or []
                except Exception:
                    jobs = []
                from datetime import datetime, timezone as _tz
                def _ts(iso):
                    try:
                        return datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=_tz.utc).timestamp()
                    except Exception:
                        return 0.0
                result = {"count": len(jobs), "jobs": [
                    {"job_id": j.get("id"), "exists": True, "filename": j.get("filename",""),
                     "status": j.get("status") or "completed",
                     "print_duration": j.get("duration_sec") or 0,
                     "total_duration": j.get("duration_sec") or 0,
                     "start_time": _ts(j.get("started_at")),
                     "end_time":   (_ts(j.get("started_at")) + (j.get("duration_sec") or 0)) if j.get("started_at") and j.get("duration_sec") else None,
                     "filament_used": 0.0, "metadata": {}}
                    for j in jobs
                ]}
            elif method == "machine.update.status":
                result = {"busy": False, "version_info": {}}
            elif method == "server.files.metadata":
                # Obico + Mobileraker request metadata for a file. Same
                # logic as the HTTP endpoint (previously a separate broken path with
                # a non-existent store method -> empty response ->
                # Mobileraker-Endlosschleife, Issue #48).
                fname = (params or {}).get("filename") if isinstance(params, dict) else None
                fname = fname or self._state.get("filename", "")
                result = self._build_file_metadata(fname) if fname else {}
            else:
                log.debug(f"Unbekannte RPC-Methode: {method}")
                result = {}
        except Exception as e:
            log.error(f"RPC error for {method}: {e}")
            error = {"code": -32603, "message": str(e)}

        if rpc_id is not None:
            response = {"jsonrpc": "2.0", "id": rpc_id}
            if error:
                response["error"] = error
            else:
                response["result"] = result
            await ws.send_str(json.dumps(response))

    # -------------------------------------------------------------------------
    # Poll loop (sync, runs in executor)
    # -------------------------------------------------------------------------

    def _printer_reachable(self) -> bool:
        """TCP probe on the MQTT port - no ICMP needed, no root required."""
        import socket as _socket
        try:
            with _socket.create_connection(
                (self._args.printer_ip, self._args.mqtt_port), timeout=2.0
            ):
                return True
        except OSError:
            return False

    def _poll_loop(self, stop_event: threading.Event):
        _offline = self._state["kobra_state"] == "offline"
        _probe_interval = 10.0   # Sekunden zwischen TCP-Probes im Offline-Modus

        while not stop_event.is_set():
            # ── Offline-Modus: warten bis Drucker wieder erreichbar ──────────
            if _offline:
                if self._printer_reachable():
                    log.info("Printer reachable - establishing MQTT connection...")
                    try:
                        self.client.connect()
                        _offline = False
                        self._state["print_state"] = "standby"
                        self._state["kobra_state"] = "free"
                        self._state["connection_error"] = ""
                        log.info("MQTT connection re-established")
                    except Exception as e:
                        err = _mqtt_error_msg(e)
                        self._state["connection_error"] = err
                        log.warning(f"Connection attempt failed: {err}")
                        stop_event.wait(_probe_interval)
                        continue
                else:
                    stop_event.wait(_probe_interval)
                    continue

            # ── Online-Modus: normaler Poll ──────────────────────────────────
            try:
                info = self.client.query_info()
                if info:
                    self._on_info(info)
                # While printing: query print/report directly
                if self._state["print_state"] in ("printing", "preheating",
                                                   "auto_leveling", "checking", "init"):
                    print_r = self.client.publish("print", "query", timeout=3.0)
                    if print_r:
                        self._on_print(print_r)
                    # Spoolman mid-print sync
                    if (self._spoolman and self._spoolman.sync_rate > 0
                            and self._spoolman_slot_spools
                            and self._state.get("print_state") == "printing"):
                        now = time.time()
                        if now - self._spoolman_last_sync >= self._spoolman.sync_rate:
                            self._spoolman_sync_midprint()
                            self._spoolman_last_sync = now
                    self._check_progress_notifications()
                box = self.client.query_multicolor_box()
                if box:
                    data = box.get("data") or {}
                    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
                        self._spoolman_attribute_tick(activity_map)
                    else:
                        # No multiColorBox data — still attribute (no transitions to skip)
                        self._spoolman_attribute_tick({})
                # Recheck Spoolman reachability periodically so the UI status
                # dot reflects the current state, not just the boot-time result.
                if self._spoolman and time.time() - self._spoolman_last_health_check >= 30.0:
                    self._spoolman_reachable = self._spoolman.health_check()
                    self._spoolman_last_health_check = time.time()
            except Exception as e:
                log.warning(f"Poll error: {e}")
                # Check whether the printer is really gone
                if not self._printer_reachable():
                    log.info("Printer unreachable - switching to offline mode")
                    self._state["print_state"] = "error"
                    self._state["kobra_state"] = "offline"
                    self._state["connection_error"] = f"Printer unreachable ({self._args.printer_ip})"
                    try:
                        self.client.disconnect()
                    except Exception:
                        pass
                    _offline = True
            stop_event.wait(getattr(self._args, "poll_interval", 3))


# ---------------------------------------------------------------------------
# App factory + main
# ---------------------------------------------------------------------------

def _mqtt_error_msg(exc: Exception) -> str:
    msg = str(exc)
    if "20020005" in msg:
        return "Wrong MQTT credentials (username, password or device ID incorrect)"
    return msg


@web.middleware
async def cors_middleware(request, handler):
    if request.method == "OPTIONS":
        return web.Response(status=204, headers={
            "Access-Control-Allow-Origin":  "*",
            "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
            "Access-Control-Allow-Headers": "Content-Type",
        })
    resp = await handler(request)
    resp.headers["Access-Control-Allow-Origin"] = "*"
    return resp


def build_app(bridge: KobraXBridge) -> web.Application:
    app = web.Application(
        client_max_size=256 * 1024 * 1024,
        middlewares=[cors_middleware],
    )
    r = app.router

    # Moonraker API
    r.add_get("/server/info",                bridge.handle_server_info)
    r.add_get("/printer/info",               bridge.handle_printer_info)
    r.add_get("/machine/system_info",        bridge.handle_machine_system_info)
    r.add_get("/printer/objects/list",       bridge.handle_objects_list)
    r.add_get("/printer/objects/query",      bridge.handle_objects_query)
    r.add_get("/printer/objects/subscribe",  bridge.handle_objects_subscribe)
    r.add_post("/printer/objects/subscribe", bridge.handle_objects_subscribe)
    r.add_get("/server/files/list",          bridge.handle_files_list)
    r.add_get("/server/files/metadata",      bridge.handle_files_metadata)
    r.add_post("/server/files/upload",       bridge.handle_file_upload)
    r.add_post("/printer/print/start",       bridge.handle_print_start)
    r.add_post("/printer/print/pause",       bridge.handle_print_pause)
    r.add_post("/printer/print/resume",      bridge.handle_print_resume)
    r.add_post("/printer/print/cancel",      bridge.handle_print_cancel)

    # Moonraker stubs for moonraker-obico
    r.add_get("/access/api_key",             bridge.handle_access_api_key)
    r.add_get("/machine/update/status",      bridge.handle_machine_update_status)
    r.add_get("/server/history/list",        bridge.handle_history_list)
    r.add_get("/server/webcams/list",        bridge.handle_webcams_list)
    r.add_post("/printer/gcode/script",      bridge.handle_printer_gcode_script)

    # OctoPrint compatibility (OrcaSlicer probes this + uploads here)
    r.add_get("/api/version",                bridge.handle_octoprint_version)
    r.add_post("/api/files/local",           bridge.handle_file_upload)
    r.add_post("/api/files/{path:.*}",       bridge.handle_file_upload)

    # Moonraker database (OrcaSlicer AMS-Sync)
    r.add_get("/server/database/item",       bridge.handle_moonraker_database)
    r.add_post("/server/database/item",      bridge.handle_moonraker_database_post)
    r.add_get("/server/database/list",       bridge.handle_database_list)

    # New API endpoints
    r.add_post("/api/light",               bridge.handle_api_light)
    r.add_post("/api/fan",                 bridge.handle_api_fan)
    r.add_post("/api/connect",             bridge.handle_api_connect)
    r.add_post("/api/disconnect",          bridge.handle_api_disconnect)
    r.add_post("/api/restart",             bridge.handle_api_restart)
    r.add_post("/api/speed",               bridge.handle_api_speed)
    r.add_post("/api/ams/feed",            bridge.handle_api_ams_feed)
    r.add_post("/api/ams/set_slot",        bridge.handle_api_ams_set_slot)
    r.add_post("/api/ace/auto_feed",        bridge.handle_api_ace_auto_feed)
    r.add_post("/api/ace/dry",             bridge.handle_api_ace_dry)
    r.add_post("/api/axis",                bridge.handle_api_axis)
    r.add_post("/api/temperature",         bridge.handle_api_temperature)
    r.add_get("/api/camera",               bridge.handle_api_camera)
    r.add_get("/api/camera/stream",        bridge.handle_camera_stream)
    r.add_get("/api/camera/h264",          bridge.handle_camera_h264)
    r.add_get("/api/camera/snapshot",      bridge.handle_api_camera_snapshot)
    r.add_post("/api/camera/start",        bridge.handle_api_camera_start)
    r.add_post("/api/camera/stop",         bridge.handle_api_camera_stop)
    r.add_post("/api/camera/reset",        bridge.handle_api_camera_reset)
    r.add_get("/api/state",                bridge.handle_api_state)
    r.add_get("/api/settings",             bridge.handle_api_settings_get)
    r.add_post("/api/settings",            bridge.handle_api_settings_post)
    r.add_post("/api/notifications/test",  bridge.handle_api_notifications_test)
    r.add_post("/api/notifications/pause", bridge.handle_api_notifications_pause)
    r.add_get("/api/update/check",         bridge.handle_api_update_check)
    r.add_post("/api/update/apply",        bridge.handle_api_update_apply)
    r.add_post("/api/file_ready/clear",    bridge.handle_api_file_ready_clear)
    r.add_get("/api/log/stream",           bridge.handle_api_log_stream)
    r.add_get("/api/log/download",         bridge.handle_api_log_download)
    r.add_get("/serve/{filename}",         bridge.handle_serve_file)
    # /kx/ GCode Store + History + Filament
    r.add_get("/kx/printers",              bridge.handle_kx_printers)
    r.add_post("/kx/printers/add",         bridge.handle_kx_printer_add)
    r.add_delete("/kx/printers/{pid}",     bridge.handle_kx_printer_remove)
    r.add_post("/kx/print",               bridge.handle_kx_print)
    r.add_get("/kx/files",                bridge.handle_kx_files)
    r.add_delete("/kx/files/{file_id}",    bridge.handle_kx_file_delete)
    r.add_get("/kx/files/{file_id}/download", bridge.handle_kx_file_download)
    r.add_post("/kx/files/{file_id}/verify", bridge.handle_kx_file_verify)
    r.add_get("/kx/printer-files",         bridge.handle_kx_printer_files)
    r.add_post("/kx/printer-files/delete", bridge.handle_kx_printer_file_delete)
    r.add_get("/kx/printer-files/{filename}/thumbnail", bridge.handle_kx_printer_file_thumbnail)
    r.add_get("/kx/filament/slots",        bridge.handle_kx_filament_slots)
    r.add_get("/kx/filament/profiles",     bridge.handle_kx_filament_profiles)
    r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile)
    r.add_get("/kx/filament/visible_vendors",  bridge.handle_kx_visible_vendors)
    r.add_post("/kx/filament/visible_vendors", bridge.handle_kx_visible_vendors)
    # Custom profile import (Issue #41) - the user uploads their own Orca filament
    # profiles as ZIP/JSON (e.g. from ~/.config/OrcaSlicer/user/<id>/filament/),
    # because the bridge typically does not run on the same host as OrcaSlicer.
    r.add_get("/kx/filament/profiles/user",    bridge.handle_kx_filament_profiles_user_list)
    r.add_post("/kx/filament/profiles/user",   bridge.handle_kx_filament_profiles_import)
    r.add_delete("/kx/filament/profiles/user", bridge.handle_kx_filament_profiles_user_delete)
    r.add_get("/kx/history",               bridge.handle_kx_history)
    r.add_get("/kx/ui/{name:.*}",          bridge.handle_kx_ui_asset)
    r.add_get("/kx/files/{id}/objects",    bridge.handle_kx_file_objects)
    r.add_post("/kx/skip",                 bridge.handle_kx_skip)
    r.add_post("/kx/skip/query",           bridge.handle_kx_skip_query)
    r.add_get("/kx/skip/state",            bridge.handle_kx_skip_state)
    r.add_get("/kx/spoolman/status",       bridge.handle_kx_spoolman_status)
    r.add_get("/kx/spoolman/spools",       bridge.handle_kx_spoolman_spools)
    r.add_post("/kx/spoolman/active-spool", bridge.handle_kx_spoolman_set_active)
    r.add_route("OPTIONS", "/kx/{path:.*}", bridge.handle_kx_options)

    # Root + Printer-Routen (Single-Page, JS liest Pathname)
    r.add_get("/",                           bridge.handle_index)
    r.add_get(r"/printer{num:\d+}",          bridge.handle_index)
    r.add_get("/favicon.ico",               bridge.handle_favicon)

    # WebSocket
    r.add_get("/websocket",                  bridge.handle_websocket)

    # Catch-all: log all unknown requests instead of 404
    r.add_route("*", "/{path:.*}",           bridge.handle_catchall)

    return app


def _build_per_printer_args(base_args, p: dict):
    """Copy CLI args, override with the printer entry from config.ini."""
    import copy
    a = copy.copy(base_args)
    a.printer_ip = p.get("printer_ip") or base_args.printer_ip
    a.mqtt_port  = int(p.get("mqtt_port") or base_args.mqtt_port)
    a.username   = p.get("username")  or base_args.username
    a.password   = p.get("password")  or base_args.password
    a.mode_id    = p.get("mode_id")   or base_args.mode_id
    a.device_id  = p.get("device_id") or base_args.device_id
    a.port       = int(p.get("http_port") or base_args.port)
    return a


async def run_bridge(args):
    _set_verbose_http_log(bool(getattr(args, "verbose_http_log", 0)))
    printers = env_loader.list_printers()
    multi_mode = bool(printers)
    if not printers:
        printers = [{
            "id":         "1",
            "name":       getattr(args, "printer_name", None) or "Anycubic Kobra X",
            "printer_ip": args.printer_ip,
            "mqtt_port":  args.mqtt_port,
            "username":   args.username,
            "password":   args.password,
            "mode_id":    args.mode_id,
            "device_id":  args.device_id,
            "http_port":  args.port,
        }]

    store = GCodeStore(args.data_dir)
    all_bridges: dict = {}
    runners = []
    stop_event = threading.Event()
    loop = asyncio.get_event_loop()

    for idx, p in enumerate(printers):
        pid = str(p.get("id") or (idx + 1))
        per_args = _build_per_printer_args(args, p)
        # Default port convention: 7125 + (id-1) when no http_port is set
        if not p.get("http_port") and multi_mode:
            try:
                per_args.port = 7125 + (int(pid) - 1)
            except ValueError:
                per_args.port = 7125 + idx

        client = KobraXClient(
            host=per_args.printer_ip,
            port=per_args.mqtt_port,
            username=per_args.username,
            password=per_args.password,
            mode_id=per_args.mode_id,
            device_id=per_args.device_id,
            client_id=f"kobrax_bridge_{pid}",
        )
        bridge = KobraXBridge(
            client, args=per_args, store=store,
            printer_id=pid, all_bridges=all_bridges,
        )
        bridge._loop = loop
        # Adopt printer_name from config.ini if set
        if p.get("name"):
            bridge._state["printer_name"] = p["name"]
            bridge._name_locked = True
        all_bridges[pid] = bridge

        log.info(f"[Printer {pid}] Connecting to {per_args.printer_ip}:{per_args.mqtt_port}...")
        try:
            await loop.run_in_executor(None, client.connect)
            log.info(f"[Printer {pid}] MQTT connected")
        except Exception as e:
            err = _mqtt_error_msg(e)
            log.warning(f"[Printer {pid}] Connection failed: {err} - offline mode")
            bridge._state["print_state"] = "error"
            bridge._state["kobra_state"] = "offline"
            bridge._state["connection_error"] = err

        threading.Thread(
            target=bridge._poll_loop, args=(stop_event,),
            daemon=True, name=f"poll-{pid}",
        ).start()

        app = build_app(bridge)
        runner = web.AppRunner(app)
        await runner.setup()
        site = web.TCPSite(runner, args.host, per_args.port)
        await site.start()
        runners.append((runner, client, pid))

    import socket as _socket
    _in_docker = os.path.exists("/.dockerenv")
    _host_ip_override = env_loader.BRIDGE_HOST_IP.strip()
    if _host_ip_override:
        _local_ip = _host_ip_override
    else:
        try:
            with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as _s:
                _s.connect(("8.8.8.8", 80))
                _local_ip = _s.getsockname()[0]
        except Exception:
            _local_ip = args.host
    # Propagate to all bridge instances - used for absolute webcam URLs
    for _b in all_bridges.values():
        _b._local_ip = _local_ip
    ports = ", ".join(str(getattr(b._args, 'port', 0)) for b in all_bridges.values())
    if _in_docker and not _host_ip_override:
        # In a container the UDP trick only yields the Docker-internal IP - don't show it
        log.info(f"OrcaSlicer → Klipper → http://<IP of this Docker host>:{ports}")
        log.info("Running in Docker — set BRIDGE_HOST_IP to show the exact address")
    else:
        log.info(f"OrcaSlicer → Klipper → http://{_local_ip}:{ports}")
    log.info("Press Ctrl-C to stop")

    try:
        while True:
            await asyncio.sleep(3600)
    except (KeyboardInterrupt, asyncio.CancelledError):
        pass
    finally:
        stop_event.set()
        for runner, client, pid in runners:
            try:
                await runner.cleanup()
            except Exception:
                pass
            try:
                client.disconnect()
            except Exception:
                pass
        log.info("Bridge stopped")


def _default_data_dir() -> str:
    """Persistenz-Verzeichnis: Docker setzt KX_DATA_DIR, Binary nutzt <exe-dir>/data,
    Dev script uses <repo>/data (or /app/data if present)."""
    if os.environ.get("KX_DATA_DIR"):
        return os.environ["KX_DATA_DIR"]
    if getattr(sys, "frozen", False):
        return os.path.join(os.path.dirname(sys.executable), "data")
    if os.path.isdir("/app"):
        return "/app/data"
    return os.path.normpath(os.path.join(_BASE, "..", "data"))


def main():
    parser = argparse.ArgumentParser(description="Moonraker bridge for the Anycubic Kobra X")
    parser.add_argument("--printer-ip",  default=env_loader.PRINTER_IP,
                        help="IP-Adresse des Druckers")
    parser.add_argument("--mqtt-port",   type=int, default=env_loader.MQTT_PORT)
    parser.add_argument("--username",    default=env_loader.USERNAME)
    parser.add_argument("--password",    default=env_loader.PASSWORD)
    parser.add_argument("--mode-id",     default=env_loader.MODE_ID)
    parser.add_argument("--device-id",       default=env_loader.DEVICE_ID)
    parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT)
    parser.add_argument("--auto-leveling",           type=int, default=env_loader.AUTO_LEVELING)
    parser.add_argument("--vibration-compensation",  type=int, default=env_loader.VIBRATION_COMPENSATION)
    parser.add_argument("--camera-on-print",         type=int, default=env_loader.CAMERA_ON_PRINT)
    parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING)
    parser.add_argument("--print-start-dialog", dest="print_start_dialog", type=int, default=env_loader.PRINT_START_DIALOG)
    parser.add_argument("--file-ready-dialog",  dest="print_start_dialog", type=int)
    parser.add_argument("--spoolman-server",    default=env_loader.SPOOLMAN_SERVER,
                        help="Spoolman URL (e.g. http://192.168.x.x:7912); leave empty to disable")
    parser.add_argument("--spoolman-sync-rate", type=int, default=env_loader.SPOOLMAN_SYNC_RATE,
                        help="Mid-print filament sync interval in seconds (0 = only on print end)")
    parser.add_argument("--poll-interval", type=int, default=env_loader.POLL_INTERVAL,
                        help="Printer poll interval in seconds")
    parser.add_argument("--verbose-http-log", type=int, default=env_loader.VERBOSE_HTTP_LOG,
                        help="Log every HTTP request (aiohttp access log)")

    parser.add_argument("--host",            default="0.0.0.0",
                        help="Bind address for the bridge server")
    parser.add_argument("--port",        type=int, default=7125,
                        help="HTTP/WS-Port (Moonraker-Standard: 7125)")
    parser.add_argument("--data-dir",    default=_default_data_dir(),
                        help="Persistence directory for the GCode store and DB")
    parser.add_argument(
        "--ui-theme",
        default=os.environ.get("KX_UI_THEME", "default"),
        metavar="NAME",
        help="Web-UI-Theme (Ordner web/themes/NAME/, Standard: default). "
        "Alternativ: Umgebungsvariable KX_UI_THEME.",
    )
    args = parser.parse_args()
    if args.printer_ip and ":" in args.printer_ip:
        args.printer_ip = args.printer_ip.split(":")[0]

    # Windows needs ProactorEventLoop for asyncio.create_subprocess_exec
    if sys.platform == "win32":
        asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())

    asyncio.run(run_bridge(args))


if __name__ == "__main__":
    main()
