From 0322ade6065b104e9e0309292f458fd6b12b1d86 Mon Sep 17 00:00:00 2001 From: viewit Date: Mon, 6 Jul 2026 22:33:25 +0200 Subject: [PATCH] refactor: translate all logs, comments and API error strings to English Logs are user-facing across all locales; comments and docstrings switch to English for external contributors. No behavior change. --- config_loader.py | 66 +-- env_loader.py | 7 +- kobrax_client.py | 116 ++-- kobrax_moonraker_bridge.py | 1022 ++++++++++++++++++------------------ orca_filaments.py | 52 +- 5 files changed, 638 insertions(+), 625 deletions(-) diff --git a/config_loader.py b/config_loader.py index df01ee8..e15e0a2 100644 --- a/config_loader.py +++ b/config_loader.py @@ -1,7 +1,7 @@ """ -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 @@ -34,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() @@ -48,7 +48,7 @@ def _load_env_file(path: pathlib.Path): 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") @@ -66,6 +66,7 @@ def _load_config_file(path: pathlib.Path): "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"), "SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"), "SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"), } @@ -124,12 +125,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(): @@ -145,7 +146,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) @@ -153,13 +154,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() @@ -200,28 +201,28 @@ def _filament_section(printer_id: Optional[str] = None) -> str: def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]: - """Liest die [filament_profiles]-Sektion aus config.ini. + """Reads the [filament_profiles] section from config.ini. With ``printer_id`` set, reads the per-printer ``[filament_profiles_]`` section and falls back to the legacy global ``[filament_profiles]`` while that printer has no own section yet. - Format pro AMS-Slot — primärer Selector ist (vendor, name), die `id` wird - aus der orca_filaments.json beim Speichern nachgeschlagen und mitgeführt - (als Hint für OrcaSlicer; das Orca-Datenmodell hat ~136 Profile mit - derselben filament_id wie 'OGFL99', d.h. die ID ist nicht eindeutig): + Format per AMS slot - the primary selector is (vendor, name); the `id` is + looked up from orca_filaments.json on save and carried along + (as a hint for OrcaSlicer; the Orca data model has ~136 profiles sharing + the same filament_id like 'OGFL99', i.e. the ID is not unique): [filament_profiles] slot_0_vendor = Polymaker 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() @@ -236,7 +237,7 @@ def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]: return {} result: dict[int, dict] = {} for key, value in cfg.items(section): - # Erwartet: slot__id oder slot__vendor oder slot__name + # Expects: slot__id or slot__vendor or slot__name if not key.startswith("slot_"): continue parts = key.split("_", 2) @@ -256,11 +257,11 @@ def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]: def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str] = None) -> bool: - """Schreibt die übergebenen Slot-Profile in die [filament_profiles]- - Sektion der config.ini. Existierende Einträge werden komplett ersetzt. + """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. @@ -271,8 +272,8 @@ def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str] cfg = configparser.ConfigParser() cfg.read(path, encoding="utf-8") section = _filament_section(printer_id) - # visible_vendors (Issue #41) ist kein Slot-Mapping — beim Ersetzen der - # Sektion erhalten, sonst geht der Vendor-Filter beim Slot-Save verloren. + # 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(section, "visible_vendors"): @@ -299,10 +300,10 @@ def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str] def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]: - """Liest [filament_profiles] visible_vendors (komma-separiert) aus config.ini. + """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. @@ -322,7 +323,7 @@ def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]: def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) -> bool: - """Schreibt visible_vendors in [filament_profiles], ohne die Slot-Mappings + """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 @@ -431,7 +432,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", "") @@ -446,3 +447,4 @@ 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", "") diff --git a/env_loader.py b/env_loader.py index 9f93a0f..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", "") @@ -52,3 +52,4 @@ 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 c882403..4977945 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) @@ -135,33 +135,33 @@ logging.basicConfig(level=logging.INFO, datefmt="%H:%M:%S") log = logging.getLogger("bridge") -# Web-UI: Unterverzeichnis unter web/themes//index.html +# 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", } -# Dateien aus lib/ werden anhand der Extension ausgeliefert (kein Whitelist-Eintrag nötig) +# 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) @@ -208,12 +208,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 @@ -233,14 +233,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") @@ -262,7 +262,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" @@ -284,12 +284,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] @@ -414,14 +414,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) + # Plus layer_height / first_layer_height (Obico Z height, v0.9.18) for col, typ in ( ("objects_skip_parts", "TEXT"), ("svg_image", "TEXT"), @@ -433,7 +433,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() @@ -446,7 +446,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: @@ -486,7 +486,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: @@ -498,7 +498,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=?", @@ -580,17 +580,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" @@ -614,7 +614,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): @@ -639,7 +639,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: @@ -656,7 +656,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 @@ -684,8 +684,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() @@ -714,7 +714,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: @@ -730,7 +730,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 @@ -740,8 +740,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: @@ -824,20 +824,20 @@ 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._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._printer_id) @@ -858,10 +858,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"), @@ -893,13 +893,13 @@ 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._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] = [] @@ -912,21 +912,21 @@ class KobraXBridge: if _sm_url else None ) # Persistierte Spool-Zuordnung (AMS-Slot → Spoolman-Spool) je Drucker laden. - # Fix: hier wurde `config_loader` referenziert, aber der Modul-Alias ist - # `env_loader` (Zeile 32) → NameError, den das bare `except` verschluckte, - # sodass die Persistenz nie lud. Jetzt über den lokalen Import + per-Drucker. + # 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: Slot-Map laden fehlgeschlagen: %s", _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) @@ -1072,14 +1072,14 @@ class KobraXBridge: int(k): int(v) for k, v in slot_map.items() if str(v).isdigit() and int(v) > 0 } - # Persistieren je Drucker (eigene [spoolman_]-Sektion), damit die - # Zuordnung Bridge-Neustart überlebt und zwei AMS sich nicht überschreiben. - # (Vorher: NameError auf `config_loader` → nichts wurde je gespeichert.) + # 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: Slot-Map speichern fehlgeschlagen: %s", _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 @@ -1171,8 +1171,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) @@ -1181,12 +1181,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: @@ -1216,9 +1216,9 @@ class KobraXBridge: self._spoolman_notify_end() self._current_job_id = "" - # 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"): @@ -1254,25 +1254,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 @@ -1280,12 +1280,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 if project: if "filename" in project: self._state["filename"] = project["filename"] @@ -1322,22 +1322,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] @@ -1353,7 +1353,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 @@ -1362,7 +1362,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 {} @@ -1371,11 +1371,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 "" @@ -1386,13 +1386,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: @@ -1568,7 +1568,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 @@ -1591,8 +1591,8 @@ class KobraXBridge: return [] loaded_map = {gidx: s for gidx, s in loaded} max_idx = max(loaded_map.keys()) - # Drucker interpretiert ams_box_mapping als geordnete Liste (Eintrag N = TN). - # Fehlende Slots müssen als Platzhalter rein, sonst verschiebt sich alles. + # 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: @@ -1725,8 +1725,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 {} @@ -1741,12 +1741,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): @@ -1846,13 +1846,13 @@ 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", @@ -1884,9 +1884,9 @@ class KobraXBridge: "HIPS": "OGFS98", } - # Normalisiert Material-Typ-Strings auf den kanonischen Key für _TRAY_INFO_IDX - # und _default_filament_name. PLA-Varianten die nicht exakt matchen fallen - # auf ihre Basisfamilie zurück (PLA+ → PLA+, PLA Matte → PLA, etc.). + # 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("_", " ") @@ -1905,12 +1905,12 @@ class KobraXBridge: return m def _build_lane_data(self) -> dict: - """Baut BBL-AMS-JSON für OrcaSlicer DevFilaSystemParser::ParseV1_0. + """Builds BBL AMS JSON for OrcaSlicer DevFilaSystemParser::ParseV1_0. - 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). + 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) @@ -1943,15 +1943,15 @@ class KobraXBridge: else: color_hex = "FFFFFFFF" material = self._normalize_material(slot.get("type", "PLA")) - # 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: + # 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 + # 2. Generic fallback (_TRAY_INFO_IDX) per material type - no + # vendor hint; OrcaSlicer then picks its own generic preset user_profile = self._filament_profiles.get(slot_index) or {} if user_profile.get("name"): vendor = user_profile.get("vendor", "") @@ -1959,9 +1959,9 @@ class KobraXBridge: 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") @@ -1974,14 +1974,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, @@ -2010,9 +2010,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 @@ -2025,18 +2025,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) @@ -2051,12 +2051,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: @@ -2071,11 +2071,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): @@ -2099,9 +2099,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], @@ -2121,24 +2121,24 @@ class KobraXBridge: 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 {} 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 je Gate aus der (druckerspezifischen) Slot-Map, damit - # Happy-Hare/OrcaSlicer den gebundenen Spool anzeigen kann (-1 = keiner). + # 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)} @@ -2159,19 +2159,19 @@ class KobraXBridge: } 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 = self._normalize_material(material) profs = self._load_orca_filaments() # Varianten-Mapping: Drucker meldet z.B. "PLA SILK", OrcaSlicer speichert - # alle Varianten unter type=PLA mit dem Variant-Namen im name-Feld. + # 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", @@ -2191,8 +2191,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: @@ -2228,7 +2228,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": { @@ -2238,7 +2238,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": [], @@ -2249,9 +2249,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, @@ -2262,12 +2262,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, @@ -2277,8 +2277,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, @@ -2290,14 +2290,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, @@ -2306,23 +2306,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 = { @@ -2342,8 +2342,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, @@ -2352,16 +2352,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, @@ -2372,8 +2372,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), @@ -2401,8 +2401,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: @@ -2425,7 +2425,7 @@ class KobraXBridge: self._store.update_file_filaments(f["id"], parsed_filaments) except Exception: pass - # Letzten Job-Status + Dauer pro Datei ergänzen + # Add last job status + duration per file jobs = self._store.list_jobs(limit=500) last_job: dict = {} for j in reversed(jobs): @@ -2453,8 +2453,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="") @@ -2478,8 +2478,8 @@ class KobraXBridge: "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", ""), @@ -2487,12 +2487,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() @@ -2504,8 +2504,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": []}) @@ -2517,21 +2517,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() @@ -2570,7 +2570,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): @@ -2596,8 +2596,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() @@ -2626,17 +2626,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"), ] @@ -2649,18 +2649,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")) @@ -2676,8 +2676,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, @@ -2702,12 +2702,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() @@ -2726,11 +2726,11 @@ class KobraXBridge: 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] = [] @@ -2756,8 +2756,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") @@ -2766,8 +2766,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", "") @@ -2780,12 +2780,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) @@ -2795,10 +2795,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: @@ -2806,7 +2806,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, @@ -2815,7 +2815,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": ["..", ".."]} """ @@ -2834,7 +2834,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 = "" @@ -2855,10 +2855,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: @@ -2879,28 +2879,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}" @@ -2914,7 +2914,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: @@ -2944,7 +2944,7 @@ 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 @@ -2952,7 +2952,7 @@ class KobraXBridge: 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 = { @@ -2980,7 +2980,7 @@ class KobraXBridge: }, } - # UI erst nach echter Drucker-Bestätigung als "geskippt" markieren. + # Mark the UI 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] @@ -2989,13 +2989,13 @@ class KobraXBridge: 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)) @@ -3105,14 +3105,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) @@ -3139,7 +3139,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, @@ -3153,20 +3153,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, @@ -3178,9 +3178,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: @@ -3189,15 +3189,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: @@ -3221,11 +3221,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}" @@ -3290,13 +3290,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, @@ -3305,7 +3305,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) @@ -3330,7 +3330,7 @@ class KobraXBridge: del file_data # RAM freigeben 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_url = self._state.get("upload_url") or None @@ -3340,20 +3340,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}") - # 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) @@ -3392,10 +3392,10 @@ class KobraXBridge: 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) + # OctoPrint-compatible response (OrcaSlicer evaluates refs) return web.json_response({ "done": True, "files": { @@ -3416,11 +3416,11 @@ class KobraXBridge: }, 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 [] @@ -3459,11 +3459,11 @@ class KobraXBridge: 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: @@ -3474,12 +3474,12 @@ 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]}") + log.debug(f"AMS slots: {len(loaded)} mapped (used paints: {used_paint_indices}) -> {[i for i, _ in loaded]}") auto_leveling = getattr(self._args, "auto_leveling", 1) payload = { "taskid": "-1", @@ -3510,7 +3510,7 @@ class KobraXBridge: 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") @@ -3547,9 +3547,9 @@ 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 [] @@ -3562,7 +3562,7 @@ class KobraXBridge: md5 = self._state.get("last_upload_md5", "") if filament_assignments is not None: - # Explizite Slot-Zuweisung aus dem Filament-Dialog + # 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}") @@ -3571,8 +3571,8 @@ class KobraXBridge: if not ams_box_mapping: return web.json_response({"error": "no usable filament assignments for current filament mode"}, status=400) else: - # Dashboard-Reprint: gcode_filaments aus DB laden damit used_paint_indices- - # Filter greift und leere/verschobene Slots nicht falsch gemappt werden. + # 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) @@ -3581,7 +3581,7 @@ class KobraXBridge: except Exception: pass - # Pre-Print Skip setzen bevor _start_print aufgerufen wird + # Set the pre-print skip before _start_print is called 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] @@ -3624,7 +3624,7 @@ class KobraXBridge: }, } - # UI erst nach echter Drucker-Bestätigung als "geskippt" markieren. + # Mark the UI 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] @@ -3643,7 +3643,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)) @@ -3721,9 +3721,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, @@ -3731,10 +3731,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 /