Compare commits

7 Commits

16 changed files with 509 additions and 1381 deletions

View File

@@ -1,5 +1,50 @@
# Changelog # Changelog
## [0.9.20] 2026-06-08
### Neu
- **Französische Sprachunterstützung (PR #45 von @Nathacks)**
- **Z-Höhe in der Print-UI (PR #49 von @Nathacks).** Zeigt die aktuelle
Z-Position in mm unterhalb des Layer-Zählers.
### Behoben
- **Kamera-Autostart ignorierte das "Kamera bei Druckstart einschalten"-
Setting nach einem Bridge-Restart (Issue #50).** Das Setting wurde in
der Prozessumgebung gecacht — nach dem Speichern in der UI überlebte
der alte Wert den Restart und der neue Wert aus `config.ini` wurde
nicht gelesen.
- **Kamera startete nach manuellem Stopp während eines Drucks automatisch
neu (Issue #50).** Ein neues `_camera_user_stopped`-Flag unterdrückt
den Autostart für die aktuelle Drucksitzung. Es wird beim Druckende
zurückgesetzt.
- **Falscher "Stream nicht verfügbar"-Fehler-Toast beim manuellen
Kamera-Stopp.** Der Bild-Fehler-Handler war noch registriert als
`img.src` geleert wurde.
- **JS-Fehler (`ReferenceError: br is not defined`) beim Licht-Toggle.**
Variable wurde aus dem falschen Scope referenziert.
- Webcam-URLs sind jetzt absolut, damit Mobileraker/Obico-Clients sie
erreichen können.
## [0.9.19.1] 2026-06-04
### Behoben
- Standalone-Binaries (Linux/Windows) zeigten `vunknown` als Version.
Die `VERSION`-Datei ist jetzt ins PyInstaller-Onefile eingebettet.
- Bei fehlenden TLS-Zertifikaten (`anycubic_slicer.crt`/`.key`) gab
es nur den rohen Fehler `[Errno 2] No such file or directory`. Die
Bridge meldet jetzt klar, wo die Dateien hingelegt werden müssen
und dass `anycubic-certs.zip` aus dem Gitea-Release stammt.
### Geändert
- Filament-Profil-Liste neu kuratiert: 209 statt 399 Einträge.
Profile die nur für drucker-spezifische Vendor-Bundles existieren
(z.B. Eryone Thinker X400, Artillery M1 Pro, WonderMaker ZR,
Tiertime, Cubicon, CoLiDo, Afinia, Snapmaker) sind rausgeflogen
— OrcaSlicer hätte sie im Standard-Kobra-X-Setup beim Sync
ohnehin nicht gefunden, weil die jeweiligen Vendor-Bundles nur
bei aktivem Drucker-Vendor geladen werden. Für solche Filamente
bleibt der Custom-Profile-Import (Issue #41) der Weg.
## [0.9.19] 2026-06-02 ## [0.9.19] 2026-06-02
### Neu ### Neu
@@ -19,9 +64,9 @@
wird beim Sync an Orca als User-Match weitergegeben. Funktioniert wird beim Sync an Orca als User-Match weitergegeben. Funktioniert
über HTTP, also auch wenn die Bridge im Docker auf Raspi/NAS läuft über HTTP, also auch wenn die Bridge im Docker auf Raspi/NAS läuft
und OrcaSlicer auf dem Desktop. Auch reine Override-Profile mit nur und OrcaSlicer auf dem Desktop. Auch reine Override-Profile mit nur
`inherits: "Generic PLA @System"` + ein paar Tweaks (z.B. `inherits: "Generic PLA @System"` + ein paar Tweaks werden korrekt
„Bert - PLA") werden korrekt erkannt — die Bridge resolved die erkannt — die Bridge resolved die vererbten Felder aus dem
vererbten Felder aus dem System-Parent. System-Parent.
### Fixes ### Fixes
- **AMS-Sync landete hartnäckig auf „Generic PLA":** das Orca- - **AMS-Sync landete hartnäckig auf „Generic PLA":** das Orca-

View File

@@ -1,5 +1,52 @@
# Changelog # Changelog
## [0.9.20] 2026-06-08
### New
- **French language support (PR #45 by @Nathacks)**
- **Z height display in the print UI (PR #49 by @Nathacks).** Shows
current Z position in mm below the layer counter.
### Fixed
- **Camera auto-start ignored "Enable camera on print start" setting
after a bridge restart (issue #50).** The setting was cached in the
process environment — after saving it in the UI, the old value
survived the restart and the new value from `config.ini` was never
read.
- **Camera restarted automatically after manual stop during a print
(issue #50).** A new `_camera_user_stopped` flag suppresses
auto-restart for the current print session. It resets when the
print ends.
- **Spurious "stream unavailable" error toast when stopping the camera
manually.** The image error handler was still registered when
`img.src` was cleared.
- **JS error (`ReferenceError: br is not defined`) when toggling the
light.** Variable was referenced from the wrong scope.
- Webcam URLs are now absolute so that Mobileraker/Obico clients can
reach them.
## [0.9.19.1] 2026-06-04
### Fixed
- Standalone binaries (Linux/Windows) reported `vunknown` as their
version. The `VERSION` file is now embedded into the PyInstaller
onefile bundle.
- When the TLS certificates (`anycubic_slicer.crt`/`.key`) were
missing, the bridge only logged the raw `[Errno 2] No such file
or directory`. It now states clearly where the files need to be
placed and that `anycubic-certs.zip` from the Gitea release is the
source.
### Changed
- Filament profile list re-curated: 209 entries instead of 399.
Profiles that only exist inside printer-specific vendor bundles
(e.g. Eryone Thinker X400, Artillery M1 Pro, WonderMaker ZR,
Tiertime, Cubicon, CoLiDo, Afinia, Snapmaker) were dropped —
OrcaSlicer wouldn't have found them in a default Kobra X setup
anyway, because the matching vendor bundle is only loaded when
the corresponding printer vendor is active. For those filaments
the custom profile import (issue #41) remains the way.
## [0.9.19] 2026-06-02 ## [0.9.19] 2026-06-02
### New ### New
@@ -19,9 +66,9 @@
are passed through to Orca on sync as user matches. Works over HTTP are passed through to Orca on sync as user matches. Works over HTTP
so the bridge can run in Docker on a Raspi/NAS while OrcaSlicer so the bridge can run in Docker on a Raspi/NAS while OrcaSlicer
lives on a desktop. Override-only profiles with just lives on a desktop. Override-only profiles with just
`inherits: "Generic PLA @System"` + a few tweaks (e.g. "Bert - PLA") `inherits: "Generic PLA @System"` + a few tweaks are detected
are detected correctly — the bridge resolves the inherited fields correctly — the bridge resolves the inherited fields from the
from the system parent. system parent.
### Fixes ### Fixes
- **AMS sync stuck on "Generic PLA":** the Orca data model has 68 - **AMS sync stuck on "Generic PLA":** the Orca data model has 68

View File

@@ -2,6 +2,8 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt

View File

@@ -66,17 +66,43 @@ docker compose up -d
**Binario Linux (sin Docker):** **Binario Linux (sin Docker):**
```bash ```bash
chmod +x kx-bridge && ./kx-bridge chmod +x kx-bridge-linux-amd64 && ./kx-bridge-linux-amd64
``` ```
**EXE Windows (sin Docker):** **EXE Windows (sin Docker):**
``` ```
kx-bridge.exe kx-bridge.exe
``` ```
> `config\` y `data\` se crean junto al EXE — instalación portátil.
> Con los binarios de Linux y Windows, `config/` y `data/` (configuración, SQLite, almacén de GCode) > ⚠️ **Certificados TLS necesarios para el binario standalone**
> viven junto al programa. Copia toda la carpeta para mover la instalación. >
> El bridge habla con el MQTT de la impresora vía mTLS y necesita dos
> ficheros de certificado **junto al binario**:
>
> - `anycubic_slicer.crt`
> - `anycubic_slicer.key`
>
> Ambos vienen en **`anycubic-certs.zip`** en la misma página de release.
> Descárgalo y extrae los dos ficheros en el mismo directorio que
> `kx-bridge-linux-amd64` o `kx-bridge.exe`. Sin ellos verás
> `Verbindung fehlgeschlagen: TLS-Zertifikate fehlen …` (0.9.19.1+) o
> `[Errno 2] No such file or directory` (versiones anteriores).
>
> Estructura correcta:
> ```
> ~/kx-bridge/
> ├── kx-bridge-linux-amd64 (o kx-bridge.exe)
> ├── anycubic_slicer.crt ← de anycubic-certs.zip
> ├── anycubic_slicer.key ← de anycubic-certs.zip
> └── config/ (se crea en el primer arranque)
> ```
>
> Los usuarios de Docker no necesitan hacer esto — los certificados
> están incluidos en la imagen.
> Con los binarios de Linux y Windows, `config/` y `data/` (configuración,
> SQLite, almacén de GCode) viven junto al programa. Copia toda la carpeta
> para mover la instalación.
**Python directamente:** **Python directamente:**
```bash ```bash

View File

@@ -1 +1 @@
0.9.19 0.9.20

File diff suppressed because it is too large Load Diff

View File

@@ -154,6 +154,13 @@ class KobraXClient:
# -- Connection ---------------------------------------------------------- # -- Connection ----------------------------------------------------------
def _do_connect(self): def _do_connect(self):
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."
)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE ctx.verify_mode = ssl.CERT_NONE

View File

@@ -222,7 +222,7 @@ def _parse_gcode_estimated_time(data: bytes) -> int:
elif unit == "m": secs += int(val) * 60 elif unit == "m": secs += int(val) * 60
elif unit == "s": secs += int(val) elif unit == "s": secs += int(val)
if secs: if secs:
log.info(f"Slicer-Schätzzeit: {secs}s ({m.group(1).strip()})") log.info(f"Slicer estimate: {secs}s ({m.group(1).strip()})")
return secs return secs
@@ -798,6 +798,7 @@ class KobraXBridge:
self._serve_dir_path: str = self._store._gcode_dir self._serve_dir_path: str = self._store._gcode_dir
self._current_job_id: str = "" self._current_job_id: str = ""
self._camera_autostarted: bool = False self._camera_autostarted: bool = False
self._camera_user_stopped: bool = False # User hat Kamera während Druck manuell gestoppt
self.camera_cache: CameraCache = CameraCache() self.camera_cache: CameraCache = CameraCache()
self._thumbnail_b64: str = "" self._thumbnail_b64: str = ""
@@ -809,7 +810,7 @@ class KobraXBridge:
# Theme-Name prüfen (keine Sonderzeichen oder Umlaute) # Theme-Name prüfen (keine Sonderzeichen oder Umlaute)
raw_theme = (getattr(args, "ui_theme", None) or "default").strip() raw_theme = (getattr(args, "ui_theme", None) or "default").strip()
if not _UI_THEME_NAME_RE.match(raw_theme): if not _UI_THEME_NAME_RE.match(raw_theme):
log.warning("Ungültiger UI-Theme-Name %r nutze default", raw_theme) log.warning("Invalid UI theme name %r using default", raw_theme)
raw_theme = "default" raw_theme = "default"
self._ui_theme = raw_theme self._ui_theme = raw_theme
self._index_tpl_cache: str | None = None self._index_tpl_cache: str | None = None
@@ -914,7 +915,9 @@ class KobraXBridge:
# Zentral hier, damit es alle Druck-Startwege abdeckt (OrcaSlicer + UI). # Zentral hier, damit es alle Druck-Startwege abdeckt (OrcaSlicer + UI).
# _camera_autostarted verhindert Mehrfach-Trigger pro Druck. # _camera_autostarted verhindert Mehrfach-Trigger pro Druck.
if kobra_state == "printing": if kobra_state == "printing":
if getattr(self._args, "camera_on_print", 0) and not getattr(self, "_camera_autostarted", False): if (getattr(self._args, "camera_on_print", 0)
and not self._camera_autostarted
and not self._camera_user_stopped):
self._camera_autostarted = True self._camera_autostarted = True
try: try:
self.client.start_camera() self.client.start_camera()
@@ -923,6 +926,7 @@ class KobraXBridge:
log.warning(f"Kamera-Autostart fehlgeschlagen: {e}") log.warning(f"Kamera-Autostart fehlgeschlagen: {e}")
elif kobra_state in ("free", "finished", "stoped", "canceled"): elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False self._camera_autostarted = False
self._camera_user_stopped = False # für nächsten Druck freigeben
# Job-History: Druckstart erkennen # Job-History: Druckstart erkennen
if kobra_state == "printing" and not self._current_job_id: if kobra_state == "printing" and not self._current_job_id:
@@ -934,7 +938,7 @@ class KobraXBridge:
gcode_file_id=gf["id"], gcode_file_id=gf["id"],
printer_id=self._printer_id, printer_id=self._printer_id,
) )
log.info(f"Job gestartet: {self._current_job_id} für {filename}") log.info(f"Job started: {self._current_job_id} for {filename}")
# Job-History: Druckende erkennen # Job-History: Druckende erkennen
if kobra_state in ("finished",) and self._current_job_id: if kobra_state in ("finished",) and self._current_job_id:
@@ -1001,7 +1005,9 @@ class KobraXBridge:
# Kamera-Autostart auch hier (OrcaSlicer meldet Start oft via info/report). # Kamera-Autostart auch hier (OrcaSlicer meldet Start oft via info/report).
# _camera_autostarted-Guard verhindert Doppel-Start mit _on_print. # _camera_autostarted-Guard verhindert Doppel-Start mit _on_print.
if kobra_state == "printing": if kobra_state == "printing":
if getattr(self._args, "camera_on_print", 0) and not getattr(self, "_camera_autostarted", False): if (getattr(self._args, "camera_on_print", 0)
and not self._camera_autostarted
and not self._camera_user_stopped):
self._camera_autostarted = True self._camera_autostarted = True
try: try:
self.client.start_camera() self.client.start_camera()
@@ -1010,6 +1016,7 @@ class KobraXBridge:
log.warning(f"Kamera-Autostart fehlgeschlagen: {e}") log.warning(f"Kamera-Autostart fehlgeschlagen: {e}")
elif kobra_state in ("free", "finished", "stoped", "canceled"): elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False self._camera_autostarted = False
self._camera_user_stopped = False # für nächsten Druck freigeben
if project: if project:
if "filename" in project: if "filename" in project:
self._state["filename"] = project["filename"] self._state["filename"] = project["filename"]
@@ -1075,7 +1082,7 @@ class KobraXBridge:
if filename: if filename:
try: try:
self._store.update_file_objects(filename, objs, svg) self._store.update_file_objects(filename, objs, svg)
log.info(f"Skip-Objekte für {filename}: {len(objs)} ({'mit SVG' if svg else 'ohne SVG'})") log.info(f"Skip objects for {filename}: {len(objs)} ({'with SVG' if svg else 'no SVG'})")
except Exception as e: except Exception as e:
log.warning(f"update_file_objects fehlgeschlagen: {e}") log.warning(f"update_file_objects fehlgeschlagen: {e}")
self._push_status_update() self._push_status_update()
@@ -1884,6 +1891,41 @@ class KobraXBridge:
"gcode_macro TIMELAPSE_TAKE_FRAME": { "gcode_macro TIMELAPSE_TAKE_FRAME": {
"is_paused": False, "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).
"configfile": {
"config": {},
"settings": {
"printer": {
"kinematics": "cartesian",
"max_velocity": 450,
"max_accel": 10000,
"max_z_velocity": 12,
"max_z_accel": 100,
"square_corner_velocity": 20.0,
},
"extruder": {
"nozzle_diameter": 0.4,
"min_temp": 0,
"max_temp": 320,
"min_extrude_temp": 10,
},
"heater_bed": {
"min_temp": 0,
"max_temp": 120,
},
"stepper_x": {"position_min": -18.5, "position_max": 280},
"stepper_y": {"position_min": -6.5, "position_max": 272.5},
"stepper_z": {"position_min": -4, "position_max": 262},
"virtual_sdcard": {"path": "/data/gcodes"},
"pause_resume": {},
"display_status": {},
},
"warnings": [],
"save_config_pending": False,
"save_config_pending_items": {},
},
} }
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -2655,7 +2697,20 @@ class KobraXBridge:
return web.json_response({"result": {"count": len(result_jobs), "jobs": result_jobs}}) return web.json_response({"result": {"count": len(result_jobs), "jobs": result_jobs}})
async def handle_webcams_list(self, request): async def handle_webcams_list(self, request):
"""Moonraker /server/webcams/list — Obico holt die Webcam-URLs hier.""" """Moonraker /server/webcams/list — Obico holt die Webcam-URLs hier.
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."""
host_hdr = request.headers.get("Host", "") if request else ""
host_name = (host_hdr or "").split(":")[0]
port_part = f":{host_hdr.split(':')[1]}" if ":" in (host_hdr or "") else f":{self._args.port}"
local_ip = getattr(self, "_local_ip", None) or host_name
if host_name in ("localhost", "127.0.0.1", ""):
host_name = local_ip
base = f"http://{host_name}{port_part}"
stream_url = f"{base}/api/camera/stream"
snapshot_url = f"{base}/api/camera/snapshot"
return web.json_response({ return web.json_response({
"result": { "result": {
"webcams": [ "webcams": [
@@ -2667,8 +2722,8 @@ class KobraXBridge:
"icon": "mdiWebcam", "icon": "mdiWebcam",
"target_fps": 5, "target_fps": 5,
"target_fps_idle": 2, "target_fps_idle": 2,
"stream_url": "/api/camera/stream", "stream_url": stream_url,
"snapshot_url": "/api/camera/snapshot", "snapshot_url": snapshot_url,
"flip_horizontal": False, "flip_horizontal": False,
"flip_vertical": False, "flip_vertical": False,
"rotation": 0, "rotation": 0,
@@ -2853,7 +2908,7 @@ class KobraXBridge:
log.info(f"print/start → {filename} url={url} ams={len(ams_box_mapping)} slots mode={self._filament_mode}") log.info(f"print/start → {filename} url={url} ams={len(ams_box_mapping)} slots mode={self._filament_mode}")
result = self.client.publish("print", "start", payload, timeout=15.0) result = self.client.publish("print", "start", payload, timeout=15.0)
if result: if result:
log.info(f"Druckstart bestätigt: state={result.get('state')}") log.info(f"Print start confirmed: state={result.get('state')}")
else: else:
log.warning("Druckstart: keine Antwort vom Drucker") log.warning("Druckstart: keine Antwort vom Drucker")
@@ -3108,7 +3163,7 @@ class KobraXBridge:
return web.json_response({"result": "disconnected"}) return web.json_response({"result": "disconnected"})
async def handle_api_restart(self, request): async def handle_api_restart(self, request):
log.info("Neustart über API angefordert") log.info("Restart requested via API")
response = web.json_response({"status": "restarting"}) response = web.json_response({"status": "restarting"})
asyncio.get_event_loop().call_later(0.3, self._restart_bridge) asyncio.get_event_loop().call_later(0.3, self._restart_bridge)
return response return response
@@ -3388,6 +3443,9 @@ class KobraXBridge:
await loop.run_in_executor(None, lambda: self.client.publish( await loop.run_in_executor(None, lambda: self.client.publish(
"video", "stopCapture", None, timeout=0 "video", "stopCapture", None, timeout=0
)) ))
# Verhindert dass der Autostart-Guard die Kamera während des
# laufenden Drucks wieder einschaltet (State-Flicker-Problem).
self._camera_user_stopped = True
return web.json_response({"result": "ok"}) return web.json_response({"result": "ok"})
async def handle_api_camera_snapshot(self, request): async def handle_api_camera_snapshot(self, request):
@@ -3447,7 +3505,7 @@ class KobraXBridge:
stderr=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
) )
except (FileNotFoundError, OSError) as e: except (FileNotFoundError, OSError) as e:
log.warning("Kamera: ffmpeg nicht gefunden Kamerastream nicht verfügbar") log.warning("Camera: ffmpeg not found camera stream unavailable")
return web.Response(status=503, text="ffmpeg not found") return web.Response(status=503, text="ffmpeg not found")
except Exception as e: except Exception as e:
log.warning(f"Kamera: ffmpeg konnte nicht gestartet werden: {e}") log.warning(f"Kamera: ffmpeg konnte nicht gestartet werden: {e}")
@@ -3542,7 +3600,7 @@ class KobraXBridge:
if not os.path.isfile(serve_path): if not os.path.isfile(serve_path):
return web.Response(status=404, text="not found") return web.Response(status=404, text="not found")
size = os.path.getsize(serve_path) size = os.path.getsize(serve_path)
log.info(f"Drucker lädt Datei ab: {filename} ({size} bytes)") log.info(f"Printer downloading file: {filename} ({size} bytes)")
return web.FileResponse(serve_path, headers={ return web.FileResponse(serve_path, headers={
"Content-Disposition": f'attachment; filename="{filename}"' "Content-Disposition": f'attachment; filename="{filename}"'
}) })
@@ -3580,6 +3638,7 @@ class KobraXBridge:
"remain_time": s["remain_time"], "remain_time": s["remain_time"],
"curr_layer": s["curr_layer"], "curr_layer": s["curr_layer"],
"total_layers": s["total_layers"], "total_layers": s["total_layers"],
"z_mm": self._estimate_current_z(),
"filename": s["filename"], "filename": s["filename"],
"slicer_time": slicer_time, "slicer_time": slicer_time,
"camera_url": s["camera_url"], "camera_url": s["camera_url"],
@@ -3857,7 +3916,7 @@ class KobraXBridge:
with open(config_path, "w", encoding="utf-8") as f: with open(config_path, "w", encoding="utf-8") as f:
f.write("# KX-Bridge Konfigurationsdatei\n\n") f.write("# KX-Bridge Konfigurationsdatei\n\n")
cfg.write(f) cfg.write(f)
log.info(f"Drucker '{name or creds['model']}' als {sec} hinzugefügt (Port {new_port})") log.info(f"Printer '{name or creds['model']}' added as {sec} (port {new_port})")
response = self._json_cors({"status": "restarting", "section": sec, "http_port": new_port}) response = self._json_cors({"status": "restarting", "section": sec, "http_port": new_port})
asyncio.get_event_loop().call_later(0.5, self._restart_bridge) asyncio.get_event_loop().call_later(0.5, self._restart_bridge)
return response return response
@@ -3932,13 +3991,13 @@ class KobraXBridge:
# die alten Werte statt der geänderten config.ini. # die alten Werte statt der geänderten config.ini.
for _k in ("PRINTER_IP", "MQTT_PORT", "MQTT_USERNAME", "MQTT_PASSWORD", for _k in ("PRINTER_IP", "MQTT_PORT", "MQTT_USERNAME", "MQTT_PASSWORD",
"MODE_ID", "DEVICE_ID", "DEFAULT_AMS_SLOT", "AUTO_LEVELING", "MODE_ID", "DEVICE_ID", "DEFAULT_AMS_SLOT", "AUTO_LEVELING",
"BRIDGE_PRINTER_NAME"): "CAMERA_ON_PRINT", "WEB_UPLOAD_WARNING", "BRIDGE_PRINTER_NAME"):
os.environ.pop(_k, None) os.environ.pop(_k, None)
in_docker = os.path.exists("/.dockerenv") or os.environ.get("KX_IN_DOCKER") in_docker = os.path.exists("/.dockerenv") or os.environ.get("KX_IN_DOCKER")
if in_docker: if in_docker:
# Docker/systemd: Prozess beenden reicht der Supervisor startet neu (frische environ) # Docker/systemd: Prozess beenden reicht der Supervisor startet neu (frische environ)
log.info("Container-Umgebung erkannt beende Prozess für Supervisor-Restart") log.info("Container environment detected exiting for supervisor restart")
os._exit(0) os._exit(0)
frozen = getattr(sys, "frozen", False) frozen = getattr(sys, "frozen", False)
@@ -3973,7 +4032,9 @@ class KobraXBridge:
GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag" GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag"
def _read_version(self) -> str: def _read_version(self) -> str:
for base in (pathlib.Path(_BASE), pathlib.Path(_BASE).parent): # PyInstaller-Onefile entpackt VERSION (per kx-bridge.spec datas) nach
# sys._MEIPASS — daher _WEB_BASE statt _BASE benutzen.
for base in (pathlib.Path(_WEB_BASE), pathlib.Path(_BASE), pathlib.Path(_BASE).parent):
p = base / "VERSION" p = base / "VERSION"
if p.is_file(): if p.is_file():
return p.read_text(encoding="utf-8").strip() return p.read_text(encoding="utf-8").strip()
@@ -4115,7 +4176,7 @@ class KobraXBridge:
if fname == "kobrax_moonraker_bridge.py": if fname == "kobrax_moonraker_bridge.py":
return web.json_response( return web.json_response(
{"error": f"Download {fname}: HTTP {resp.status}"}, status=502) {"error": f"Download {fname}: HTTP {resp.status}"}, status=502)
log.warning(f"Update: {fname} nicht im Release ({resp.status}) übersprungen") log.warning(f"Update: {fname} not found in release ({resp.status}) skipped")
continue continue
downloaded.append((app_dir / fname, await resp.read())) downloaded.append((app_dir / fname, await resp.read()))
# Phase 2: atomar ersetzen (erst nach komplettem, erfolgreichem Download) # Phase 2: atomar ersetzen (erst nach komplettem, erfolgreichem Download)
@@ -4392,11 +4453,14 @@ class KobraXBridge:
# Obico registriert obico_remote_event-Callback. Wir akzeptieren leer. # Obico registriert obico_remote_event-Callback. Wir akzeptieren leer.
result = "ok" result = "ok"
elif method == "server.webcams.list": elif method == "server.webcams.list":
# WS-Variante des HTTP-Endpoints # WS-Variante: absolute URL mit echter LAN-IP statt localhost
_lip = getattr(self, "_local_ip", None) or "127.0.0.1"
_base = f"http://{_lip}:{self._args.port}"
result = {"webcams": [{ result = {"webcams": [{
"name": "KX-Bridge", "location": "printer", "service": "mjpegstreamer", "name": "KX-Bridge", "location": "printer", "service": "mjpegstreamer",
"enabled": True, "stream_url": "/api/camera/stream", "enabled": True,
"snapshot_url": "/api/camera/snapshot", "stream_url": f"{_base}/api/camera/stream",
"snapshot_url": f"{_base}/api/camera/snapshot",
"flip_horizontal": False, "flip_vertical": False, "rotation": 0, "flip_horizontal": False, "flip_vertical": False, "rotation": 0,
"target_fps": 5, "aspect_ratio": "16:9", "target_fps": 5, "aspect_ratio": "16:9",
}]} }]}
@@ -4446,7 +4510,7 @@ class KobraXBridge:
log.debug(f"Unbekannte RPC-Methode: {method}") log.debug(f"Unbekannte RPC-Methode: {method}")
result = {} result = {}
except Exception as e: except Exception as e:
log.error(f"RPC-Fehler für {method}: {e}") log.error(f"RPC error for {method}: {e}")
error = {"code": -32603, "message": str(e)} error = {"code": -32603, "message": str(e)}
if rpc_id is not None: if rpc_id is not None:
@@ -4760,7 +4824,7 @@ async def run_bridge(args):
site = web.TCPSite(runner, args.host, per_args.port) site = web.TCPSite(runner, args.host, per_args.port)
await site.start() await site.start()
runners.append((runner, client, pid)) runners.append((runner, client, pid))
log.info(f"[Drucker {pid}] Bridge läuft auf http://{args.host}:{per_args.port}") log.info(f"[Printer {pid}] Bridge running on http://{args.host}:{per_args.port}")
import socket as _socket import socket as _socket
try: try:
@@ -4769,6 +4833,9 @@ async def run_bridge(args):
_local_ip = _s.getsockname()[0] _local_ip = _s.getsockname()[0]
except Exception: except Exception:
_local_ip = args.host _local_ip = args.host
# An alle Bridge-Instanzen weitergeben — wird für absolute Webcam-URLs genutzt
for _b in all_bridges.values():
_b._local_ip = _local_ip
log.info(f"OrcaSlicer → Klipper → Host: {_local_ip} Ports: " + log.info(f"OrcaSlicer → Klipper → Host: {_local_ip} Ports: " +
", ".join(str(getattr(b._args, 'port', 0)) for b in all_bridges.values())) ", ".join(str(getattr(b._args, 'port', 0)) for b in all_bridges.values()))
log.info("Ctrl-C zum Beenden") log.info("Ctrl-C zum Beenden")

View File

@@ -6,7 +6,7 @@
# ein → zur Laufzeit über sys._MEIPASS lesbar (_WEB_BASE in der Bridge). # ein → zur Laufzeit über sys._MEIPASS lesbar (_WEB_BASE in der Bridge).
from PyInstaller.utils.hooks import collect_all from PyInstaller.utils.hooks import collect_all
datas = [("web", "web"), ("data", "static")] # bridge/data/ → static/ im _MEIPASS datas = [("web", "web"), ("data", "static"), ("VERSION", ".")] # bridge/data/ → static/ im _MEIPASS
binaries = [] binaries = []
hiddenimports = [] hiddenimports = []

View File

@@ -1,11 +1,12 @@
// ── State ── // ── State ──
var S={nozzle_temp:0,nozzle_target:0,bed_temp:0,bed_target:0, var S={nozzle_temp:0,nozzle_target:0,bed_temp:0,bed_target:0,
print_state:'standby',filename:'',progress:0,print_duration:0,remain_time:0, print_state:'standby',filename:'',progress:0,print_duration:0,remain_time:0,
curr_layer:0,total_layers:0,printer_name:'Kobra X',firmware_version:'', curr_layer:0,total_layers:0,z_mm:0,printer_name:'Kobra X',firmware_version:'',
camera_url:'',fan_speed:0,print_speed_mode:2,light_on:false,light_brightness:80, camera_url:'',fan_speed:0,print_speed_mode:2,light_on:false,light_brightness:80,
ams_slots:[],filament_mode:'toolhead',ace_units:[],ace_dry_presets:null,ace_drying:{status:0,target_temp:0,duration:0,remain_time:0,humidity:null,current_temp:null,units:[]},web_upload_warning:1}; ams_slots:[],filament_mode:'toolhead',ace_units:[],ace_dry_presets:null,ace_drying:{status:0,target_temp:0,duration:0,remain_time:0,humidity:null,current_temp:null,units:[]},web_upload_warning:1};
var tempHistory={n:[],b:[]}; var tempHistory={n:[],b:[]};
var camOn=false; var camOn=false;
var camUserStopped=false; // user stopped camera manually — suppress auto-restart for this print
var currentStep=1; var currentStep=1;
var currentPanel='dashboard'; var currentPanel='dashboard';
var aceAutoRefillPrefs=(function(){ var aceAutoRefillPrefs=(function(){
@@ -101,6 +102,7 @@ function tr(key,fallback){
function _langToggleLabel(lang){ function _langToggleLabel(lang){
if(lang==='de')return 'Deutsch'; if(lang==='de')return 'Deutsch';
if(lang==='en')return 'English'; if(lang==='en')return 'English';
if(lang==='fr')return 'Français';
if(lang==='zh-cn')return '简体中文'; if(lang==='zh-cn')return '简体中文';
return 'Espanol'; return 'Espanol';
} }
@@ -108,10 +110,10 @@ function _langToggleLabel(lang){
function _mapSupportedLang(lang){ function _mapSupportedLang(lang){
if(!lang)return ''; if(!lang)return '';
var l=String(lang).toLowerCase().replace(/_/g,'-').trim(); var l=String(lang).toLowerCase().replace(/_/g,'-').trim();
if(l==='de'||l==='en'||l==='es'||l==='zh-cn')return l; if(l==='de'||l==='en'||l==='es'||l==='fr'||l==='zh-cn')return l;
var base=l.split('-')[0]; var base=l.split('-')[0];
if(base==='de'||base==='en'||base==='es')return base; if(base==='de'||base==='en'||base==='es'||base==='fr')return base;
if(base==='zh'){ if(base==='zh'){
if(l.indexOf('cn')>=0||l.indexOf('hans')>=0||l==='zh')return 'zh-cn'; if(l.indexOf('cn')>=0||l.indexOf('hans')>=0||l==='zh')return 'zh-cn';
@@ -282,6 +284,7 @@ function applyLang(){
setText('d-lbl-remain',T.lbl_remaining); setText('d-lbl-remain',T.lbl_remaining);
setText('d-slicer-label',T.lbl_slicer_time); setText('d-slicer-label',T.lbl_slicer_time);
setText('d-lbl-layers',T.lbl_layers); setText('d-lbl-layers',T.lbl_layers);
setText('d-lbl-zpos',T.lbl_zpos);
setText('d-lbl-light',T.lbl_light); setText('d-lbl-light',T.lbl_light);
setText('d-lbl-nozzle',T.label_nozzle); setText('d-lbl-nozzle',T.label_nozzle);
setText('d-lbl-bed',T.label_bed); setText('d-lbl-bed',T.label_bed);
@@ -659,6 +662,7 @@ function applyState(){
var layers=s.curr_layer&&s.total_layers?'L '+s.curr_layer+' / '+s.total_layers:''; var layers=s.curr_layer&&s.total_layers?'L '+s.curr_layer+' / '+s.total_layers:'';
var dlayers=document.getElementById('d-layers');if(dlayers)dlayers.textContent=layers; var dlayers=document.getElementById('d-layers');if(dlayers)dlayers.textContent=layers;
var dzpos=document.getElementById('d-zpos');if(dzpos)dzpos.textContent=s.z_mm>0?s.z_mm.toFixed(2)+' mm':'';
var delapsed=document.getElementById('d-elapsed');if(delapsed)delapsed.textContent=fmtTime(s.print_duration); var delapsed=document.getElementById('d-elapsed');if(delapsed)delapsed.textContent=fmtTime(s.print_duration);
var dremain=document.getElementById('d-remain');if(dremain)dremain.textContent=s.remain_time>0?fmtTime(s.remain_time):''; var dremain=document.getElementById('d-remain');if(dremain)dremain.textContent=s.remain_time>0?fmtTime(s.remain_time):'';
@@ -822,10 +826,14 @@ function applyState(){
var co=document.getElementById('cam-overlay'); var co=document.getElementById('cam-overlay');
if(co)co.style.display=(s.print_state==='printing'&&camOn)?'block':'none'; if(co)co.style.display=(s.print_state==='printing'&&camOn)?'block':'none';
// auto-start camera during print // auto-start camera during print (unless user explicitly stopped it)
if(s.print_state==='printing'&&!camOn&&s.camera_url){ if(s.print_state==='printing'&&!camOn&&s.camera_url&&!camUserStopped){
camStart(); camStart();
} }
// reset user-stopped flag when print ends so next print auto-starts again
if(s.print_state!=='printing'){
camUserStopped=false;
}
updateConnBtn(); updateConnBtn();
} }
@@ -1437,7 +1445,8 @@ function setBed(){
function setLight(){ function setLight(){
var on=document.getElementById('d-light-toggle').checked; var on=document.getElementById('d-light-toggle').checked;
post('/api/light',{on:on,brightness:80}) post('/api/light',{on:on,brightness:80})
.then(function(){clog('Licht '+(on?'an, '+br+'%':'aus'),'msg-ok')}) .then(function(){clog('Licht '+(on?'an, 80%':'aus'),'msg-ok')})
.catch(function(e){clog('Licht-Fehler: '+e,'msg-err')}); .catch(function(e){clog('Licht-Fehler: '+e,'msg-err')});
} }
@@ -1518,11 +1527,13 @@ function camStart(){
function camStop(){ function camStop(){
var img=document.getElementById('cam-img'); var img=document.getElementById('cam-img');
img.onerror=null; // deregister error handler before clearing src to avoid spurious error toast
post('/api/camera/stop',{}).catch(function(){}); post('/api/camera/stop',{}).catch(function(){});
img.src=''; img.src='';
img.style.display='none'; img.style.display='none';
document.getElementById('cam-placeholder').style.display='flex'; document.getElementById('cam-placeholder').style.display='flex';
camOn=false; camOn=false;
camUserStopped=true; // suppress auto-restart for remainder of this print
document.getElementById('cam-toggle-btn').textContent=tr('btn_cam_start'); document.getElementById('cam-toggle-btn').textContent=tr('btn_cam_start');
clog(tr('log_cam_stop'),'msg-ok'); clog(tr('log_cam_stop'),'msg-ok');
} }

View File

@@ -38,6 +38,7 @@
<option value="de">Deutsch</option> <option value="de">Deutsch</option>
<option value="en">English</option> <option value="en">English</option>
<option value="es">Espanol</option> <option value="es">Espanol</option>
<option value="fr">Français</option>
<option value="zh-cn">中文(简体)</option> <option value="zh-cn">中文(简体)</option>
</select> </select>
</div> </div>
@@ -266,9 +267,15 @@
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div> <div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
<div style="display:flex;align-items:center;gap:10px;margin:8px 0"> <div style="display:flex;align-items:center;gap:10px;margin:8px 0">
<div class="progress-bar" style="flex:1;margin:0"><div class="progress-fill" id="d-pbar" style="width:0%"></div></div> <div class="progress-bar" style="flex:1;margin:0"><div class="progress-fill" id="d-pbar" style="width:0%"></div></div>
<div class="time-block" style="padding:6px 10px;min-width:72px;text-align:center;flex-shrink:0"> <div style="display:flex;flex-direction:column;gap:4px;flex-shrink:0">
<div class="time-label" id="d-lbl-layers"></div> <div class="time-block" style="padding:6px 10px;min-width:72px;text-align:center">
<div class="time-val" style="font-size:16px" id="d-layers"></div> <div class="time-label" id="d-lbl-layers"></div>
<div class="time-val" style="font-size:16px" id="d-layers"></div>
</div>
<div class="time-block" style="padding:4px 10px;min-width:72px;text-align:center">
<div class="time-label" id="d-lbl-zpos">Z</div>
<div class="time-val" style="font-size:13px" id="d-zpos"></div>
</div>
</div> </div>
</div> </div>
<div class="time-grid"> <div class="time-grid">

View File

@@ -37,6 +37,7 @@
"lbl_remaining": "Restzeit:", "lbl_remaining": "Restzeit:",
"lbl_slicer_time": "Slicer-Schätzung:", "lbl_slicer_time": "Slicer-Schätzung:",
"lbl_layers": "Layer", "lbl_layers": "Layer",
"lbl_zpos": "Z (mm)",
"speed_silent": "🐢 Leise", "speed_silent": "🐢 Leise",
"speed_normal": "⚡ Normal", "speed_normal": "⚡ Normal",
"speed_sport": "🚀 Sport", "speed_sport": "🚀 Sport",

View File

@@ -37,6 +37,7 @@
"lbl_remaining": "Remaining:", "lbl_remaining": "Remaining:",
"lbl_slicer_time": "Slicer estimate:", "lbl_slicer_time": "Slicer estimate:",
"lbl_layers": "Layer", "lbl_layers": "Layer",
"lbl_zpos": "Z (mm)",
"speed_silent": "🐢 Silent", "speed_silent": "🐢 Silent",
"speed_normal": "⚡ Normal", "speed_normal": "⚡ Normal",
"speed_sport": "🚀 Sport", "speed_sport": "🚀 Sport",

View File

@@ -37,6 +37,7 @@
"lbl_remaining": "Restante:", "lbl_remaining": "Restante:",
"lbl_slicer_time": "Estimación del slicer:", "lbl_slicer_time": "Estimación del slicer:",
"lbl_layers": "Capa", "lbl_layers": "Capa",
"lbl_zpos": "Z (mm)",
"speed_silent": "🐢 Silencioso", "speed_silent": "🐢 Silencioso",
"speed_normal": "⚡ Normal", "speed_normal": "⚡ Normal",
"speed_sport": "🚀 Sport", "speed_sport": "🚀 Sport",

249
web/translations/fr.json Normal file
View File

@@ -0,0 +1,249 @@
{
"header_status_standby": "Prêt",
"header_status_printing": "Impression",
"header_status_complete": "Terminé",
"header_status_error": "Erreur",
"kobra_free": "Disponible",
"kobra_busy": "Occupé",
"kobra_printing": "Impression",
"kobra_preheating": "Préchauffage",
"kobra_auto_leveling": "Mise à niveau auto",
"kobra_checking": "Vérification",
"kobra_updated": "Mise à jour",
"kobra_init": "Initialisation",
"kobra_pausing": "Pause en cours…",
"kobra_paused": "En pause",
"kobra_resuming": "Reprise en cours…",
"kobra_resumed": "Repris",
"kobra_stopping": "Arrêt en cours…",
"kobra_stoped": "Arrêté",
"kobra_finished": "Terminé",
"kobra_failed": "Erreur",
"kobra_canceled": "Annulé",
"kobra_offline": "Hors ligne",
"nav_dashboard": "Tableau de bord",
"nav_print": "Impression",
"nav_temps": "Températures",
"nav_motion": "Mouvement",
"nav_ams": "AMS",
"nav_extras": "Lumière / Ventilateur",
"nav_console": "Console",
"card_progress": "Progression",
"card_temps": "Températures",
"card_light_fan": "Ventilateur",
"card_speed": "Vitesse d'impression",
"card_cam": "Caméra",
"lbl_elapsed": "Écoulé :",
"lbl_remaining": "Restant :",
"lbl_slicer_time": "Estimation slicer :",
"lbl_layers": "Couche",
"lbl_zpos": "Z (mm)",
"speed_silent": "🐢 Silencieux",
"speed_normal": "⚡ Normal",
"speed_sport": "🚀 Sport",
"lbl_light": "💡 Lumière",
"lbl_feed": "Charger",
"lbl_unload": "Décharger",
"card_ace_dry": "Séchage ACE",
"ace_dry_dryer": "Séchoir",
"ace_dry_status_off": "Statut : Arrêté",
"ace_dry_status_on": "Statut : Actif",
"ace_dry_status_remaining": "Restant",
"ace_dry_humidity": "Humidité",
"ace_dry_current_temp": "Température",
"ace_dry_chart": "Historique (Temp/Humidité)",
"ace_dry_temp": "Température (°C)",
"ace_dry_duration": "Durée (min)",
"ace_dry_start": "▶ Démarrer",
"ace_dry_stop": "■ Arrêter",
"ace_dry_auto_refill": "Remplissage auto",
"ace_dry_enable": "Activer le séchage",
"ace_dry_temp_line": "Température de séchage",
"ace_dry_time_line": "Durée de séchage",
"ace_dry_ui_pending": "(Interface seule, backend suivant)",
"ace_dry_dialog_title": "Réglages Temp/Durée du séchoir",
"ace_dry_dialog_temp": "Température (30-80°C)",
"ace_dry_dialog_time": "Temps restant (h:m:s)",
"ace_dry_dialog_confirm": "Confirmer",
"ace_dry_dialog_cancel": "Annuler",
"ace_dry_dialog_save_restart": "Enregistrer et redémarrer",
"ace_dry_dialog_custom_name": "Nom personnalisé",
"ace_dry_dialog_reset_default": "Réinitialiser",
"cam_placeholder": "📷 Caméra non démarrée",
"cam_stream_unavailable": "Flux indisponible",
"btn_cam_start": "▶ Caméra",
"btn_cam_stop": "◼ Caméra",
"btn_pause": "⏸ Pause",
"btn_resume": "▶ Reprendre",
"btn_cancel": "✕ Arrêter",
"label_nozzle": "Buse",
"label_bed": "Plateau",
"label_fan": "🌀 Ventilateur",
"label_light": "💡 Lumière",
"label_on_off": "On / Off",
"label_speed": "Vitesse",
"panel_print_title": "Contrôle impression",
"panel_print_btn_pause": "⏸ Pause",
"panel_print_btn_resume": "▶ Reprendre",
"panel_print_btn_cancel": "✕ Annuler",
"panel_print_temps_live": "Températures (en direct)",
"label_set": "Définir",
"label_off": "Éteint",
"panel_temps_nozzle": "Buse",
"panel_temps_bed": "Plateau chauffant",
"panel_temps_chart": "Historique (60 dernières valeurs)",
"label_target_c": "Cible :",
"panel_motion_xy": "Axes XY",
"panel_motion_z": "Axe Z",
"label_step": "Pas :",
"btn_home_z": "Origine Z",
"btn_home_xy": "Origine XY",
"btn_home_all": "Origine Tout",
"btn_disable_motors": "Moteurs Off",
"panel_ams_title": "Filament",
"card_ams": "Filament",
"ams_no_data": "Aucune donnée AMS reçue",
"label_slot": "Slot",
"ams_empty": "Vide",
"panel_extras_light": "Lumière",
"panel_extras_fan": "Ventilateur",
"panel_extras_camera": "Caméra",
"btn_cam_start2": "▶ Démarrer",
"btn_cam_stop2": "◼ Arrêter",
"panel_console_title": "Journal d'événements",
"log_light_on": "Lumière allumée",
"log_light_off": "Lumière éteinte",
"log_fan": "Ventilateur →",
"log_nozzle": "Buse →",
"log_bed": "Plateau →",
"log_axis": "Axe",
"log_home": "Origine",
"log_home_all": "Origine Tout",
"log_cam_start": "Caméra démarrée :",
"log_cam_stop": "Caméra arrêtée",
"log_poll_error": "Erreur de sondage :",
"log_error": "Erreur :",
"confirm_cancel": "Vraiment annuler l'impression ?",
"settings_title": "Paramètres",
"settings_connection": "Connexion",
"settings_print": "Paramètres d'impression",
"settings_poll": "Intervalle de sondage",
"settings_version": "Version",
"settings_save": "Enregistrer et redémarrer",
"settings_printer_name": "Nom de l'imprimante",
"settings_printer_ip": "IP de l'imprimante",
"settings_mqtt_port": "Port MQTT",
"settings_username": "Nom d'utilisateur MQTT",
"settings_password": "Mot de passe MQTT",
"settings_device_id": "ID de l'appareil",
"settings_mode_id": "ID du mode",
"hint_ip_no_port": "Adresse IP uniquement, sans port (ex. 192.168.1.102)",
"settings_default_slot": "Slot par défaut (couleur unique)",
"settings_slot_auto": "Auto (tous les slots chargés)",
"settings_auto_leveling": "Mise à niveau auto avant impression",
"settings_camera_on_print": "Activer la caméra au démarrage de l'impression",
"settings_web_upload_warning": "Afficher un avertissement lors de l'impression de fichiers web",
"update_check": "Vérifier les mises à jour",
"update_checking": "Vérification…",
"update_available": "disponible",
"update_none": "Déjà à jour",
"update_apply": "Installer maintenant",
"update_applying": "Téléchargement…",
"update_restarting": "Redémarrage…",
"update_error": "Erreur",
"btn_connect": "⚡ Connecter",
"btn_disconnect": "✕ Déconnecter",
"lbl_conn_error": "Erreur de connexion :",
"slot_edit_title": "Modifier le slot",
"slot_edit_color": "Couleur",
"slot_edit_material": "Matériau",
"slot_edit_load": "⬇ Charger",
"slot_edit_unload": "⬆ Décharger",
"slot_edit_save": "💾 Enregistrer",
"slot_edit_custom": "ex. PLA, PETG, ABS…",
"slot_edit_ok": "Slot AMS",
"slot_edit_profile": "Profil OrcaSlicer",
"slot_edit_profile_hint": "Envoyé lors de la synchronisation OrcaSlicer comme marque spécifique au lieu de \"Générique\"",
"slot_edit_profile_default": "— Générique (défaut) —",
"orca_profile_section": "Profils OrcaSlicer",
"orca_profile_hint": "Importez vos propres profils de filament OrcaSlicer (ouvrez le dossier utilisateur via Aide → Afficher le dossier de configuration)",
"orca_profile_import_btn": "Importer des profils",
"orca_profile_import_link": "★ Importer mes profils…",
"orca_profile_import_title": "Importer vos profils OrcaSlicer",
"orca_profile_help_html": "Déposez un <b>ZIP</b> de votre dossier filament OrcaSlicer ou des fichiers <b>.json</b> individuels.<br>Dans OrcaSlicer : <i>Aide → Afficher le dossier de configuration → user/&lt;id&gt;/filament/</i>",
"orca_profile_dropmsg": "Déposez ici ou cliquez",
"orca_profile_list_label": "Profils importés",
"orca_profile_user_label": "Mes profils",
"orca_profile_user_empty": " aucun ",
"orca_profile_uploading": "Envoi en cours…",
"orca_profile_done": "Importé",
"orca_profile_skipped": "ignoré",
"log_dir_all": "Tout",
"log_lvl_label": "Niveau :",
"file_ready_btn": "▶ Lancer l'impression",
"file_slots_btn": "🎨 Choisir les slots",
"file_cancel_btn": "✕ Annuler",
"nav_printers": "Imprimantes",
"skip_title": "✂ Ignorer des objets",
"skip_hint": "Décochez les objets que vous ne souhaitez plus imprimer :",
"skip_btn_label": "Objets",
"skip_no_objects": "Aucun objet dans cette impression.",
"skip_already": "ignoré",
"skip_select_at_least_one": "Veuillez sélectionner au moins un objet.",
"skip_sending": "Envoi …",
"skip_success": "Les objets seront ignorés.",
"fd_objects_hint": "Ignorer des objets (optionnel) :",
"fd_slots_hint": "Associer le canal GCode au slot AMS :",
"fd_cancel": "Annuler",
"fd_print": "▶ Imprimer",
"fd_no_slots_msg": "Aucun slot AMS chargé.{br}Lancer l'impression quand même ?",
"fd_slot": "Slot",
"fd_no_matching_material": "Aucun matériau correspondant",
"fd_used": "UTILISÉ",
"add_printer": "Ajouter une imprimante",
"apd_lbl_ip": "IP de l'imprimante",
"apd_lbl_name": "Nom (optionnel)",
"apd_placeholder_name": "ex. Kobra X Salon",
"apd_cancel": "Annuler",
"apd_confirm": "Ajouter",
"apd_fetching": "Récupération des données de l'imprimante…",
"apd_success": "Imprimante ajoutée, redémarrage du bridge…",
"apd_err_ip": "Veuillez saisir une adresse IP",
"printers_remove": "Supprimer l'imprimante",
"printers_remove_confirm": "Supprimer l'imprimante \"{name}\" ? Le bridge va redémarrer.",
"printers_active": "● actif",
"printers_switch": "Changer →",
"printers_current": "Imprimante actuelle",
"printers_loading": "Chargement…",
"printers_none": "Aucune imprimante configurée.",
"printers_empty_hint": "Aucune imprimante configurée.",
"nav_browser": "Navigateur",
"panel_browser_title": "Explorateur de fichiers",
"store_search_placeholder": "🔍 Rechercher…",
"store_empty": "Aucun fichier uploadé.",
"store_refresh": "↻ Actualiser",
"store_print": "▶ Imprimer",
"store_download": "⬇ Télécharger",
"store_delete_confirm": "Supprimer le fichier ?",
"store_print_confirm": "Imprimer le fichier ?",
"store_web_verify_title": "Vérifier le fichier",
"store_web_verify_msg": "Veuillez vérifier que ce fichier a été créé pour l'Anycubic Kobra X.",
"store_web_verify_confirm": "Confirmer",
"store_web_verify_abort": "Annuler",
"store_no_results": "Aucun fichier trouvé.",
"store_never": "jamais imprimé",
"store_estimate": "Estimation",
"store_upload_label_prefix": "Déposez un GCode ici ou ",
"store_upload_label_browse": "parcourir",
"store_upload_busy": "⏳ Envoi en cours…",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"sf_all": "Tout",
"sf_ok": "✓ Terminés",
"sf_err": "✗ Échoués",
"sf_new": "Nouveau",
"ss_date": "↓ Date",
"ss_name": "AZ Nom",
"ss_dur": "⏱ Durée d'impression"
}

View File

@@ -37,6 +37,7 @@
"lbl_remaining": "剩余时间:", "lbl_remaining": "剩余时间:",
"lbl_slicer_time": "切片预估:", "lbl_slicer_time": "切片预估:",
"lbl_layers": "层", "lbl_layers": "层",
"lbl_zpos": "Z (mm)",
"speed_silent": "🐢 静音", "speed_silent": "🐢 静音",
"speed_normal": "⚡ 标准", "speed_normal": "⚡ 标准",
"speed_sport": "🚀 运动", "speed_sport": "🚀 运动",