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.
This commit is contained in:
2026-07-06 22:33:25 +02:00
parent 786fa08ca0
commit 0322ade606
5 changed files with 638 additions and 625 deletions

View File

@ -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_<id>]``
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_<idx>_id oder slot_<idx>_vendor oder slot_<idx>_name
# Expects: slot_<idx>_id or slot_<idx>_vendor or slot_<idx>_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_<id>]``
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", "")

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
"""OrcaSlicer Filament-Profil Parser.
Geteilt zwischen dem Generator (tools/gen_orca_filament_list.py) und dem
Shared between the generator (tools/gen_orca_filament_list.py) and the
Custom-Profile-Import-Endpoint (bridge/kobrax_moonraker_bridge.py).
Liest Orca-Filament-JSON-Dateien (System- oder User-Profile) und gibt
sie als normalisierte Liste mit (id, name, vendor, type, color) zurück.
Reads Orca filament JSON files (system or user profiles) and returns
them as a normalized list with (id, name, vendor, type, color).
"""
from __future__ import annotations
@ -13,8 +13,8 @@ import re
def first_str(value, default: str = "") -> str:
"""Orca-Profile speichern manche Felder als ['wert']. Liefert erstes
Element als String."""
"""Orca profiles store some fields as ['value']. Returns the first
element as a string."""
if isinstance(value, list):
return str(value[0]) if value else default
if isinstance(value, str):
@ -37,34 +37,34 @@ def parse_profile(data: dict, by_name: dict | None = None,
path_vendor: str | None = None,
source_path: str = "",
system_index: list | None = None) -> dict | None:
"""Parsed ein einzelnes Orca-Filament-Profil zum Bridge-Schema.
"""Parses a single Orca filament profile into the bridge schema.
`by_name` ist optional ein {name: [profile, …]}-Index für Inherits-Resolve
aus dem rohen Source-Tree (Generator). Bei Single-File-Import (User-Datei
aus OrcaSlicer-User-Dir) reichen wir stattdessen `system_index` rein —
die fertige System-Profile-Liste aus orca_filaments.json. Damit können
wir filament_id/vendor/type/color über die `inherits`-Kette aus dem
System-Parent ableiten, auch wenn das User-Profil diese Felder nicht
selbst setzt (typisch: User-Override-Profile haben nur Tweaks).
`by_name` is optionally a {name: [profile, ...]} index for inherits resolution
from the raw source tree (generator). For single-file imports (user file
from the OrcaSlicer user dir) we pass `system_index` instead -
the finished system profile list from orca_filaments.json. This lets
us derive filament_id/vendor/type/color via the `inherits` chain from
the system parent even when the user profile does not set these
fields itself (typically: user override profiles only contain tweaks).
Liefert {id, name, vendor, type, color} oder None wenn das Profil
keine filament_id hat (z.B. abstrakte @base-Templates).
Returns {id, name, vendor, type, color} or None when the profile
has no filament_id (e.g. abstract @base templates).
"""
if not isinstance(data, dict):
return None
# User-Profile aus dem OrcaSlicer-User-Dir setzen oft KEIN "type"-Feld
# das kommt vom System-Parent. Wir akzeptieren das wenn entweder "type"
# explizit "filament" ist ODER ein "inherits" auf ein anderes Profil zeigt.
# User profiles from the OrcaSlicer user dir often set NO "type" field -
# it comes from the system parent. We accept that when either "type"
# is explicitly "filament" OR an "inherits" points to another profile.
if data.get("type") not in (None, "filament") and not data.get("inherits"):
return None
if data.get("type") == "filament" and data.get("inherits") is None and not data.get("filament_id"):
# type=filament aber kein parent + keine ID wertloses Stub
# type=filament but no parent + no ID -> worthless stub
return None
inst = data.get("instantiation", "true")
if isinstance(inst, str) and inst.lower() == "false":
return None
# Build system-name-Index für den fallback-Lookup wenn system_index gesetzt.
# Build the system name index for the fallback lookup when system_index is set.
sys_by_name: dict[str, dict] = {}
if system_index:
for p in system_index:
@ -92,7 +92,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
return None
def _resolve_via_system_index(key: str):
"""Inherits-Kette über system_index (clean_name-Match)."""
"""Inherits chain via system_index (clean_name match)."""
parent_raw = data.get("inherits")
if not parent_raw or not sys_by_name:
return None
@ -100,7 +100,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
sys_p = sys_by_name.get(parent_clean)
if not sys_p:
return None
# System-JSON benutzt schon das normalisierte Schema
# The system JSON already uses the normalized schema
mapping = {
"filament_id": "id",
"filament_vendor": "vendor",
@ -136,10 +136,10 @@ def parse_profile(data: dict, by_name: dict | None = None,
def parse_profile_bytes(blob: bytes, source_name: str = "",
system_index: list | None = None) -> dict | None:
"""Liest ein einzelnes Profil aus JSON-Bytes. Für File-Upload-Pfad.
`system_index` ist optional die fertige Liste aus orca_filaments.json
wird für die Inherits-Resolve von User-Profilen genutzt die das volle
Schema vom System-Parent erben."""
"""Reads a single profile from JSON bytes. For the file upload path.
`system_index` is optionally the finished list from orca_filaments.json -
used for the inherits resolution of user profiles that do not carry the full
schema from the system parent."""
try:
data = json.loads(blob.decode("utf-8", errors="replace"))
except Exception: