forked from viewit/KX-Bridge-Release
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:
@ -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", "")
|
||||
|
||||
@ -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", "")
|
||||
|
||||
116
kobrax_client.py
116
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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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:
|
||||
|
||||
Reference in New Issue
Block a user