diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index 2a625bd..9b9818c 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -94,7 +94,7 @@ jobs: # VERSION-Datei im Arbeitsverzeichnis für den Docker-Build setzen (kein Commit) echo "$VERSION" > VERSION docker buildx build \ - --platform linux/amd64,linux/arm64 \ + --platform linux/amd64,linux/arm64,linux/arm/v7 \ --push \ --provenance=false \ --no-cache \ diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 8e58cd9..91ff4ec 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -61,7 +61,7 @@ jobs: run: | VERSION="${GITHUB_REF#refs/tags/v}" docker buildx build \ - --platform linux/amd64,linux/arm64 \ + --platform linux/amd64,linux/arm64,linux/arm/v7 \ --push \ --provenance=false \ --no-cache \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 40bb001..75fb40e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [Unreleased] + +### Fixed +- **Slot kept showing/printing a stale filament type after a spool swap.** The + per-slot profile override (config.ini `[filament_profiles]`) stores only + vendor+name and was sticky: swapping the physical filament updated the AMS + colour and type live, but the saved profile persisted, so a slot that held + e.g. "KINGROON PETG Basic" kept showing/sending PETG in the panel and the + OrcaSlicer lane hint even after yellow PLA was loaded — and survived restarts. + The override is now applied only while its material *family* still matches the + loaded AMS material (PLA / PLA+ / PLA SILK / PLA MATTE are one family, so + within-family swaps never invalidate a valid profile). On a family change the + slot falls back to the generic default; the override is not deleted, so + reloading the original material reactivates it. +- **Filament profiles not isolated between printers in a multi-printer bridge** + (issue #74). The slot→profile mapping and `visible_vendors` were stored in a + single global `[filament_profiles]` section, so configuring one printer + overwrote the other and after a restart both loaded the same mapping. Each + printer now persists to its own `[filament_profiles_]` section, with a + read-fallback to the legacy global section (single-printer setups unchanged). +- **Printer dropdown showed the other printer's filament profiles** (issue #74). + The header dropdown and the printers-management "switch" link navigated within + the same port (`/printerN`), so viewing another printer pulled its profile + names cross-instance from the local origin. The links now point at each + printer's own `bridge_url`, so every printer is viewed same-origin on its own + port. + ## [0.9.26] – 2026-06-21 ### New diff --git a/Dockerfile b/Dockerfile index 553475a..ceec4e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,10 +2,11 @@ FROM python:3.11-slim-bookworm WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg gcc python3-dev && rm -rf /var/lib/apt/lists/* COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r requirements.txt && \ + apt-get purge -y gcc python3-dev && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* COPY kobrax_moonraker_bridge.py . COPY web/ ./web/ diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index f6a628a..973a812 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,6 +1,8 @@ ## Changes in this build -- Unified axes control panel: XY and Z merged into one card, shared step size selector (0.1 / 1 / 5 / 10 mm) plus custom mm input field, Home XY/Z buttons placed directly below their respective pads -- Language selector moved from header bar to Settings → Appearance -- Filament mismatch detection: Upload-and-Print is intercepted when GCode material differs from the loaded AMS slot — slot mapper dialog opens automatically to correct the assignment before printing -- Spoolman: assign a spool per AMS slot directly in the AMS status tab (dropdown per slot kachel) and in the Filaments settings tab (dedicated assignment card) +- Fix: the poll interval setting was saved to config.ini but never actually applied — the poll loop always used a hardcoded 3s wait +- Fix: Spoolman status showed a green "connected" dot even when the server was unreachable — reachability is now rechecked periodically and shown accurately (green/red) +- Fix: **settings could silently revert after saving** — the bridge restart that applies settings didn't clean up all the relevant environment variables, so the old value could win over the one just saved (affected poll interval, resonance compensation, Docker host IP, and the new HTTP log toggle below). Fixed at the root so this class of bug can't recur for future settings. +- Feat: new "Log every HTTP request (verbose)" toggle in Settings — off by default, since aiohttp's per-request access log was drowning out the bridge's own logs with the frontend's 2s polling + +**A stable release is coming soon** with the fixes and features from the last several nightly builds. As usual it will be published as ready-to-run binaries (Linux amd64/arm64, Windows) alongside the Docker image. diff --git a/config.ini.example b/config.ini.example index f0fa3b3..5b31436 100644 --- a/config.ini.example +++ b/config.ini.example @@ -41,6 +41,21 @@ web_upload_warning = 1 # Poll-Intervall in Sekunden poll_interval = 3 +# ─── Spoolman (optional) ─────────────────────────────────────────────────────── +# Verfolgt den Filamentverbrauch je AMS-Slot und bucht ihn automatisch vom +# passenden Spool ab (mm-basiert, wie Moonraker; Spoolman rechnet mm→Gramm). +# [spoolman] +# # Server-URL der Spoolman-Instanz (aus Sicht des Bridge-Containers erreichbar): +# server = http://192.168.x.x:7912 +# # 0 = nur am Druckende abbuchen, >0 = alle N Sekunden während des Drucks: +# sync_rate = 0 +# +# Die AMS-Slot → Spool-Zuordnung wird in der Weboberfläche gesetzt und je Drucker +# automatisch persistiert (nicht von Hand eintragen): +# Einzeldrucker : [spoolman] slot_spools = 0:42,1:17 +# Multi-Printer : [spoolman_1] slot_spools = 0:42,1:17 +# [spoolman_2] slot_spools = 0:5,1:6 + # ─── Multi-Printer (optional) ────────────────────────────────────────────────── # Mehrere Drucker können als [printer_1], [printer_2], … definiert werden. # Jede Bridge-Instanz verbindet sich mit einem Drucker (je eigener Port). diff --git a/config_loader.py b/config_loader.py index e3ebba0..57fe9ca 100644 --- a/config_loader.py +++ b/config_loader.py @@ -1,12 +1,13 @@ """ -config_loader.py – lädt Verbindungsparameter aus config/config.ini (primär) -oder .env (Fallback / Migration). -Umgebungsvariablen haben immer Vorrang. +config_loader.py - loads connection parameters from config/config.ini (primary) +or .env (fallback / migration). +Environment variables always take precedence. """ import os import sys import pathlib import configparser +from typing import Optional _BASE = pathlib.Path(sys.executable).parent if getattr(sys, "frozen", False) else pathlib.Path(__file__).parent @@ -33,7 +34,7 @@ def _find_env_file() -> pathlib.Path | None: def _load_env_file(path: pathlib.Path): - """Lädt .env-Datei als Fallback – setzt nur Keys die noch nicht in os.environ sind.""" + """Loads the .env file as a fallback - only sets keys not yet in os.environ.""" with open(path, encoding="utf-8") as f: for line in f: line = line.strip() @@ -46,28 +47,39 @@ def _load_env_file(path: pathlib.Path): os.environ[key] = val +# Single source of truth for env-var <-> config.ini mapping. _restart_bridge() +# in kobrax_moonraker_bridge.py clears exactly these keys from os.environ +# before restarting, so a value removed here or in the UI settings save can +# never survive as a stale env var read by the new process. Add new settings +# here ONLY - no second list to keep in sync. +CONFIG_ENV_MAPPING = { + "PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"), + "MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"), + "MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"), + "MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"), + "MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"), + "DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"), + "DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"), + "AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"), + "VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"), + "CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"), + "WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"), + "PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"), + "BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"), + "BRIDGE_HOST_IP": (CONFIG_SECTION_BRIDGE, "host_ip"), + "POLL_INTERVAL": (CONFIG_SECTION_BRIDGE, "poll_interval"), + "VERBOSE_HTTP_LOG": (CONFIG_SECTION_BRIDGE, "verbose_http_log"), + "SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"), + "SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"), +} + + def _load_config_file(path: pathlib.Path): - """Lädt config.ini und setzt Keys in os.environ (nur wenn nicht bereits gesetzt).""" + """Loads config.ini and sets keys in os.environ (only if not already set).""" cfg = configparser.ConfigParser() cfg.read(path, encoding="utf-8") - mapping = { - "PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"), - "MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"), - "MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"), - "MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"), - "MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"), - "DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"), - "DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"), - "AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"), - "CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"), - "WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"), - "PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"), - "BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"), - "SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"), - "SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"), - } - for env_key, (section, option) in mapping.items(): + for env_key, (section, option) in CONFIG_ENV_MAPPING.items(): if env_key not in os.environ: try: val = cfg.get(section, option) @@ -112,8 +124,9 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path): } cfg[CONFIG_SECTION_PRINT] = { "default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"), - "auto_leveling": env_vals.get("AUTO_LEVELING", "1"), - "camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"), + "auto_leveling": env_vals.get("AUTO_LEVELING", "1"), + "vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"), + "camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"), "web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"), } cfg[CONFIG_SECTION_BRIDGE] = { @@ -121,12 +134,12 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path): } with open(config_path, "w", encoding="utf-8") as f: f.write("# KX-Bridge Konfigurationsdatei\n") - f.write("# Automatisch migriert aus .env\n\n") + f.write("# Automatically migrated from .env\n\n") cfg.write(f) def find_config_path() -> pathlib.Path: - """Gibt den Pfad zur config.ini zurück (auch wenn sie noch nicht existiert).""" + """Returns the path to config.ini (even if it does not exist yet).""" for base in (_BASE, _BASE.parent): config_dir = base / "config" if config_dir.is_dir(): @@ -142,7 +155,7 @@ _env_path = _find_env_file() if _config_path: _load_config_file(_config_path) elif _env_path: - # Kein config.ini vorhanden → aus .env migrieren + # No config.ini present -> migrate from .env _target = find_config_path() migrate_env_to_config(_env_path, _target) _load_config_file(_target) @@ -150,13 +163,13 @@ elif _env_path: def list_printers() -> list[dict]: - """Liest alle [printer_N]-Sektionen aus config.ini. + """Reads all [printer_N] sections from config.ini. - Jede Sektion kann folgende Keys haben: + Each section may contain the following keys: name, printer_ip, mqtt_port, username, password, mode_id, device_id, bridge_url, default_ams_slot, auto_leveling - Gibt eine leere Liste zurück wenn keine [printer_N]-Sektionen vorhanden sind + Returns an empty list when no [printer_N] sections exist (Single-Printer-Betrieb via [connection]). """ path = _find_config_file() @@ -181,33 +194,19 @@ def list_printers() -> list[dict]: idx += 1 return printers +def _filament_section(printer_id: Optional[str] = None) -> str: + """Section name holding a printer's filament-profile mapping. -def list_notification_urls() -> list[dict]: - """Reads [{url, events: [str]}] from the [notifications] section of config.ini.""" - path = _find_config_file() - if not path: - return [] - cfg = configparser.ConfigParser() - cfg.read(path, encoding="utf-8") - if not cfg.has_section("notifications"): - return [] - result = [] - idx = 1 - while True: - url_key = f"url_{idx}" - if not cfg.has_option("notifications", url_key): - break - url = cfg.get("notifications", url_key).strip() - if url: - events_str = cfg.get("notifications", f"events_{idx}", fallback="finished,failed,cancelled") - events = [e.strip() for e in events_str.split(",") if e.strip()] - include_image = cfg.get("notifications", f"image_{idx}", fallback="false").strip().lower() in ("1", "true", "yes") - enabled = cfg.get("notifications", f"enabled_{idx}", fallback="true").strip().lower() in ("1", "true", "yes") - result.append({"url": url, "events": events, "include_image": include_image, "enabled": enabled}) - idx += 1 - return result - - + Multi-printer (one bridge, N printers): each printer keeps its own + ``[filament_profiles_]`` section so the mappings cannot overwrite each + other. ``printer_id is None`` (single-printer / legacy callers) maps to the + original global ``[filament_profiles]`` section — full backward compatibility. + """ + pid = str(printer_id).strip() if printer_id is not None else "" + if pid and pid != "0": + return f"filament_profiles_{pid}" + return "filament_profiles" + def list_filament_profiles() -> dict[int, dict]: """Liest die [filament_profiles]-Sektion aus config.ini. @@ -221,12 +220,12 @@ def list_filament_profiles() -> dict[int, dict]: slot_0_name = PolyTerra PLA slot_0_id = OGFL01 - Gibt einen Dict {slot_index: {"id": ..., "vendor": ..., "name": ...}} - zurück. Leere/fehlende Slots werden NICHT aufgenommen — das Default-Mapping - (per filament_type) in der Bridge bleibt dann aktiv. + Returns a dict {slot_index: {"id": ..., "vendor": ..., "name": ...}}. + Empty/missing slots are NOT included - the default mapping + (per filament_type) in the bridge then stays active. - Backwards-Kompat: alte Configs mit nur (vendor, id) bleiben lesbar; `name` - fehlt dann und der Aufrufer kann optional aus der orca_filaments.json + Backwards compat: old configs with only (vendor, id) stay readable; `name` + is then missing and the caller can optionally resolve it from orca_filaments.json rekonstruieren. """ path = _find_config_file() @@ -234,11 +233,14 @@ def list_filament_profiles() -> dict[int, dict]: return {} cfg = configparser.ConfigParser() cfg.read(path, encoding="utf-8") - if not cfg.has_section("filament_profiles"): + section = _filament_section(printer_id) + if not cfg.has_section(section): + section = "filament_profiles" # fallback: legacy global section + if not cfg.has_section(section): return {} result: dict[int, dict] = {} - for key, value in cfg.items("filament_profiles"): - # Erwartet: slot__id oder slot__vendor oder slot__name + for key, value in cfg.items(section): + # Expects: slot__id or slot__vendor or slot__name if not key.startswith("slot_"): continue parts = key.split("_", 2) @@ -257,74 +259,173 @@ def list_filament_profiles() -> dict[int, dict]: return result -def save_filament_profiles(profiles: dict[int, dict]) -> bool: - """Schreibt die übergebenen Slot-Profile in die [filament_profiles]- - Sektion der config.ini. Existierende Einträge werden komplett ersetzt. +def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str] = None) -> bool: + """Writes the given slot profiles into the [filament_profiles] + section of config.ini. Existing entries are completely replaced. profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker", "name": "PolyTerra PLA"}} - Mindestens vendor+name müssen gesetzt sein; id ist optional (Hint). + At least vendor+name must be set; id is optional (hint). + + With ``printer_id`` set, writes the per-printer ``[filament_profiles_]`` + section only — other printers and the legacy global section are untouched. """ path = _find_config_file() if not path: return False cfg = configparser.ConfigParser() cfg.read(path, encoding="utf-8") - # visible_vendors (Issue #41) ist kein Slot-Mapping — beim Ersetzen der - # Sektion erhalten, sonst geht der Vendor-Filter beim Slot-Save verloren. + section = _filament_section(printer_id) + # visible_vendors (Issue #41) is not a slot mapping - preserve it when + # replacing the section, otherwise the vendor filter is lost on slot save. + # First save of a per-printer section inherits the legacy global filter. preserved_vendors = None - if cfg.has_option("filament_profiles", "visible_vendors"): + if cfg.has_option(section, "visible_vendors"): + preserved_vendors = cfg.get(section, "visible_vendors") + elif cfg.has_option("filament_profiles", "visible_vendors"): preserved_vendors = cfg.get("filament_profiles", "visible_vendors") - if cfg.has_section("filament_profiles"): - cfg.remove_section("filament_profiles") + if cfg.has_section(section): + cfg.remove_section(section) if profiles or preserved_vendors: - cfg["filament_profiles"] = {} + cfg[section] = {} if preserved_vendors: - cfg["filament_profiles"]["visible_vendors"] = preserved_vendors + cfg[section]["visible_vendors"] = preserved_vendors for slot_idx in sorted(profiles.keys()): entry = profiles[slot_idx] or {} if entry.get("vendor"): - cfg["filament_profiles"][f"slot_{slot_idx}_vendor"] = entry["vendor"] + cfg[section][f"slot_{slot_idx}_vendor"] = entry["vendor"] if entry.get("name"): - cfg["filament_profiles"][f"slot_{slot_idx}_name"] = entry["name"] + cfg[section][f"slot_{slot_idx}_name"] = entry["name"] if entry.get("id"): - cfg["filament_profiles"][f"slot_{slot_idx}_id"] = entry["id"] + cfg[section][f"slot_{slot_idx}_id"] = entry["id"] with open(path, "w", encoding="utf-8") as f: cfg.write(f) return True -def list_visible_vendors() -> list[str]: - """Liest [filament_profiles] visible_vendors (komma-separiert) aus config.ini. +def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]: + """Reads [filament_profiles] visible_vendors (comma-separated) from config.ini. - Vendor-Sichtbarkeitsfilter für das Slot-Profil-Dropdown (Issue #41 Option A). - Leere Liste = keine Einschränkung (rückwärtskompatibel: alle Vendoren). + Vendor visibility filter for the slot profile dropdown (Issue #41 option A). + Empty list = no restriction (backwards compatible: all vendors). + + With ``printer_id`` set, reads the per-printer section and falls back to the + legacy global ``[filament_profiles]`` filter. """ path = _find_config_file() if not path: return [] cfg = configparser.ConfigParser() cfg.read(path, encoding="utf-8") - if not cfg.has_option("filament_profiles", "visible_vendors"): + section = _filament_section(printer_id) + if not cfg.has_option(section, "visible_vendors"): + section = "filament_profiles" # fallback: legacy global section + if not cfg.has_option(section, "visible_vendors"): return [] - raw = cfg.get("filament_profiles", "visible_vendors") + raw = cfg.get(section, "visible_vendors") return [v.strip() for v in raw.split(",") if v.strip()] -def save_visible_vendors(vendors: list[str]) -> bool: - """Schreibt visible_vendors in [filament_profiles], ohne die Slot-Mappings - (slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder.""" +def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) -> bool: + """Writes visible_vendors into [filament_profiles] without touching the + (slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder. + + With ``printer_id`` set, writes the per-printer section. When that section is + created here for the first time, the slot mappings are seeded from the legacy + global section so they are not orphaned by the read-fallback in + ``list_filament_profiles``.""" path = _find_config_file() if not path: return False cfg = configparser.ConfigParser() cfg.read(path, encoding="utf-8") - if not cfg.has_section("filament_profiles"): - cfg.add_section("filament_profiles") + section = _filament_section(printer_id) + if not cfg.has_section(section): + cfg.add_section(section) + if section != "filament_profiles" and cfg.has_section("filament_profiles"): + for key, value in cfg.items("filament_profiles"): + if key.startswith("slot_"): + cfg[section][key] = value clean = [v.strip() for v in (vendors or []) if v and v.strip()] if clean: - cfg["filament_profiles"]["visible_vendors"] = ", ".join(clean) - elif cfg.has_option("filament_profiles", "visible_vendors"): - cfg.remove_option("filament_profiles", "visible_vendors") + cfg[section]["visible_vendors"] = ", ".join(clean) + elif cfg.has_option(section, "visible_vendors"): + cfg.remove_option(section, "visible_vendors") + with open(path, "w", encoding="utf-8") as f: + cfg.write(f) + return True + + +def _spoolman_map_section(printer_id: Optional[str] = None) -> str: + """Section name holding a printer's AMS-slot → Spoolman-spool map. + + Multi-printer (one bridge, N printers): each printer keeps its map in its + own ``[spoolman_]`` section so two AMS units cannot overwrite each + other's mapping. ``printer_id is None`` (single-printer / legacy callers) + uses the original ``[spoolman] slot_spools`` key — full backward + compatibility. The global ``[spoolman]`` section keeps ``server`` / + ``sync_rate`` regardless. + """ + pid = str(printer_id).strip() if printer_id is not None else "" + if pid and pid != "0": + return f"{CONFIG_SECTION_SPOOLMAN}_{pid}" + return CONFIG_SECTION_SPOOLMAN + + +def _parse_slot_spools(raw: str) -> dict[int, int]: + """Parse ``"0:42,1:17"`` → ``{0: 42, 1: 17}`` (positive spool ids only).""" + result: dict[int, int] = {} + for pair in (raw or "").split(","): + pair = pair.strip() + if ":" not in pair: + continue + k, _, v = pair.partition(":") + k, v = k.strip(), v.strip() + if k.isdigit() and v.lstrip("-").isdigit() and int(v) > 0: + result[int(k)] = int(v) + return result + + +def list_spool_map(printer_id: Optional[str] = None) -> dict[int, int]: + """Read the AMS-slot → Spoolman-spool-id map from config.ini. + + With ``printer_id`` set, reads the per-printer ``[spoolman_] + slot_spools`` key and falls back to the legacy global ``[spoolman] + slot_spools`` while that printer has no own section yet. Returns + ``{slot_index: spool_id}`` (only positive ids). + """ + path = _find_config_file() + if not path: + return {} + cfg = configparser.ConfigParser() + cfg.read(path, encoding="utf-8") + section = _spoolman_map_section(printer_id) + if cfg.has_option(section, "slot_spools"): + return _parse_slot_spools(cfg.get(section, "slot_spools", fallback="")) + if cfg.has_option(CONFIG_SECTION_SPOOLMAN, "slot_spools"): # legacy global fallback + return _parse_slot_spools(cfg.get(CONFIG_SECTION_SPOOLMAN, "slot_spools", fallback="")) + return {} + + +def save_spool_map(slot_spools: dict[int, int], printer_id: Optional[str] = None) -> bool: + """Persist the AMS-slot → Spoolman-spool-id map to config.ini. + + With ``printer_id`` set, writes only the per-printer ``[spoolman_]`` + section so other printers and the global ``[spoolman]`` server config stay + untouched. An empty map clears the key. + """ + path = _find_config_file() + if not path: + return False + cfg = configparser.ConfigParser() + cfg.read(path, encoding="utf-8") + section = _spoolman_map_section(printer_id) + clean = {int(k): int(v) for k, v in (slot_spools or {}).items() if int(v) > 0} + if clean: + if not cfg.has_section(section): + cfg.add_section(section) + cfg[section]["slot_spools"] = ",".join(f"{k}:{v}" for k, v in sorted(clean.items())) + elif cfg.has_option(section, "slot_spools"): + cfg.remove_option(section, "slot_spools") with open(path, "w", encoding="utf-8") as f: cfg.write(f) return True @@ -334,7 +435,7 @@ def get(key: str, default: str = "") -> str: return os.environ.get(key, default) -# Häufig verwendete Shortcuts +# Frequently used shortcuts PRINTER_IP = get("PRINTER_IP", "") MQTT_PORT = int(get("MQTT_PORT", "9883")) USERNAME = get("MQTT_USERNAME", "") @@ -342,9 +443,13 @@ PASSWORD = get("MQTT_PASSWORD", "") MODE_ID = get("MODE_ID", "") DEVICE_ID = get("DEVICE_ID", "") DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto") -AUTO_LEVELING = int(get("AUTO_LEVELING","1")) -CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0")) +AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) +VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0")) +CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0")) WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1")) PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1"))) SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "") SPOOLMAN_SYNC_RATE = int(get("SPOOLMAN_SYNC_RATE", "0")) +BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "") +POLL_INTERVAL = int(get("POLL_INTERVAL", "3")) +VERBOSE_HTTP_LOG = int(get("VERBOSE_HTTP_LOG", "0")) diff --git a/docker-compose.yml b/docker-compose.yml index 21fa90c..773bebf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,8 @@ services: - ./.env:/app/.env:ro ports: - "7125-7130:7125-7130" + # environment: + # - BRIDGE_HOST_IP=192.168.1.100 # LAN-IP des Docker-Hosts (für korrekte Log-Anzeige) restart: unless-stopped logging: driver: json-file diff --git a/env_loader.py b/env_loader.py index 0ff9433..97f37af 100644 --- a/env_loader.py +++ b/env_loader.py @@ -1,6 +1,6 @@ """ -env_loader.py – lädt Verbindungsparameter aus .env (Repo-Root oder Arbeitsverzeichnis). -Umgebungsvariablen haben Vorrang vor .env-Werten. +env_loader.py - loads connection parameters from .env (repo root or working directory). +Environment variables take precedence over .env values. """ import os import sys @@ -39,7 +39,7 @@ def get(key: str, default: str = "") -> str: return os.environ.get(key, default) -# Häufig verwendete Shortcuts +# Frequently used shortcuts PRINTER_IP = get("PRINTER_IP", "") MQTT_PORT = int(get("MQTT_PORT", "9883")) USERNAME = get("MQTT_USERNAME", "") @@ -47,7 +47,9 @@ PASSWORD = get("MQTT_PASSWORD", "") MODE_ID = get("MODE_ID", "") DEVICE_ID = get("DEVICE_ID", "") DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto") -AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) -CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0")) +AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) +VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0")) +CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0")) WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1")) PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1"))) +BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "") diff --git a/kobrax_client.py b/kobrax_client.py index ec81391..7d03980 100644 --- a/kobrax_client.py +++ b/kobrax_client.py @@ -1,10 +1,10 @@ """ kobrax_client.py – Anycubic Kobra X LAN-MQTT-Client -Protokoll vollständig rekonstruiert via Sniffer 2026-04-17 (953 Nachrichten). +Protocol fully reconstructed via sniffer 2026-04-17 (953 messages). Voraussetzungen: - - /tmp/anycubic_slicer.crt und .key (aus cloud_mqtt.dll @ 0x2ed5b0 / 0x2edce0) + - /tmp/anycubic_slicer.crt and .key (from cloud_mqtt.dll @ 0x2ed5b0 / 0x2edce0) - Drucker im LAN-Modus erreichbar auf Port 9883 Verwendung: @@ -121,9 +121,9 @@ class KobraXClient: self._buf = b"" self._pid = 1 self._lock = threading.Lock() - # Generations-Marker: wird bei jedem Socket-Swap/Close erhöht, damit der - # Reader-Thread erkennt wenn _reconnect/_do_connect den Socket unter ihm - # ersetzt hat (Issue #53). Schützt gegen recv auf einem stale fd. + # Generation marker: incremented on every socket swap/close so the + # reader thread notices when _reconnect/_do_connect swapped the socket + # underneath it (Issue #53). Protects against recv on a stale fd. self._sock_gen = 0 self._running = False @@ -162,9 +162,9 @@ class KobraXClient: if not os.path.exists(CERT_FILE) or not os.path.exists(KEY_FILE): raise FileNotFoundError( f"TLS-Zertifikate fehlen: anycubic_slicer.crt + anycubic_slicer.key " - f"müssen neben der kx-bridge Binary liegen ({_SCRIPT_DIR}/). " - f"Lade anycubic-certs.zip vom Gitea-Release herunter und entpacke " - f"die Dateien dorthin." + f"must sit next to the kx-bridge binary ({_SCRIPT_DIR}/). " + f"Download anycubic-certs.zip from the Gitea release and extract " + f"the files there." ) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ctx.check_hostname = False @@ -172,9 +172,9 @@ class KobraXClient: ctx.set_ciphers("DEFAULT:@SECLEVEL=0") ctx.load_cert_chain(CERT_FILE, KEY_FILE) - # Socket als lokale Variable aufbauen — der Handshake (Connect + CONNACK) - # läuft OHNE gehaltenes Lock, damit ein langsamer Connect die Sender nicht - # einfriert. Erst der fertige Socket wird unter Lock eingeschwenkt (#53). + # Build the socket as a local variable - the handshake (connect + CONNACK) + # runs WITHOUT holding the lock so a slow connect does not freeze + # senders. Only the finished socket is swapped in under the lock (#53). _ai = socket.getaddrinfo(self.host, self.port, socket.AF_INET, socket.SOCK_STREAM) raw = socket.create_connection(_ai[0][4], timeout=5) new_sock = ctx.wrap_socket(raw) @@ -196,7 +196,7 @@ class KobraXClient: self._sock = new_sock self._sock_gen += 1 self._buf = b"" - self._subscribe(self._sub_topic()) # nimmt das Lock selbst — nicht verschachteln + self._subscribe(self._sub_topic()) # takes the lock itself - do not nest log.debug("MQTT connected to %s:%s", self.host, self.port) def connect(self): @@ -206,10 +206,10 @@ class KobraXClient: time.sleep(0.3) def _ensure_reader(self): - """Stellt sicher dass der Reader-Thread lebt. Wenn der Reader nach einer - früheren disconnect/reconnect-Sequenz oder einem unbehandelten Fehler - gestorben ist, würden empfangene Replies sonst nie ankommen — publish() - würde dann zwar senden, aber auf Antworten ewig warten.""" + """Ensures the reader thread is alive. If the reader died after a + previous disconnect/reconnect sequence or an unhandled error, + received replies would never arrive - publish() + would still send but wait for replies forever.""" if not self._running: return # gewollter disconnect t = getattr(self, "_reader_thread", None) @@ -232,13 +232,13 @@ class KobraXClient: self._sock_gen += 1 def _reconnect(self): - """Persistenter Reconnect: versucht endlos weiter bis der Drucker wieder - antwortet oder disconnect() gerufen wurde. Backoff cappt bei 60 s. Die - ersten 5 Versuche loggen als WARNING (akute Verbindungsstörung), danach - nur DEBUG um Log-Spam bei langem Drucker-Ausfall (z.B. über Nacht + """Persistent reconnect: keeps retrying forever until the printer is + responds or disconnect() was called. Backoff caps at 60 s. The + first 5 attempts log as WARNING (acute connection issue), afterwards + only DEBUG to avoid log spam during long printer outages (e.g. switched ausgeschaltet) zu vermeiden.""" - log.warning("Verbindung verloren – reconnect…") - # Close + Invalidierung unter Lock, damit kein Sender mitten im sendall + log.warning("Connection lost - reconnecting...") + # Close + invalidation under the lock so no sender is mid-sendall # auf den gerade geschlossenen Socket trifft (Issue #53). with self._lock: try: @@ -254,18 +254,18 @@ class KobraXClient: delay = delays[min(attempt, len(delays) - 1)] try: self._do_connect() - log.info("Reconnect erfolgreich (nach %d Versuchen)", attempt + 1) + log.info("Reconnect successful (after %d attempts)", attempt + 1) return True except Exception as e: attempt += 1 lvl = log.warning if attempt <= 5 else log.debug lvl("Reconnect fehlgeschlagen (%s, Versuch %d), warte %ss…", e, attempt, delay) - # Geteiltes Sleep damit disconnect() den Loop schneller bricht. + # Split sleep so disconnect() breaks the loop faster. slept = 0.0 while slept < delay and self._running: time.sleep(min(0.5, delay - slept)) slept += 0.5 - return False # nur wenn disconnect() gerufen wurde + return False # only when disconnect() was called def _subscribe(self, topic: str): with self._lock: @@ -290,15 +290,15 @@ class KobraXClient: ping_ok = True except Exception: ping_ok = False - # _reconnect() AUSSERHALB des Locks aufrufen — es nimmt das Lock - # selbst, und threading.Lock ist nicht reentrant (sonst Deadlock). + # Call _reconnect() OUTSIDE the lock - it takes the lock + # itself, and threading.Lock is not reentrant (deadlock otherwise). if not ping_ok: if self._running and not self._reconnect(): break last_ping = time.time() - # Aktuellen Socket + Generation unter Lock greifen, damit ein - # paralleler _reconnect/_do_connect-Swap uns nicht auf einem stale - # fd pollen lässt (Issue #53). + # Grab the current socket + generation under the lock so a + # parallel _reconnect/_do_connect swap does not leave us polling + # a stale fd (Issue #53). with self._lock: sock = self._sock gen = self._sock_gen @@ -306,37 +306,37 @@ class KobraXClient: time.sleep(0.05) continue - # Idle-Wartezeit OHNE Lock — select probt nur die Bereitschaft, so - # blockiert der Reader während Leerlauf nie das gemeinsame Lock. + # Idle wait WITHOUT the lock - select only probes readiness, so + # the reader never blocks the shared lock while idle. try: ready, _, _ = select.select([sock], [], [], 0.2) except (OSError, ValueError): - # fd geschlossen/ungültig (Reconnect oder Disconnect mitten im select) + # fd closed/invalid (reconnect or disconnect mid-select) if not self._running: break time.sleep(0.05) continue if not ready: - continue # Leerlauf, kein Lock gehalten + continue # idle, no lock held - # Daten liegen an: Lock kurz greifen für das eine recv, serialisiert - # gegen alle sendall-Caller. recv blockiert nicht lange (select sagte - # ready, Socket-Timeout ist 0.2s). + # Data pending: briefly take the lock for the single recv, serialized + # against all sendall callers. recv does not block long (select said + # ready, socket timeout is 0.2s). try: with self._lock: - # Socket könnte zwischen select und hier ersetzt worden sein. + # The socket could have been swapped between select and here. if self._sock_gen != gen or self._sock is not sock: continue data = sock.recv(65536) if not data: - # Windows SSL kann kurzzeitig b"" liefern ohne echten EOF + # Windows SSL can briefly return b"" without a real EOF _empty_count += 1 if _empty_count >= 5: raise ConnectionResetError("EOF") continue _empty_count = 0 self._buf += data - self._drain() # außerhalb des Locks — Dispatch/event.set() bleibt prompt + self._drain() # outside the lock - dispatch/event.set() stays prompt except ssl.SSLWantReadError: continue except socket.timeout: @@ -445,8 +445,8 @@ class KobraXClient: # -- Publish + request/response ------------------------------------------ def publish(self, msg_type: str, action: str, data=None, timeout: float = 5.0) -> dict | None: - # Falls Reader-Thread aus historischen Gründen tot ist, wiederbeleben — - # sonst würden Replies nie ankommen und event.wait() läuft ins Timeout. + # If the reader thread is dead for historical reasons, revive it - + # otherwise replies would never arrive and event.wait() would time out. self._ensure_reader() msgid = str(uuid.uuid4()) payload = json.dumps({ @@ -471,7 +471,7 @@ class KobraXClient: report_registered = True topic = self._pub_topic(msg_type) - # Status-Poll-TX (query/getInfo) ist reines Rauschen (alle paar Sekunden) → + # Status poll TX (query/getInfo) is pure noise (every few seconds) -> # auf DEBUG. Aktions-TX (start/set/control/move/…) bleibt INFO sichtbar. _tx_level = logging.DEBUG if action in ("query", "getInfo") else logging.INFO log.log(_tx_level, "TX %-25s action=%-12s data=%s", @@ -531,8 +531,8 @@ class KobraXClient: self._sock.sendall(_build_publish(topic, payload)) except Exception as e: log.error("web send error: %s, reconnecting…", e) - # Reconnect triggern (analog zu publish()); ohne Retry weil - # fire-and-forget — der nächste Aufruf wird auf den frischen Socket + # Trigger a reconnect (like publish()); no retry because it is + # fire-and-forget - the next call will hit the fresh socket # treffen. try: self._reconnect() @@ -579,13 +579,13 @@ class KobraXClient: # -- Part-Skip ("Exclude Object") --------------------------------------- def query_skip_objects(self) -> dict | None: - """Fragt den Drucker nach der aktuellen Objekt-/Skip-Liste.""" + """Asks the printer for the current object/skip list.""" return self.publish("skip", "query_obj") def skip_objects(self, names: list[str]) -> dict | None: - """Überspringt die genannten Objekte – auch mid-print möglich. + """Skips the named objects - also possible mid-print. - Namen entsprechen den EXCLUDE_OBJECT_DEFINE NAME=… Einträgen + Names correspond to the EXCLUDE_OBJECT_DEFINE NAME=... entries im GCode-Header bzw. file_details.objects_skip_parts. """ return self.publish("skip", "start", {"objects_skip_parts": list(names)}) @@ -653,14 +653,14 @@ class KobraXClient: f"Connection: close\r\n\r\n" ).encode() - # Connect-Timeout kurz (LAN). Während sendall() darf der Socket so - # lange brauchen wie nötig — bei großen Dateien (>100 MB) und - # langsamerem WLAN am Drucker dauert das Schieben sonst >30 s und - # würde den Connect-Timeout fälschlich auslösen. Read-Timeout danach - # generös (Drucker verarbeitet die Datei bevor er antwortet). + # Short connect timeout (LAN). During sendall() the socket may take + # as long as needed - with large files (>100 MB) and slower WiFi + # at the printer, pushing otherwise takes >30 s and would falsely + # trip the connect timeout. The read timeout afterwards is generous + # (the printer processes the file before replying). _ai = socket.getaddrinfo(self.host, 18910, socket.AF_INET, socket.SOCK_STREAM) sock = socket.create_connection(_ai[0][4], timeout=10) - sock.settimeout(None) # blocking während Send + sock.settimeout(None) # blocking during send sock.sendall(headers + body) sock.settimeout(180) response = b"" @@ -717,7 +717,7 @@ if __name__ == "__main__": parser.add_argument("--mode-id", default=env_loader.MODE_ID) parser.add_argument("--device-id", default=env_loader.DEVICE_ID) parser.add_argument("--monitor", action="store_true", - help="Dauerhaft mithören und alle Reports ausgeben") + help="Listen continuously and print all reports") args = parser.parse_args() client = KobraXClient( @@ -741,7 +741,7 @@ if __name__ == "__main__": client.callbacks["*"] = on_msg client.connect() - print("[kobrax] Monitor-Modus aktiv (Ctrl-C zum Beenden)") + print("[kobrax] Monitor mode active (Ctrl-C to stop)") try: while True: time.sleep(1) @@ -755,7 +755,7 @@ if __name__ == "__main__": info = client.query_info() if info: d = info.get("data", {}) - print(f" Drucker: {d.get('printerName')} FW {d.get('version')}") + print(f" Printer: {d.get('printerName')} FW {d.get('version')}") print(f" Status: {d.get('state')}") t = d.get("temp", {}) print(f" Nozzle: {t.get('curr_nozzle_temp')}°C → {t.get('target_nozzle_temp')}°C") @@ -764,6 +764,6 @@ if __name__ == "__main__": print(f" Upload: {urls.get('fileUploadurl')}") print(f" Kamera: {urls.get('rtspUrl')}") else: - print(" Keine Antwort") + print(" No response") client.disconnect() diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index b708f7d..5e10af3 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1,7 +1,7 @@ """ -kobrax_moonraker_bridge.py – Moonraker-kompatibler HTTP/WebSocket-Bridge für Anycubic Kobra X +kobrax_moonraker_bridge.py - Moonraker-compatible HTTP/WebSocket bridge for the Anycubic Kobra X -Emuliert die Moonraker/Klipper-API damit OrcaSlicer den Kobra X direkt ansteuern kann. +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 @@ -48,10 +48,10 @@ import threading import html from urllib.parse import quote -# Bei PyInstaller-Binary liegt alles neben sys.executable, sonst neben __file__ +# 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) werden im Onefile-Binary via --add-data unter +# 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 @@ -73,7 +73,7 @@ try: from aiohttp import web import aiohttp except ImportError: - print("Fehler: aiohttp nicht installiert. Bitte: pip install aiohttp") + print("Error: aiohttp is not installed. Run: pip install aiohttp") sys.exit(1) try: @@ -97,13 +97,13 @@ def _kx_decrypt_info(encrypted_b64: str, key: str, iv: str) -> dict: async def _kx_fetch_credentials(ip: str, port: int = 18910) -> dict: - """Holt + entschlüsselt Drucker-Credentials via HTTP /info + /ctrl. + """Fetches + decrypts printer credentials via HTTP /info + /ctrl. - Wirft eine Exception bei Netzwerk-/Decrypt-Fehlern. Algorithmus aus + 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 nicht installiert") + 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) @@ -134,29 +134,42 @@ 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) -# Web-UI: Unterverzeichnis unter web/themes//index.html + +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//index.html _UI_THEME_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$") -# Erlaubte statische Theme-Dateien unter /kx/ui/ +# Allowed static theme files under /kx/ui/ _KX_UI_ASSETS: dict[str, str] = { "style.css": "text/css", "app.js": "application/javascript", } +# Files from lib/ are served based on their extension (no whitelist entry needed) +_KX_UI_LIB_TYPES: dict[str, str] = { + ".js": "application/javascript", + ".css": "text/css", +} _KX_UI_TRANSLATION_RE = re.compile(r"^translations/([a-z]{2}(?:-[a-z]{2})?)\.json$") -# Ring-Buffer für Browser-Log-Stream (letzte 200 Einträge) +# 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): - """Sendet Log-Records in den Ring-Buffer und alle offenen SSE-Queues.""" + """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() - # Exceptions mit Traceback in den Browser durchreichen (sonst sieht der - # Nutzer nur "Fehler: X" ohne Kontext). + # 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) @@ -203,12 +216,12 @@ KLIPPER_VERSION = "v0.12.0-1" def _parse_gcode_estimated_time(data: bytes) -> int: - """Liest geschätzte Druckzeit aus GCode (OrcaSlicer + PrusaSlicer). - Gibt Sekunden zurück, 0 wenn nicht gefunden. - PrusaSlicer schreibt die Zeit ins Header (erste 16KB), - OrcaSlicer schreibt sie ans Ende der Datei (letzte 16KB).""" + """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 - # Anfang + Ende der Datei durchsuchen (OrcaSlicer schreibt Zeit am Ende) + # 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 @@ -228,14 +241,14 @@ def _parse_gcode_estimated_time(data: bytes) -> int: def _parse_gcode_layer_heights(data: bytes) -> tuple[float, float]: - """Liest (layer_height, initial_layer_height) aus dem OrcaSlicer-/PrusaSlicer- - GCode-Header. Beide sind als Konfigblock am Ende des GCode hinterlegt. + """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 - Liefert (0.0, 0.0) wenn nicht gefunden — Aufrufer entscheidet was er macht + 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") @@ -257,7 +270,7 @@ def _parse_gcode_layer_heights(data: bytes) -> tuple[float, float]: def _extract_thumbnail(data: bytes) -> str: - """Extrahiert Base64-PNG-Thumbnail aus GCode (OrcaSlicer-Format).""" + """Extracts the base64 PNG thumbnail from GCode (OrcaSlicer format).""" try: marker = b"; thumbnail begin" end_marker = b"; thumbnail end" @@ -279,12 +292,12 @@ def _extract_thumbnail(data: bytes) -> str: def _extract_filament_info(data: bytes) -> list[dict]: - """Liest Filament-Farben/Materialien inkl. Tool-Reihenfolge aus Orca/Prusa-GCode. + """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, ...) zurück. - Sucht sowohl am Anfang als auch am Ende der Datei, da Orca große - Thumbnail-Blöcke einfügen kann und Metadaten dann im Tail stehen. + (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] @@ -409,14 +422,14 @@ class GCodeStore: abort_reason TEXT ); """) - # Migration: Spalte gcode_filaments nachrüsten falls DB älter + # 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: Spalten objects_skip_parts + svg_image (Part-Skip-Feature, v0.9.10) - # Plus layer_height / first_layer_height (Obico Z-Höhe, v0.9.18) + # 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"), @@ -428,7 +441,7 @@ class GCodeStore: self._conn.commit() except Exception: pass - # Migration: Flag für Web-Uploads (Warnhinweis vor Druck) + # 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() @@ -441,7 +454,7 @@ class GCodeStore: web_unverified: bool = False, layer_height: float = 0.0, first_layer_height: float = 0.0) -> str: - """Speichert GCode-Datei auf Disk und in DB. Gibt Pfad zurück.""" + """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: @@ -481,7 +494,7 @@ class GCodeStore: return dict(row) if row else None def update_file_objects(self, filename: str, objects: list, svg: str = "") -> None: - """Speichert Objekt-Liste + optionales SVG zu einer Datei (matcht via filename).""" + """Saves the object list + optional SVG for a file (matched via filename).""" if not filename: return with self._lock: @@ -493,7 +506,7 @@ class GCodeStore: self._conn.commit() def update_file_filaments(self, file_id: str, gcode_filaments: list | None) -> None: - """Aktualisiert geparste GCode-Filamente für einen bestehenden DB-Eintrag.""" + """Updates parsed GCode filaments for an existing DB entry.""" with self._lock: self._conn.execute( "UPDATE gcode_files SET gcode_filaments=? WHERE id=?", @@ -575,17 +588,17 @@ class GCodeStore: class CameraCache: """Zentraler Kamera-Demuxer. - Hält EINEN ffmpeg-Prozess offen, der den FLV-Stream vom Drucker liest - und parallel zwei Outputs erzeugt: - - MJPEG @ 2fps → letzter Frame im RAM für /api/camera/snapshot - - MPEG-TS (-c:v copy) → Fanout an alle /api/camera/h264-Subscriber + Keeps ONE ffmpeg process open that reads the FLV stream from the printer + and produces two outputs in parallel: + - MJPEG @ 2fps -> last frame in RAM for /api/camera/snapshot + - MPEG-TS (-c:v copy) -> fanout to all /api/camera/h264 subscribers Damit: - * Nur EINE FLV-Verbindung zum Drucker (löst Single-Client-Limit / 429) - * Snapshot ist instant (Speicher-Read, kein ffmpeg-Spawn pro Request) - * Mehrere parallele H.264-Konsumenten möglich (Plugin + Web-UI + …) + * Only ONE FLV connection to the printer (solves the single-client limit / 429) + * Snapshots are instant (memory read, no ffmpeg spawn per request) + * Multiple parallel H.264 consumers possible (plugin + web UI + ...) - Lazy-Start beim ersten Konsumenten, Auto-Restart bei ffmpeg-Crash. + Lazy start on the first consumer, auto-restart on ffmpeg crash. """ JPEG_SOI = b"\xff\xd8" @@ -609,7 +622,7 @@ class CameraCache: self._url = url def reset(self): - """Backoff-Zähler zurücksetzen und laufende ffmpeg-Prozesse killen.""" + """Reset backoff counters and kill running ffmpeg processes.""" self._fail_count_jpeg = 0 self._fail_count_h264 = 0 for proc in (self._proc_jpeg, self._proc_h264): @@ -634,7 +647,7 @@ class CameraCache: return args async def _run_jpeg_loop(self): - """Hält einen ffmpeg-Prozess am Leben der MJPEG@2fps in den Cache schreibt.""" + """Keeps an ffmpeg process alive that writes MJPEG@2fps into the cache.""" while True: url = self._url if not url: @@ -651,7 +664,7 @@ class CameraCache: stderr=asyncio.subprocess.PIPE, ) except Exception as e: - log.warning(f"CameraCache: ffmpeg-jpeg start fehlgeschlagen: {e}") + log.warning(f"CameraCache: ffmpeg-jpeg start failed: {e}") await asyncio.sleep(3.0) continue @@ -679,8 +692,8 @@ class CameraCache: except Exception as e: log.debug(f"CameraCache: jpeg-loop unterbrochen: {e}") finally: - # Kill + Wait — sonst bleibt der Child-Prozess als Zombie und - # asyncio meldet "Unknown child pid …" beim nächsten reaper-Tick. + # 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() @@ -709,7 +722,7 @@ class CameraCache: await asyncio.sleep(2.0) async def _run_h264_loop(self): - """Hält einen ffmpeg-Prozess am Leben der MPEG-TS an alle Subscriber fanoutet.""" + """Keeps an ffmpeg process alive that fans out MPEG-TS to all subscribers.""" while True: url = self._url if not url: @@ -725,7 +738,7 @@ class CameraCache: stderr=asyncio.subprocess.PIPE, ) except Exception as e: - log.warning(f"CameraCache: ffmpeg-h264 start fehlgeschlagen: {e}") + log.warning(f"CameraCache: ffmpeg-h264 start failed: {e}") await asyncio.sleep(3.0) continue @@ -735,8 +748,8 @@ class CameraCache: chunk = await self._proc_h264.stdout.read(self.TS_CHUNK) if not chunk: break - # Fanout: nicht-blockierend pro Subscriber, langsame Clients - # bekommen ihren ältesten Chunk verworfen (Queue voll → drop). + # 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: @@ -819,23 +832,23 @@ class KobraXBridge: 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 für Moonraker /server/database/item (moonraker-obico, - # mainsail-presets etc.). Nicht persistent — überlebt keinen Restart. + # 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 (aus config.ini [filament_profiles]). + # Slot -> Orca filament profile mapping (from config.ini [filament_profiles]). # Format: {slot_idx: {"id": "OGFL01", "vendor": "Polymaker"}}. - # Wird in _build_lane_data verwendet, damit OrcaSlicer die konkrete - # Marke ("PolyTerra PLA — Polymaker") statt nur "Generic PLA" anzeigt. + # 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._filament_profiles: dict[int, dict] = _cl.list_filament_profiles(self._printer_id) except Exception: self._filament_profiles = {} - # Vendor-Sichtbarkeitsfilter fürs Slot-Profil-Dropdown (Issue #41 Option A). - # Leere Liste = alle Vendoren sichtbar (rückwärtskompatibel). + # 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._visible_vendors: list[str] = _cl.list_visible_vendors(self._printer_id) except Exception: self._visible_vendors = [] self._last_state: dict = {} @@ -853,10 +866,10 @@ class KobraXBridge: "remain_time": 0, "curr_layer": 0, "total_layers": 0, - # Layer-Heights pro aktuell laufender Datei (aus dem GCode-Header - # geparst). Wird im Upload-Pfad + beim _fetch_from_store gesetzt. - # Obico nutzt currentZ aus gcode_position[2] — die Bridge rechnet - # currentZ aus curr_layer + diesen Werten in build_print_payload. + # 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"), @@ -888,7 +901,7 @@ class KobraXBridge: 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 hat Kamera während Druck manuell gestoppt + 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 = "" @@ -915,7 +928,7 @@ class KobraXBridge: self._thumbnail_b64: str = "" self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config() - # Part-Skip: zuletzt vom Drucker gemeldete Skip-Liste (v0.9.10) + # 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] = [] @@ -927,13 +940,22 @@ class KobraXBridge: SpoolmanClient(_sm_url, getattr(args, "spoolman_sync_rate", 0)) if _sm_url else None ) - self._spoolman_slot_spools: dict[int, int] = {} # {ams_slot_idx: spoolman_spool_id} + # 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 - # Theme-Name prüfen (keine Sonderzeichen oder Umlaute) + # 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) @@ -951,14 +973,18 @@ class KobraXBridge: 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: - threading.Thread( - target=lambda: log.info( - f"Spoolman: {'OK' if self._spoolman.health_check() else 'unreachable'} " - f"at {self._spoolman.server_url}" - ), - daemon=True, name="spoolman-health", - ).start() + 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 ────────────────────────────────────────────────────── @@ -1049,6 +1075,7 @@ class KobraXBridge: """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()}, @@ -1074,11 +1101,19 @@ class KobraXBridge: data = await request.json() except Exception: return self._json_cors({"error": "invalid JSON"}, status=400) - slot_map = data.get("slot_map") or {} + 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_] 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 @@ -1339,8 +1374,8 @@ class KobraXBridge: if kobra_state: self._state["kobra_state"] = kobra_state - # Kamera bei Druckstart automatisch einschalten (Settings-Option). - # Zentral hier, damit es alle Druck-Startwege abdeckt (OrcaSlicer + UI). + # 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) @@ -1349,12 +1384,12 @@ class KobraXBridge: self._camera_autostarted = True try: self.client.start_camera() - log.info("Kamera bei Druckstart automatisch eingeschaltet") + log.info("Camera switched on automatically at print start") except Exception as e: - log.warning(f"Kamera-Autostart fehlgeschlagen: {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 # für nächsten Druck freigeben + self._camera_user_stopped = False # release for the next print # Job-History: Druckstart erkennen if kobra_state == "printing" and not self._current_job_id: @@ -1406,9 +1441,9 @@ class KobraXBridge: self._notify("paused", _notif_filename) self._prev_kobra_state = kobra_state - # Nach Druckende das Upload-Banner verschwinden lassen (Issue #29): der - # Drucker meldet "finished" nach erfolgreichem Druck — file_ready wurde - # bisher nur bei stoped/canceled geleert, dadurch kam das Banner zurück. + # Hide the upload banner after the print finishes (Issue #29): the + # printer reports "finished" after a successful print - file_ready used to be + # cleared only on stoped/canceled, which brought the banner back. if kobra_state == "finished": self._state["file_ready"] = "" if kobra_state in ("stoped", "canceled"): @@ -1444,25 +1479,25 @@ class KobraXBridge: def _on_info(self, payload: dict): d = payload.get("data") or {} - # MQTT-Name nur übernehmen wenn kein eigener Name gesetzt (env oder per-Drucker config) + # 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"]) - # Der echte Druck-State steckt bei info/report im verschachtelten - # project.state ("printing"/"paused"/…). Das oberste data.state ist nur - # der Geräte-State ("busy"/"free") und würde "paused" verschlucken. + # 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 - # Upload-Banner nach Druckende ausblenden (Issue #29) – der State kommt - # je nach Drucker auch über info/report (project.state), nicht nur print/report. + # 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. if kobra_state in ("finished", "stoped", "canceled"): self._state["file_ready"] = "" - # Kamera-Autostart auch hier (OrcaSlicer meldet Start oft via info/report). - # _camera_autostarted-Guard verhindert Doppel-Start mit _on_print. + # 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 @@ -1470,12 +1505,12 @@ class KobraXBridge: self._camera_autostarted = True try: self.client.start_camera() - log.info("Kamera bei Druckstart automatisch eingeschaltet") + log.info("Camera switched on automatically at print start") except Exception as e: - log.warning(f"Kamera-Autostart fehlgeschlagen: {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 # für nächsten Druck freigeben + self._camera_user_stopped = False # release for the next print self._print_active = False if project: if "filename" in project: @@ -1513,22 +1548,22 @@ class KobraXBridge: def _on_skip(self, payload: dict): """skip/report-Callback (Part-Skip-Feature, v0.9.10). - Drucker meldet hier IMMER die Liste der bereits geskippten Objekte - zurück (objects_skip_parts), egal ob auf query_obj oder nach skip/start. - Die Gesamt-Objektliste kommt aus file/report. + 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 [] - # Während ein Pre-Print-Skip noch pending ist, leere Früh-Reports ignorieren - # damit die UI nicht sofort zurückspringt bevor der Drucker den Skip bestätigt. + # 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 - # Während eines aktiven Drucks sind Skip-Zustände effektiv monoton. - # Manche Firmware-Reports kommen zwischenzeitlich leer/teilweise zurück; - # diese dürfen bereits bestätigte Skip-Objekte nicht aus der UI löschen. + # 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] @@ -1544,7 +1579,7 @@ class KobraXBridge: merged.append(n) skipped = merged - # Pending-Lock aufheben sobald Drucker die gewünschten Objekte bestätigt + # 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 @@ -1553,7 +1588,7 @@ class KobraXBridge: "ts": int(time.time()), } if payload.get("state") == "done" or payload.get("code") == 200: - log.info(f"Skip-Antwort: state={payload.get('state')} code={payload.get('code')} skipped={skipped}") + log.info(f"Skip response: state={payload.get('state')} code={payload.get('code')} skipped={skipped}") def _on_file(self, payload: dict): d = payload.get("data") or {} @@ -1562,11 +1597,11 @@ class KobraXBridge: 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 während eines laufenden Drucks dürfen die aktive - # Fortschritts-Vorschau nicht überschreiben. + # 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"Vorschaubild empfangen: {len(thumb)} Zeichen base64") + 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 "" @@ -1577,13 +1612,13 @@ class KobraXBridge: 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 fehlgeschlagen: {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): - """Sendet Skip-Befehl erst nachdem Drucker in printing-State gewechselt hat. + """Sends the skip command only after the printer switched to the printing state. - Vorher sendet der Drucker den Befehl ins Leere (kein aktiver Druck). + 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: @@ -1759,7 +1794,7 @@ class KobraXBridge: return selected if warn_on_empty_default: - log.warning(f"Standard-Slot {slot_idx} ist leer – fallback auf Auto") + log.warning(f"Default slot {slot_idx} is empty - falling back to auto") return all_loaded @staticmethod @@ -1778,16 +1813,32 @@ class KobraXBridge: loaded = loaded_slots if loaded is None: loaded = self._select_loaded_slots_for_print(warn_on_empty_default=warn_on_empty_default) - return [ - { - "paint_index": pidx, - "ams_index": self._slot_to_print_ams_index(gidx), - "paint_color": [255, 255, 255, 255], - "ams_color": self._slot_color_rgba(s), - "material_type": s.get("type", "PLA"), - } - for pidx, (gidx, s) in enumerate(loaded) - ] + 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. + 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": self._slot_to_print_ams_index(i), + "paint_color": [255, 255, 255, 255], + "ams_color": [255, 255, 255, 255], + "material_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. @@ -1900,8 +1951,8 @@ class KobraXBridge: for s in global_slots: s["activity"] = activity_map.get(s.get("global_index"), "") - # Tip-Forming: nach Einziehen (status=10) oder Ausziehen (status=11) - # schickt der originale Slicer automatisch type=3 (Extruder-Rückzug). + # 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 {} @@ -1916,12 +1967,12 @@ class KobraXBridge: {"multi_color_box": [{"id": bi, "feed_status": {"slot_index": si, "type": 3}}]}, timeout=0 ) - log.info(f"Tip-Forming (type=3) nach status={cs} box={bi} slot={si}") + 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 empfangen: {len(global_slots)}, loaded_slot={self._ams_loaded_slot}") + 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): @@ -2021,39 +2072,120 @@ class KobraXBridge: self._push_status_update() # OrcaSlicer filament preset IDs (MoonrakerPrinterAgent.cpp mapping) - # Default-Mapping pro Material-Typ wenn der User keinen Slot-Profil- - # Override gesetzt hat. Für den Kobra X bevorzugen wir Anycubic-eigene - # Filament-IDs aus den `@Anycubic Kobra X 0.4 nozzle`-Profilen — die - # sind druckerspezifisch is_compatible und werden von OrcaSlicer direkt - # gematched. Library-Fallbacks (OGF*) nur für Material-Typen ohne - # Kobra-X-spezifisches Anycubic-Profil — deren @System-Profile haben - # `compatible_printers: []` (= mit allen Druckern kompatibel). + # 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", - "PETG": "GFPETG", - "ABS": "GFABS", - "ASA": "GFASA", - "TPU": "GFTPU 95A", - "PVA": "GFPVA", + "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", - "PETG-CF": "OGFG98", - "PA": "OGFN99", - "PA-CF": "OGFN98", - "PC": "OGFC99", - "HIPS": "OGFS98", + "PLA-CF": "OGFL98", + "PLA CF": "OGFL98", + "PETG-CF": "OGFG98", + "PETG CF": "OGFG98", + "PA": "OGFN99", + "PA-CF": "OGFN98", + "PA CF": "OGFN98", + "PC": "OGFC99", + "HIPS": "OGFS98", } - def _build_lane_data(self) -> dict: - """Baut BBL-AMS-JSON für OrcaSlicer DevFilaSystemParser::ParseV1_0. + # 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 - POSITIONSTREU: jeder physische Slot behält seine Position (tray id = - Slot-Position). Leere Slots werden als Platzhalter-Tray gemeldet, NICHT - weggefiltert/komprimiert — sonst rutschen die Farben auf falsche Positionen - (z.B. Slot 1=gelb, 2=leer, 3=rot → rot dürfte nicht auf Position 2 landen). + @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 _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) @@ -2085,26 +2217,29 @@ class KobraXBridge: color_hex = color_raw[:6].upper() + "FF" else: color_hex = "FFFFFFFF" - material = slot.get("type", "PLA").upper() - # User-Override aus config.ini [filament_profiles].slot_N_id - # bekommt Vorrang vor dem Default-Mapping nach material-Type. - # Vendor wird mitgesendet (tray_sub_brands + filament_vendor), - # damit ein gepatchter OrcaSlicer den Match nach Marke + Type + - # Farbe machen kann (analog SnapmakerPrinterAgent). - # Zwei-Schicht-Resolution für den Filament-Hint an OrcaSlicer: + 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) pro Material-Typ — kein - # Vendor-Hint, OrcaSlicer trifft dann sein eigenes Generic-Preset - user_profile = self._filament_profiles.get(slot_index) or {} + # 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 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) — - # ist mit allen Druckern kompatibel und garantiert sichtbar. - # Der User wählt pro Slot bewusst eine konkrete Marke wenn er - # eine will; Default bleibt neutral. + # 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") @@ -2117,14 +2252,14 @@ class KobraXBridge: "tray_sub_brands": vendor, # OrcaSlicer-Empfangs-Patch PR #13719 erwartet `name` + # `vendor_name` pro Lane (Stufen-Matching: Vendor+Name → Name → - # filament_id_by_type). Wir senden beide Schreibweisen mit - # damit ältere Patch-Varianten + zukünftige Upstream-PRs beide - # bedient sind. + # 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, - # Aliase für ältere Patch-Varianten (Variante 2, + # Aliases for older patch variants (variant 2, # MoonrakerPrinterAgent.cpp): filament_id direkt (exakt), - # sonst preset-Name per find_preset() auflösen. + # otherwise resolve the preset name via find_preset(). "filament_id": tray_info_idx, "filament_vendor": vendor, "filament_name": fila_name, @@ -2153,9 +2288,9 @@ class KobraXBridge: """OrcaSlicer-Default-Filename-Pattern: `___.gcode` z.B. `adapter_e27_plate(01)_PLA_0.2_41m1s.gcode` → 0.2. - Fallback wenn der GCode-Header nicht geparst wurde (z.B. Datei direkt am - Slicer gestartet, oder vor v0.9.18 hochgeladen). Liefert 0.0 wenn das - Pattern nicht greift.""" + 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 @@ -2168,18 +2303,18 @@ class KobraXBridge: return 0.0 def _estimate_current_z(self) -> float: - """Schätzt die aktuelle Z-Höhe aus curr_layer + Layer-Heights. + """Estimates the current Z height from curr_layer + layer heights. - Der Drucker liefert keine echte Z-Position via MQTT, aber Obico - (moonraker-obico/printer.py:267) liest currentZ aus `gcode_position[2]`. - Wir rechnen das mit der layer_height aus dem GCode-Header zurück: + 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 - Werte werden im Upload-Pfad gesetzt und nur bei Druckabbruch/-ende - zurückgesetzt (Slot-/Farbänderungen ändern nichts daran). Falls die - Werte fehlen (z.B. weil der Druck direkt am Slicer gestartet wurde - ohne Upload über die Bridge), wird einmalig aus dem GCode-Store - nachgeladen. Liefert 0.0 wenn nichts bekannt — Obico zeigt dann + 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) @@ -2194,12 +2329,12 @@ class KobraXBridge: except Exception: pass if not layer_h and fname: - # Letzter Fallback: OrcaSlicer-Default-Filename enthält die Layer-Height + # 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 damit nicht jeder Build wieder den Store fragt + # 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: @@ -2214,11 +2349,11 @@ class KobraXBridge: # WebSocket push # ------------------------------------------------------------------------- - # Statische Objekte, die sich zur Laufzeit nie ändern. Sie werden einmalig - # per objects.query/subscribe ausgeliefert, aber NICHT in jedem - # notify_status_update mitgeschickt — sonst läuft Mobilerakers - # ConfigFile.parse (teuer + strikt) bei jedem Status-Tick erneut und die App - # hängt/crasht beim Refresh (Issue #48). + # 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): @@ -2242,9 +2377,9 @@ class KobraXBridge: def _build_mmu_object(self) -> dict: # POSITIONSTREU: ein Gate je physischem Slot, in Reihenfolge. Leere Slots - # bekommen gate_status=0 (statt weggelassen zu werden) – sonst rutschen die + # get gate_status=0 (instead of being omitted) - otherwise the # Farben in OrcaSlicer auf falsche Gates (Slot 1=gelb, 2=leer, 3=rot → - # rot darf nicht auf Gate 1 landen). gate_status 0=leer, 1=verfügbar. + # 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], @@ -2257,28 +2392,34 @@ class KobraXBridge: 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 = (slot.get("type") or "PLA").upper() if occupied else "" + 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 erwartet gate_color als RRGGBB OHNE '#' (Klipper-Limitation). + # 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 aus User-Override oder Material-Default für den + # gate_filament_name from user override or material default for the # HH-Pfad in OrcaSlicer (fetch_hh_filament_info). Wenn Orca den - # HH-Pfad wählt (MMU-Erkennung), wertet PR #13719 dieses Feld als - # Preset-Namen aus → 'Anycubic PLA' matched das druckerspezifische - # Preset, leerer String führte vorher auf Generic PLA. + # 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: - user_profile = self._filament_profiles.get(_global_index) or {} + # 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) @@ -2291,24 +2432,38 @@ class KobraXBridge: "gate_temperature": gate_temperature, "gate_color_rgb": gate_color_rgb, "gate_filament_name": gate_filament_name, - "gate_spool_id": [-1] * num_gates, + "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 für `gate_filament_name`/`name` in lane_data wenn kein - User-Override gesetzt ist. Bewusste Designentscheidung: **immer - Generic ** als Default — das Library-Profil ist `compatible_printers:[]` - (= mit jedem Drucker kompatibel) und damit garantiert sichtbar. + """Default name for `gate_filament_name`/`name` in lane_data when no + user override is set. Deliberate design decision: **always + Generic ** as the default - the library profile is `compatible_printers:[]` + (= compatible with every printer) and therefore guaranteed to be visible. - OrcaSlicer matcht dann das neutrale Generic-Preset und der User - kann pro Slot eine konkrete Marke setzen wenn er das will.""" + 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 = material.upper().strip() + 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 + " ") @@ -2316,8 +2471,8 @@ class KobraXBridge: for p in profs: if p.get("vendor") == "Generic" and p.get("name", "").startswith("Generic ") and _match_type(p): return p.get("name", "") - # Falls die Library-Generic für diesen exotischen Material-Typ fehlt, - # liefern wir nichts — OrcaSlicer fällt auf filament_id_by_type zurück. + # 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: @@ -2353,7 +2508,7 @@ class KobraXBridge: "is_active": s["print_state"] == "printing", "file_path": s["filename"], # file_position approximiert: fraction × est_total_size. - # Genauer Wert kommt aus dem Drucker nicht, Obico nutzt es nur als Anzeige. + # 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": { @@ -2363,7 +2518,7 @@ class KobraXBridge: "estimated_print_time": s["print_duration"], }, "mmu": self._build_mmu_object(), - # ── Moonraker-Kompatibilität für moonraker-obico ── + # -- Moonraker compatibility for moonraker-obico -- "heaters": { "available_heaters": ["extruder", "heater_bed"], "available_sensors": [], @@ -2374,9 +2529,9 @@ class KobraXBridge: "state_message": "Printer is ready", }, # speed_factor: 1=silent(0.5) / 2=standard(1.0) / 3=high(1.3) / 4=ultra(1.5) - # Aktuelle Z-Höhe für Obico aus curr_layer + Layer-Heights schätzen - # (Drucker liefert keine echte Z-Position per MQTT). gcode_position[2] - # ist der Wert den moonraker-obico in printer.py als currentZ liest. + # 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, @@ -2387,12 +2542,12 @@ class KobraXBridge: "homing_origin": [0, 0, 0, 0], "position": [0, 0, self._estimate_current_z(), 0], }, - # motion_report: Mobileraker liest die Live-Geschwindigkeit hier - # (live_velocity). Der Kobra-X-MQTT liefert KEINE echte mm/s, nur - # einen print_speed_mode (1-4). live_velocity bleibt daher 0 — das - # Objekt muss aber existieren, sonst zeigt Mobileraker nichts an - # (motion_report war zuvor null). live_position spiegelt die - # geschätzte Z-Höhe (wie gcode_move). + # 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, @@ -2402,8 +2557,8 @@ class KobraXBridge: "speed": (int(s.get("fan_speed") or 0)) / 100.0, "rpm": None, }, - # history (object): Obico abonniert es als Objekt; das eigentliche - # /server/history/list-Endpoint liefert die echte Liste separat. + # 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, @@ -2415,14 +2570,14 @@ class KobraXBridge: }, "current_job": None, }, - # Pseudo-Klipper-Macros für moonraker-obico: - # - _OBICO_LAYER_CHANGE meldet die aktuelle Layer-Nr. Obico nutzt das - # für "first layer scan"-Trigger und layer-aligned Time-Lapse-Frames. - # Wir bedienen das aus dem MQTT-Stream (s["curr_layer"]). - # - TIMELAPSE_TAKE_FRAME signalisiert, dass die aktuelle Pause vom - # Time-Lapse stammt (sonst würde Obico die Pause als User-Pause - # interpretieren). Wir setzen is_paused=False, weil unsere Pausen - # nie Time-Lapse-Pausen sind. + # 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, @@ -2431,23 +2586,23 @@ class KobraXBridge: "gcode_macro TIMELAPSE_TAKE_FRAME": { "is_paused": False, }, - # configfile stub — Mobileraker und andere Clients crashen ohne - # dieses Objekt (Missing field: configFile). Werte aus der - # entschlüsselten avata_main.conf (ACCFG1.0 — Kobra X Firmware). - # Mobileraker (Issue #48) parst BEIDE Zweige config + settings durch + # 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:{} ließ den nicht-nullbaren Dart-Parser crashen. Daher wird + # config:{} crashed the non-nullable Dart parser. Therefore # config identisch zu settings gespiegelt. "configfile": self._klipper_configfile_stub(), } def _klipper_configfile_stub(self) -> dict: - """Minimaler Klipper-configfile-Stub für Mobileraker/OctoApp (Issue #48). + """Minimal Klipper configfile stub for Mobileraker/OctoApp (Issue #48). - Mobileraker parst BEIDE Zweige `config` und `settings` durch denselben + Mobileraker parses BOTH branches `config` and `settings` through the same ConfigFile.parse → ConfigExtruder.fromJson. Ein leeres `config: {}` - ließ den nicht-nullbaren Dart-Parser crashen, daher wird `config` - identisch zu `settings` gespiegelt. Werte aus der entschlüsselten + 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 = { @@ -2467,8 +2622,8 @@ class KobraXBridge: "max_temp": 320, "min_extrude_temp": 10, # Mobileraker ConfigExtruder erwartet diese Felder non-nullable - # (max_extrude_only_distance, max_power) bzw. als Key präsent - # (max_extrude_only_velocity/accel dürfen null sein). Fehlen = + # (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, @@ -2477,16 +2632,16 @@ class KobraXBridge: }, "heater_bed": { # Mobileraker ConfigHeaterBed: heater_pin, sensor_type, control - # sind non-nullable. Werte sind Platzhalter (Bridge kennt die - # echten Pins nicht — Anycubic-Firmware, kein Klipper-printer.cfg). + # 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, }, - # stepper_* mit non-nullable Pflichtfeldern (step_pin, dir_pin, - # rotation_distance) füllen, sonst crasht ConfigStepper.fromJson. + # 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, @@ -2497,8 +2652,8 @@ class KobraXBridge: "pause_resume": {}, "display_status": {}, } - # config + settings müssen dieselben Felder enthalten — Mobileraker - # parst beide. deepcopy, damit kein Client durch geteilte Referenz + # 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), @@ -2526,8 +2681,8 @@ class KobraXBridge: async def handle_kx_files(self, request): files = self._store.list_files() - # Legacy-Einträge ohne gespeicherte Filament-Metadaten nachziehen, - # damit Dialog links die GCode-Farben statt AMS-Slots zeigt. + # 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: @@ -2548,9 +2703,9 @@ class KobraXBridge: if parsed_filaments: f["gcode_filaments"] = json.dumps(parsed_filaments) self._store.update_file_filaments(f["id"], parsed_filaments) - except Exception: - pass - # Letzten Job-Status + Dauer pro Datei ergänzen + 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): @@ -2578,8 +2733,8 @@ class KobraXBridge: 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* mit URL-encoding für Sonderzeichen/UTF-8, - # plus ASCII-fallback (alle " und \ aus filename strippen für den + # 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="") @@ -2596,15 +2751,17 @@ class KobraXBridge: slots = [] for i, s in enumerate(self._ams_slots): gidx = int(s.get("global_index", i)) - profile = self._filament_profiles.get(gidx) or {} + # 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, - # Aktueller User-Override aus config.ini [filament_profiles] - # — (vendor,name) ist eindeutig, id ist nur Hint. + # 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", ""), @@ -2612,12 +2769,12 @@ class KobraXBridge: return self._json_cors({"result": slots}) async def handle_kx_filament_profiles(self, request): - """Liefert die statische Liste der OrcaSlicer-Filament-Profile - (aus bridge/data/orca_filaments.json — vom Generator-Script + """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. - Frontend nutzt das für die Slot-Profile-Dropdown. + 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() @@ -2629,8 +2786,8 @@ class KobraXBridge: return self._json_cors({"result": profiles}) async def handle_kx_filament_profiles_user_list(self, request): - """GET /kx/filament/profiles/user — nur die User-importierten Profile, - für den Settings-Tab (Verwaltung mit Lösch-Buttons).""" + """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": []}) @@ -2642,21 +2799,21 @@ class KobraXBridge: return self._json_cors({"result": user_profiles}) async def handle_kx_filament_profiles_import(self, request): - """POST /kx/filament/profiles/user — multipart-Upload mit einer - ZIP-Datei oder mehreren `.json`-Files aus + """POST /kx/filament/profiles/user - multipart upload with one + ZIP file or multiple `.json` files from ~/.config/OrcaSlicer/user//filament/. - Bestehende User-Profile mit gleichem (vendor, name)-Key werden - überschrieben. Geparste Profile haben dasselbe Schema wie + 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 für Inherits-Resolve: User-Profile referenzieren + # System index for inherits resolution: user profiles reference # System-Parents via "inherits" (z.B. "Generic PLA @System"). Damit - # können wir filament_id/vendor/type/color aus dem System-Parent - # ziehen wenn das User-Profil sie selbst nicht setzt. + # 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() @@ -2695,7 +2852,7 @@ class KobraXBridge: if not added: return self._json_cors({"result": "ok", "added": 0, "skipped": skipped}) - # Merge mit existierender User-JSON (gleicher (vendor,name) → ersetzen) + # Merge with existing user JSON (same (vendor,name) -> replace) path = self._orca_filaments_user_path() existing: list[dict] = [] if os.path.isfile(path): @@ -2721,8 +2878,8 @@ class KobraXBridge: "total_user": len(merged)}) async def handle_kx_filament_profiles_user_delete(self, request): - """DELETE /kx/filament/profiles/user — löscht entweder einen einzelnen - Eintrag (?vendor=…&name=…) oder alle wenn keine Query angegeben.""" + """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() @@ -2751,17 +2908,17 @@ class KobraXBridge: "total_user": len(existing)}) def _find_orca_filaments_json(self) -> str | None: - """Findet die statische JSON-Datei. Liegt analog zu web/ unter _WEB_BASE/data/ + """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 (statisch im Image, NICHT das - Volume-data/ das Runtime-State enthält — siehe Dockerfile) + * 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 - Wenn das Volume-mounted /app/data/ den static-data überdeckt, liegt eine - Kopie auch unter _WEB_BASE/data/ (= /app/ im Docker = derselbe Pfad). - Bei Konflikt: zweite Suche unter ../bridge/data/ als Fallback für Dev-Setups.""" + 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/ ist Volume → überdeckt) + # 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"), ] @@ -2774,18 +2931,18 @@ class KobraXBridge: return None async def handle_kx_filament_slot_profile(self, request): - """POST /kx/filament/slots//profile — speichert oder löscht - ein User-Override-Mapping für einen einzelnen AMS-Slot. + """POST /kx/filament/slots//profile - saves or deletes + a user override mapping for a single AMS slot. - Primärer Selector ist (vendor, name) — die ID ist im Orca-Datenmodell - nicht eindeutig (136 Profile teilen sich z.B. 'OGFL99'). Die ID wird - aus orca_filaments.json beim Speichern nachgeschlagen und als Hint - mitgeführt für OrcaSlicer's `tray_info_idx`. + 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-Kompat: {"id":..., "vendor":...} wird akzeptiert, - aber `name` ist seit v0.9.18 der primäre Selector.) + (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")) @@ -2801,8 +2958,8 @@ class KobraXBridge: new_name = (data.get("name") or "").strip() new_id = (data.get("id") or "").strip() # Backwards-Kompat-Hint if new_vendor and new_name: - # ID aus JSON lookup'en (nicht aus dem Request-Body, der könnte - # veraltet sein oder ein Generic-Fallback). + # 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, @@ -2814,7 +2971,7 @@ class KobraXBridge: # Persistieren in config.ini try: import config_loader as _cl - _cl.save_filament_profiles(self._filament_profiles) + _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) @@ -2827,12 +2984,12 @@ class KobraXBridge: async def handle_kx_visible_vendors(self, request): """GET/POST /kx/filament/visible_vendors — Vendor-Sichtbarkeitsfilter - fürs Slot-Profil-Dropdown (Issue #41 Option A). + for the slot profile dropdown (Issue #41 option A). GET → {"result": ["Polymaker", "eSUN", ...]} POST {"vendors": [...]} → speichert in config.ini [filament_profiles] - visible_vendors. Leere Liste = alle sichtbar. KEIN Bridge-Neustart - nötig (nur Anzeigefilter).""" + visible_vendors. Empty list = all visible. NO bridge restart + needed (display filter only).""" if request.method == "POST": try: data = await request.json() @@ -2844,18 +3001,18 @@ class KobraXBridge: 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) + _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]: - """Lädt System- + User-Profile aus dem Cache. System-Profile kommen - aus bridge/data/orca_filaments.json (Image-embedded), User-Profile - aus /orca_filaments.user.json (Volume-persistent — - überlebt Image-Updates). User-Profile bekommen ein `is_user: True`- - Flag damit das Frontend sie markieren kann.""" + """Loads system + user profiles from the cache. System profiles come + from bridge/data/orca_filaments.json (image-embedded), user profiles + from /orca_filaments.user.json (volume-persistent - + survives image updates). User profiles get an `is_user: True` + flag so the frontend can mark them.""" if getattr(self, "_orca_filaments_cache", None) is not None: return self._orca_filaments_cache merged: list[dict] = [] @@ -2881,8 +3038,8 @@ class KobraXBridge: return self._orca_filaments_cache def _orca_filaments_user_path(self) -> str: - """Pfad zur User-Profile-JSON. Liegt im Volume-Mount (KX_DATA_DIR), - damit Image-Updates die Daten nicht zerstören.""" + """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") @@ -2891,8 +3048,8 @@ class KobraXBridge: self._orca_filaments_cache = None def _lookup_filament_id(self, vendor: str, name: str) -> str: - """Sucht in orca_filaments.json die filament_id zu einem (vendor,name)- - Tupel. Liefert '' wenn nicht gefunden.""" + """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", "") @@ -2905,12 +3062,12 @@ class KobraXBridge: return self._json_cors({"result": jobs}) async def handle_kx_file_objects(self, request): - """Liefert die Objekt-Liste + optionales SVG für eine Datei. + """Returns the object list + optional SVG for a file. GET /kx/files/{id}/objects → {"names": [...], "svg_b64": "..."} - Wenn Datei noch keine Objekte hat (alter Eintrag): file/fileDetails - beim Drucker abfragen und Antwort abwarten ist Aufgabe des Frontends - (Reload nach Upload). Hier nur Datenbankstand zurückgeben. + 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) @@ -2920,10 +3077,10 @@ class KobraXBridge: names = json.loads(f.get("objects_skip_parts") or "[]") except Exception: names = [] - # Noch keine Objekte im Store (frischer Orca-/Web-Upload): einmal aktiv - # file/fileDetails beim Drucker anfragen. _on_file() füllt den Store nach, - # das Frontend pollt diesen Endpoint und bekommt die Liste beim nächsten - # Versuch (Issue #57 — Skip-Parität auch außerhalb des File-Browsers). + # 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: @@ -2931,7 +3088,7 @@ class KobraXBridge: self.client.publish("file", "fileDetails", {"root": "local", "filename": fn}, timeout=0) except Exception as e: - log.debug(f"fileDetails-Nachfrage fehlgeschlagen: {e}") + log.debug(f"fileDetails request failed: {e}") return self._json_cors({ "result": { "names": names, @@ -2940,7 +3097,7 @@ class KobraXBridge: }) async def handle_kx_skip(self, request): - """Mid-Print Skip auslösen. + """Trigger a mid-print skip. POST /kx/skip body={"names": ["..", ".."]} """ @@ -2959,7 +3116,7 @@ class KobraXBridge: return self._json_cors({"result": "ok", "names": names}) def _build_skip_state_result(self) -> dict: - """Baut den kombinierten Skip-State für UI-Endpunkte.""" + """Builds the combined skip state for UI endpoints.""" filename = self._state.get("filename", "") all_objects: list[str] = [] svg = "" @@ -2980,10 +3137,10 @@ class KobraXBridge: } async def handle_kx_skip_query(self, request): - """Druck-Objektliste vom Drucker neu abfragen. + """Re-request the print object list from the printer. POST /kx/skip/query → triggert skip/query_obj, wartet kurz auf den - async skip/report und gibt den zusammengeführten Skip-State zurück. + async skip/report and returns the merged skip state. """ prev_ts = int(self._skip_state.get("ts", 0) or 0) try: @@ -3004,28 +3161,28 @@ class KobraXBridge: """Aktueller Skip-State. Kombiniert: - - Gesamt-Objektliste: aus dem GCode-Store, gematcht über den aktuell - laufenden filename (file/report beim Druckstart hat die Liste gefüllt). - skip/query_obj liefert nämlich NUR die bereits geskippten zurück, - nicht die Gesamtliste. - - Geskippt: aus self._skip_state (von skip/report aktualisiert). + - 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): - # Aktive Drucker (mit IP) sammeln + # Collect active printers (with IP) active = [(pid, br) for pid, br in self._all_bridges.items() if (br._args.printer_ip or "").strip()] - # Host für bridge_url: Browser-Sicht beibehalten, aber niemals "localhost" exportieren – - # sonst scheitern Fetches aus dem Browser, wenn die UI über die LAN-IP geöffnet ist. + # 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) - # Nur bei Multi-Printer eine konkrete bridge_url setzen (Cross-Instance-Fetch). - # Single-Printer: leere bridge_url → JS nutzt relative Pfade (gleiche Origin wie UI). + # 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}" @@ -3039,7 +3196,7 @@ class KobraXBridge: return self._json_cors({"result": out}) async def handle_kx_print(self, request): - """Druckstart aus dem GCode-Store mit optionalen Filament-Assignments.""" + """Print start from the GCode store with optional filament assignments.""" try: body = await request.json() except Exception: @@ -3069,63 +3226,36 @@ class KobraXBridge: if not ams_box_mapping: return self._json_cors({"error": "no usable filament assignments for current filament mode"}, status=400) else: - # Kein Dialog → alle belegten Slots wie bei normalem Upload-Druck + # No dialog -> all occupied slots as with a normal upload print ams_box_mapping = self._build_auto_ams_box_mapping() - use_ams = len(ams_box_mapping) > 0 auto_leveling = int(body.get("auto_leveling", getattr(self._args, "auto_leveling", 1))) filename = gcode_file["filename"] file_path = gcode_file["path"] - # Datei über internes Serve-Endpoint bereitstellen + # Serve the file via the internal serve endpoint url = f"http://localhost:{self._args.port}/serve/{os.path.basename(file_path)}" - payload = { - "taskid": "-1", - "url": url, - "filename": filename, - "md5": "", - "filepath": None, - "filetype": 1, - "project_type": 1, - "filesize": gcode_file.get("size_bytes", 0), - "ams_settings": { - "use_ams": use_ams, - "ams_box_mapping": ams_box_mapping, - }, - "task_settings": { - "auto_leveling": auto_leveling, - "vibration_compensation": 0, - "flow_calibration": 0, - "dry_mode": 0, - "ai_settings": {"status": 0, "count": 0, "type": 1}, - "timelapse": {"status": 0, "count": 0, "type": 64}, - "drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0}, - "model_objects_skip_parts": excluded_objects, - }, - } + 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) - # UI erst nach echter Drucker-Bestätigung als "geskippt" markieren. - 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 - - log.info(f"KX-Store Druckstart: {filename} ams={len(ams_box_mapping)} slots assignments={bool(assignments)} excluded={len(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": "Keine Antwort vom Drucker"}, status=504) + 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)) - # Job in History starten + # 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"), @@ -3230,14 +3360,14 @@ class KobraXBridge: return web.json_response({"result": files}) def _build_file_metadata(self, filename: str) -> dict: - """Baut die Moonraker-file-metadata für eine Datei. Gemeinsame Quelle - für HTTP /server/files/metadata UND den WS-RPC server.files.metadata - (vorher hatte der WS-Pfad eigene, kaputte Logik mit einer nicht + """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 - Endlosschleife, App hing beim Refresh, Issue #48). + endless loop, app hung on refresh, Issue #48). Liefert Mobileraker-kompatible Pflichtfelder: `filename`, `size`, - `modified` sind in GCodeFile non-nullable; `print_start_time` und die + `modified` are non-nullable in GCodeFile; `print_start_time` and the Slicer-Felder optional.""" s = self._state layer_h = float(s.get("layer_height") or 0.0) @@ -3264,7 +3394,7 @@ class KobraXBridge: 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) verlangt size als non-nullable int. + # GCodeFile (Mobileraker) requires size as a non-nullable int. "size": size_bytes or 1, "modified": time.time(), "estimated_time": est_time or None, @@ -3278,20 +3408,20 @@ class KobraXBridge: async def handle_files_metadata(self, request): """Moonraker /server/files/metadata — moonraker-obico + Mobileraker holen Datei-Metadaten (Slicer-Zeit, Layer, object_height). - Logik in _build_file_metadata (gemeinsam mit WS-RPC).""" + 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 für moonraker-obico ────────────────────────────────── + # -- Moonraker stubs for moonraker-obico ---------------------------------- async def handle_access_api_key(self, request): - """Moonraker /access/api_key — wir haben keine Auth, geben einen Dummy zurück. - moonraker-obico stellt sonst eine WARNING ins Log.""" + """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 zeigt installierte Plugins damit.""" + """Moonraker /machine/update/status - Obico uses this to show installed plugins.""" return web.json_response({ "result": { "busy": False, @@ -3303,9 +3433,9 @@ class KobraXBridge: }) async def handle_history_list(self, request): - """Moonraker /server/history/list — Job-Historie aus dem GCodeStore. + """Moonraker /server/history/list - job history from the GCodeStore. - moonraker-obico nutzt nur das letzte Element (limit=1, order=desc).""" + moonraker-obico only uses the last element (limit=1, order=desc).""" try: limit = int(request.rel_url.query.get("limit", "50")) except ValueError: @@ -3314,15 +3444,15 @@ class KobraXBridge: jobs = self._store.list_jobs(limit=limit) or [] except Exception: jobs = [] - # Mapping auf Moonraker-Schema. Moonraker liefert start_time als Unix- - # Timestamp (float), nicht ISO-String — moonraker-obico parsed das mit - # int(start_time) und crasht sonst. + # 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 aus GCodeStore: "2026-05-27T21:22:25Z" + # 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: @@ -3346,11 +3476,11 @@ class KobraXBridge: return web.json_response({"result": {"count": len(result_jobs), "jobs": result_jobs}}) async def handle_webcams_list(self, request): - """Moonraker /server/webcams/list — Obico holt die Webcam-URLs hier. + """Moonraker /server/webcams/list - Obico fetches the webcam URLs here. - Wenn der Client von einem anderen Host kommt (z.B. moonraker-obico auf - separatem Server), braucht er absolute URLs damit er den Stream erreicht. - Host-Header mit localhost/127.0.0.1 wird durch die echte LAN-IP ersetzt.""" + 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}" @@ -3415,13 +3545,13 @@ class KobraXBridge: if not file_data: return web.json_response({"error": "no file received"}, status=400) - # Nur druckbare Dateien zulassen (Issue #59) — der Kobra X akzeptiert - # ausschließlich .gcode und .bgcode; .3mf-Uploads werden vom Drucker - # nicht verarbeitet und daher abgelehnt (Issue #59, @gangoke). + # 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 abgelehnt (kein GCode): {remote_filename}") + log.warning(f"Upload rejected (not GCode): {remote_filename}") return web.json_response( {"error": f"only GCode files allowed ({', '.join(_allowed_ext)})"}, status=400, @@ -3430,7 +3560,7 @@ class KobraXBridge: file_md5 = hashlib.md5(file_data).hexdigest() file_size = len(file_data) - # Slicer-Zeitschätzung + Thumbnail aus GCode auslesen + # 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) @@ -3439,7 +3569,7 @@ class KobraXBridge: self._state["layer_height"] = layer_h self._state["first_layer_height"] = first_h - # Datei persistent im GCode-Store ablegen + # Persist the file in the GCode store self._store.save_file( file_id=file_md5, filename=remote_filename, @@ -3452,12 +3582,12 @@ class KobraXBridge: first_layer_height=first_h, ) serve_path = os.path.join(self._serve_dir_path, os.path.basename(remote_filename)) - del file_data # RAM freigeben + del file_data # free RAM self._last_uploaded_file = remote_filename - log.info(f"Upload: {remote_filename} ({file_size} bytes) md5={file_md5} → Store + Drucker") + log.info(f"Upload: {remote_filename} ({file_size} bytes) md5={file_md5} -> store + printer") - # Datei per HTTP auf den Drucker hochladen (serve_path liegt bereits auf Disk) + # 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: @@ -3465,20 +3595,20 @@ class KobraXBridge: None, self.client.upload_gcode, serve_path, remote_filename, upload_url ) except Exception as e: - log.error(f"Upload fehlgeschlagen: {e}") + log.error(f"Upload failed: {e}") return web.json_response({"error": str(e)}, status=500) - log.info(f"Upload erfolgreich: {result}") + log.info(f"Upload successful: {result}") - # Druck starten mit vollständigem Payload (inkl. serve-URL + md5 + size) + # Start the print with the full payload (incl. serve URL + md5 + size) serve_url = f"http://{request.host}/serve/{remote_filename}" - # print=true im Multipart-Formular (Moonraker) oder Query-String → Druck starten - # print=false oder fehlt → nur hochladen + # 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" - # Thumbnail immer anfordern (Drucker antwortet async mit file/report) + # 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) @@ -3489,39 +3619,27 @@ class KobraXBridge: if auto_print: mismatch = self._check_filament_mismatch(gcode_filaments) if mismatch: - log.info(f"Upload+Print blockiert — Filament-Mismatch: {mismatch}") + log.info(f"Upload+print blocked - filament mismatch: {mismatch}") self._state["file_ready"] = remote_filename self._state["filament_mismatch"] = mismatch - return web.json_response({ - "done": True, - "filament_mismatch": True, - "mismatch_details": mismatch, - "files": { - "local": { - "name": remote_filename, - "origin": "local", - "path": remote_filename, - "refs": { - "download": f"http://{request.host}/api/files/local/{remote_filename}", - "resource": f"http://{request.host}/api/files/local/{remote_filename}", - } - } - }, - "result": { - "item": {"path": remote_filename, "root": "gcodes"}, - "action": "create_file", - } - }, status=201) + 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"Nur hochgeladen (print=false): {remote_filename}") + log.info(f"Upload only (print=false): {remote_filename}") self._state["file_ready"] = remote_filename - # OctoPrint-kompatibler Response (OrcaSlicer wertet refs aus) - return web.json_response({ + 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": { @@ -3538,14 +3656,17 @@ class KobraXBridge: "item": {"path": remote_filename, "root": "gcodes"}, "action": "create_file", } - }, status=201) + } + if extra: + body.update(extra) + return web.json_response(body, status=201) def _check_filament_mismatch(self, gcode_filaments: list | None) -> list[dict] | None: - """Vergleicht GCode-Filamente (is_used=True) mit aktuell belegten AMS-Slots. + """Compares GCode filaments (is_used=True) with currently occupied AMS slots. - Gibt Liste von Mismatch-Einträgen zurück wenn mindestens ein genutzter - GCode-Slot kein passendes Material im AMS hat — sonst None. - Wird nur ausgelöst wenn AMS-Daten vorhanden sind (mindestens 1 belegter Slot).""" + 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 [] @@ -3579,16 +3700,58 @@ class KobraXBridge: }) 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) - # Nur die im GCode TATSÄCHLICH genutzten Paints auf Slots mappen. OrcaSlicer - # schreibt im Header alle konfigurierten Filamente (filament_colour=…;…;…;…), - # nutzt aber oft nur eines (z.B. einfarbig → nur T3). Würden wir alle - # belegten Slots mappen, erwartet der Drucker alle Farben und blockiert, - # wenn ein anderer (ungenutzter) Slot leer ist. Die genutzten Paint-Indizes + # Only map the paints ACTUALLY used in the GCode to slots. OrcaSlicer + # writes all configured filaments into the header (filament_colour=...;...;...), + # but often uses only one (e.g. single color -> only T3). If we mapped all + # occupied slots, the printer would expect all colors and block + # when another (unused) slot is empty. The used paint indices # liefert _extract_filament_info via is_used (echte T-Tool-Changes). used_paint_indices = None if gcode_filaments: @@ -3599,43 +3762,22 @@ class KobraXBridge: if used_paint_indices is not None: # GCode-Paint-Index N entspricht AMS-Slot N (global_index). Nur belegte - # genutzte Slots mappen; nicht-belegte genutzte → später Warnung möglich. + # used slots; used-but-unloaded -> a warning may follow later. loaded = [(gidx, s) for (gidx, s) in loaded if gidx in used_paint_indices] - use_ams = len(loaded) > 0 ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded) - log.debug(f"AMS-Slots: {len(loaded)} gemappt (genutzte Paints: {used_paint_indices}) → {[i for i, _ in loaded]}") - auto_leveling = getattr(self._args, "auto_leveling", 1) - payload = { - "taskid": "-1", - "url": url, - "filename": filename, - "md5": md5, - "filepath": None, - "filetype": 1, - "project_type": 1, - "filesize": filesize, - "ams_settings": { - "use_ams": use_ams, - "ams_box_mapping": ams_box_mapping, - }, - "task_settings": { - "auto_leveling": auto_leveling, - "vibration_compensation": 0, - "flow_calibration": 0, - "dry_mode": 0, - "ai_settings": {"status": 0, "count": 0, "type": 1}, - "timelapse": {"status": 0, "count": 0, "type": 64}, - "drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0}, - "model_objects_skip_parts": [], - }, - } + log.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("Druckstart: keine Antwort vom Drucker") + 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") @@ -3672,70 +3814,64 @@ class KobraXBridge: if not filename: return web.json_response({"error": "no filename"}, status=400) - log.info(f"Druck starten: {filename}") + log.info(f"Starting print: {filename}") - # Optionale Slot-Auswahl aus dem Filament-Dialog + # 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 = [] - if filament_assignments is not None: - 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: - # AMS-Mapping aus gecachtem State — leere Slots (status != 5) überspringen - ams_box_mapping = self._build_auto_ams_box_mapping() - use_ams = len(ams_box_mapping) > 0 - # Dialog-Checkbox (body) hat Vorrang, sonst Setting-Default (wie handle_kx_print). 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", "") - payload = { - "taskid": "-1", - "url": url, - "filename": filename, - "md5": md5, - "filepath": None, - "filetype": 1, - "project_type": 1, - "filesize": filesize, - "ams_settings": { - "use_ams": use_ams, - "ams_box_mapping": ams_box_mapping, - }, - "task_settings": { - "auto_leveling": auto_leveling, - "vibration_compensation": 0, - "flow_calibration": 0, - "dry_mode": 0, - "ai_settings": {"status": 0, "count": 0, "type": 0}, - "timelapse": {"status": 0, "count": 0, "type": 0}, - "drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0}, - "model_objects_skip_parts": excluded_objects, - }, - } - - # UI erst nach echter Drucker-Bestätigung als "geskippt" markieren. - 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 + 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: - self._pending_preprint_skip = [] - self._pending_preprint_skip_deadline = 0.0 + # 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._db.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={filament_assignments is not None}" + f"ams={len(ams_box_mapping)} slots assignments=True" ) loop = asyncio.get_event_loop() @@ -3743,7 +3879,7 @@ class KobraXBridge: None, lambda: self.client.publish("print", "start", payload, timeout=15.0) ) if result is None: - return web.json_response({"error": "Keine Antwort vom Drucker"}, status=504) + return web.json_response({"error": "no response from printer"}, status=504) if excluded_objects: loop.run_in_executor(None, lambda: self._apply_preprint_skip_after_start(excluded_objects)) @@ -3789,6 +3925,12 @@ class KobraXBridge: 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: @@ -3815,9 +3957,9 @@ class KobraXBridge: tpl = self._load_index_template_cached() except OSError: p = self._theme_index_path() - log.error("Web-UI Theme-Datei fehlt oder nicht lesbar: %s (Theme: %s)", p, self._ui_theme) + log.error("Web UI theme file missing or unreadable: %s (theme: %s)", p, self._ui_theme) return web.Response( - text="
KX-Bridge: index.html nicht gefunden.\nErwartet:\n"
+                text="
KX-Bridge: index.html not found.\nExpected:\n"
                 + html.escape(p, quote=True)
                 + "
", status=500, @@ -3825,10 +3967,10 @@ class KobraXBridge: ) page = tpl.replace("__UI_ASSETS_VER__", self._ui_asset_cache_buster()) - # CSS + JS INLINE einbetten statt nur zu verlinken. OrcaSlicers - # eingebetteter Device-Tab-Webview lädt externe / + + + +
@@ -106,142 +41,57 @@ - -