Compare commits

3 Commits

16 changed files with 812 additions and 52 deletions

View File

@@ -1,5 +1,30 @@
# 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 ## [0.9.19.1] 2026-06-04
### Behoben ### Behoben

View File

@@ -1,24 +1,51 @@
# 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 ## [0.9.19.1] 2026-06-04
### Fixed ### Fixed
- Standalone-Binaries (Linux/Windows) zeigten `vunknown` als Version. - Standalone binaries (Linux/Windows) reported `vunknown` as their
Die `VERSION`-Datei ist jetzt ins PyInstaller-Onefile eingebettet. version. The `VERSION` file is now embedded into the PyInstaller
- Bei fehlenden TLS-Zertifikaten (`anycubic_slicer.crt`/`.key`) gab onefile bundle.
es nur den rohen Fehler `[Errno 2] No such file or directory`. Die - When the TLS certificates (`anycubic_slicer.crt`/`.key`) were
Bridge meldet jetzt klar, wo die Dateien hingelegt werden müssen missing, the bridge only logged the raw `[Errno 2] No such file
und dass `anycubic-certs.zip` aus dem Gitea-Release stammt. 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 ### Changed
- Filament-Profil-Liste neu kuratiert: 209 statt 399 Einträge. - Filament profile list re-curated: 209 entries instead of 399.
Profile die nur für drucker-spezifische Vendor-Bundles existieren Profiles that only exist inside printer-specific vendor bundles
(z.B. Eryone Thinker X400, Artillery M1 Pro, WonderMaker ZR, (e.g. Eryone Thinker X400, Artillery M1 Pro, WonderMaker ZR,
Tiertime, Cubicon, CoLiDo, Afinia, Snapmaker) sind rausgeflogen Tiertime, Cubicon, CoLiDo, Afinia, Snapmaker) were dropped —
OrcaSlicer hätte sie im Standard-Kobra-X-Setup beim Sync OrcaSlicer wouldn't have found them in a default Kobra X setup
ohnehin nicht gefunden, weil die jeweiligen Vendor-Bundles nur anyway, because the matching vendor bundle is only loaded when
bei aktivem Drucker-Vendor geladen werden. Für solche Filamente the corresponding printer vendor is active. For those filaments
bleibt der Custom-Profile-Import (Issue #41) der Weg. the custom profile import (issue #41) remains the way.
## [0.9.19] 2026-06-02 ## [0.9.19] 2026-06-02

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

@@ -130,7 +130,7 @@ Für sauberen AMS-Filament-Sync gibt es einen **gepatchten OrcaSlicer-Build**:
- Vendor-Match wenn `tray_info_idx` gesetzt ist, das Preset aber inkompatibel - Vendor-Match wenn `tray_info_idx` gesetzt ist, das Preset aber inkompatibel
- Zwei-Pass-Suche: erst kompatible Presets, dann alle sichtbaren - Zwei-Pass-Suche: erst kompatible Presets, dann alle sichtbaren
**Warum das zusammen wichtig ist:** ohne #13719 landen die AMS-Slots in OrcaSlicer alle auf `Generic PLA` / `Generic PETG`, obwohl die Bridge die konkrete Marke schon mitsendet (`name + vendor_name + gate_filament_name`). Mit dem KX-Build matched OrcaSlicer deine echten User-Presets — auch die, die du via [Eigene OrcaSlicer-Profile importieren](#-features) in die Bridge gezogen hast. **Warum das zusammen wichtig ist:** ohne #13719 landen die AMS-Slots in OrcaSlicer alle auf `Generic PLA` / `Generic PETG`, obwohl die Bridge die konkrete Marke schon mitsendet (`name + vendor_name + gate_filament_name`). Mit dem KX-Build matched OrcaSlicer deine echten User-Presets — auch die, die du via [Eigene OrcaSlicer-Profile importieren](docs/filament-preset-bridge-guide.md) in die Bridge gezogen hast.
Stock-Upstream-OrcaSlicer funktioniert für Slicing und Drucken weiterhin — nur das Per-Slot-Vendor-Matching beim AMS-Sync fällt dann weg. Material und Farbe pro Slot kannst du auch ohne den KX-Build über die Bridge ans Drucker-Display schreiben (das läuft über MQTT, nicht über den Slicer). Stock-Upstream-OrcaSlicer funktioniert für Slicing und Drucken weiterhin — nur das Per-Slot-Vendor-Matching beim AMS-Sync fällt dann weg. Material und Farbe pro Slot kannst du auch ohne den KX-Build über die Bridge ans Drucker-Display schreiben (das läuft über MQTT, nicht über den Slicer).

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

@@ -129,7 +129,7 @@ For proper AMS filament-sync we ship a **patched OrcaSlicer build**:
- Vendor match when `tray_info_idx` is set but its preset is incompatible - Vendor match when `tray_info_idx` is set but its preset is incompatible
- Two-pass lookup: first compatible presets, then all visible ones - Two-pass lookup: first compatible presets, then all visible ones
**Why this matters:** without #13719 the AMS slots in OrcaSlicer all fall back to `Generic PLA` / `Generic PETG` even though the bridge already sends the concrete brand (`name + vendor_name + gate_filament_name`). With the KX build OrcaSlicer matches your actual user presets — including profiles you imported into the bridge via the [Import your own OrcaSlicer profiles](#-features) flow. **Why this matters:** without #13719 the AMS slots in OrcaSlicer all fall back to `Generic PLA` / `Generic PETG` even though the bridge already sends the concrete brand (`name + vendor_name + gate_filament_name`). With the KX build OrcaSlicer matches your actual user presets — including profiles you imported into the bridge via the [Import your own OrcaSlicer profiles](docs/filament-preset-bridge-guide.md) flow.
Stock upstream OrcaSlicer still works for slicing and printing — you just lose the per-slot brand matching on AMS sync. Slot material + colour can still be pushed bridge → printer either way (that goes over MQTT, not via the slicer). Stock upstream OrcaSlicer still works for slicing and printing — you just lose the per-slot brand matching on AMS sync. Slot material + colour can still be pushed bridge → printer either way (that goes over MQTT, not via the slicer).

View File

@@ -1 +1 @@
0.9.19.1 0.9.20

View File

@@ -0,0 +1,344 @@
# Eigene Filament-Presets anlegen, prüfen und mit KX-Bridge verknüpfen
> **Gilt für:** OrcaSlicer-KX v2.4.0-alpha-kx2 oder neuer
---
## Was ist die `filament_id` und warum ist sie wichtig?
Jedes Filament-Preset in OrcaSlicer hat eine interne `filament_id`. Diese ID wird von der KX-Bridge genutzt, um beim AMS-Sync das richtige Preset zuzuordnen.
- System-Presets (z.B. "Polymaker PolyTerra PLA") haben eine feste ID wie `GFL99` oder `OGFL04`.
- **Eigene (User-)Presets** bekommen in OrcaSlicer-KX automatisch eine eindeutige ID, die mit `P` beginnt (z.B. `P3a7f2c1`).
Ohne eindeutige ID zeigt OrcaSlicer beim Sync immer "Generic PLA" — auch wenn das Preset existiert.
---
## 1. Eigenes Filament-Preset anlegen
1. OrcaSlicer-KX starten
2. Rechts oben im **Filament-Dropdown** ein passendes Basis-Preset wählen (z.B. "Generic PLA" oder ein Hersteller-Preset)
3. Einstellungen nach Wunsch anpassen (Temperaturen, Kühlung, etc.)
4. Auf das **Speichern-Symbol** (Diskette) klicken → **"Save as new preset"**
5. Namen eingeben — z.B. `SUNLU PLA+ 2.0`
> Der Name muss später exakt so in der Bridge eingetragen werden.
6. Drucker auswählen: **Anycubic Kobra X 0.4 nozzle** — wichtig für die Kompatibilität!
7. **Speichern** klicken
8. OrcaSlicer **einmal neu starten** — erst dann wird die `filament_id` dauerhaft gespeichert.
---
## 2. Eindeutige ID prüfen
Nach dem Neustart prüfen, ob die ID korrekt gesetzt wurde:
**Windows:**
```
%APPDATA%\OrcaSlicer\user\default\filament\SUNLU PLA+ 2.0.json
```
**Linux:**
```
~/.config/OrcaSlicer/user/default/filament/SUNLU PLA+ 2.0.json
```
Die Datei öffnen und nach `filament_id` suchen:
```json
{
"filament_id": "P3a7f2c1",
...
}
```
✅ Korrekt: ID beginnt mit `P` gefolgt von 7 Hex-Zeichen
❌ Fehlt oder leer: OrcaSlicer-KX zu alt — Update auf v2.4.0-alpha-kx2 oder neuer
---
## 3. Preset auf einen anderen PC übertragen (Import)
### Exportieren (Quell-PC)
Die Preset-Datei einfach kopieren:
**Windows:**
```
%APPDATA%\OrcaSlicer\user\default\filament\SUNLU PLA+ 2.0.json
```
**Linux:**
```
~/.config/OrcaSlicer/user/default/filament/SUNLU PLA+ 2.0.json
```
### Importieren (Ziel-PC)
**Methode A — Datei direkt kopieren:**
1. Die `.json`-Datei in das gleiche Verzeichnis auf dem Ziel-PC kopieren
2. OrcaSlicer neu starten → Preset erscheint im Dropdown
**Methode B — OrcaSlicer Import-Funktion:**
1. In OrcaSlicer: **File → Import → Import Configs...**
2. Die `.json`-Datei auswählen
3. OrcaSlicer neu starten
> **Wichtig:** Die `filament_id` in der Datei bleibt erhalten — das Preset wird auf dem Ziel-PC genauso erkannt wie auf dem Quell-PC.
---
## 4. Preset in KX-Bridge verknüpfen
1. KX-Bridge UI öffnen
2. **Filament-Verwaltung** → AMS-Slot auswählen
3. Im Feld **Filament-Name** exakt den OrcaSlicer-Preset-Namen eintragen:
```
SUNLU PLA+ 2.0
```
4. Speichern
Die Bridge sendet beim Sync `filament_name: "SUNLU PLA+ 2.0"` → OrcaSlicer findet das Preset anhand von Name und `filament_id` → zeigt es korrekt an.
---
## Wichtige Hinweise
| Was | Warum |
|-----|-------|
| Name in OrcaSlicer und Bridge müssen **exakt** übereinstimmen | Groß-/Kleinschreibung und Sonderzeichen werden verglichen |
| Preset muss für **Anycubic Kobra X 0.4 nozzle** kompatibel sein | Beim Speichern den richtigen Drucker auswählen |
| Nach dem ersten Speichern OrcaSlicer **neu starten** | Erst dann wird die `filament_id` persistent geschrieben |
| **OrcaSlicer-KX v2.4.0-alpha-kx2** oder neuer verwenden | Ältere Versionen generieren keine eindeutige `filament_id` für User-Presets |
---
---
# How to Create, Verify and Import Custom Filament Presets for KX-Bridge
> **Requires:** OrcaSlicer-KX v2.4.0-alpha-kx2 or newer
---
## What is the `filament_id` and why does it matter?
Every filament preset in OrcaSlicer has an internal `filament_id`. The KX-Bridge uses this ID to match the correct preset during AMS sync.
- System presets (e.g. "Polymaker PolyTerra PLA") have a fixed ID like `GFL99` or `OGFL04`.
- **Custom (user) presets** automatically receive a unique ID starting with `P` (e.g. `P3a7f2c1`) in OrcaSlicer-KX.
Without a unique ID, OrcaSlicer will always show "Generic PLA" during sync — even if the preset exists.
---
## 1. Create a Custom Filament Preset
1. Launch OrcaSlicer-KX
2. Select a suitable base preset from the **filament dropdown** (e.g. "Generic PLA" or a vendor preset)
3. Adjust settings as needed (temperatures, cooling, etc.)
4. Click the **save icon** (floppy disk) → **"Save as new preset"**
5. Enter a name — e.g. `SUNLU PLA+ 2.0`
> This name must be entered in the bridge exactly as typed here.
6. Select printer: **Anycubic Kobra X 0.4 nozzle** — required for compatibility!
7. Click **Save**
8. **Restart OrcaSlicer once** — the `filament_id` is only written permanently after a restart.
---
## 2. Verify the Unique ID
After restarting, check that the ID was set correctly:
**Windows:**
```
%APPDATA%\OrcaSlicer\user\default\filament\SUNLU PLA+ 2.0.json
```
**Linux:**
```
~/.config/OrcaSlicer/user/default/filament/SUNLU PLA+ 2.0.json
```
Open the file and look for `filament_id`:
```json
{
"filament_id": "P3a7f2c1",
...
}
```
✅ Correct: ID starts with `P` followed by 7 hex characters
❌ Missing or empty: Your OrcaSlicer-KX version is too old — update to v2.4.0-alpha-kx2 or newer
---
## 3. Transfer a Preset to Another PC (Import)
### Export (source PC)
Simply copy the preset file:
**Windows:**
```
%APPDATA%\OrcaSlicer\user\default\filament\SUNLU PLA+ 2.0.json
```
**Linux:**
```
~/.config/OrcaSlicer/user/default/filament/SUNLU PLA+ 2.0.json
```
### Import (target PC)
**Method A — Copy file directly:**
1. Copy the `.json` file to the same directory on the target PC
2. Restart OrcaSlicer → preset appears in the dropdown
**Method B — OrcaSlicer import function:**
1. In OrcaSlicer: **File → Import → Import Configs...**
2. Select the `.json` file
3. Restart OrcaSlicer
> **Note:** The `filament_id` inside the file is preserved — the preset will be recognized on the target PC exactly as on the source PC.
---
## 4. Link the Preset in KX-Bridge
1. Open the KX-Bridge UI
2. Go to **Filament Management** → select the AMS slot
3. In the **Filament Name** field, enter the OrcaSlicer preset name exactly:
```
SUNLU PLA+ 2.0
```
4. Save
The bridge sends `filament_name: "SUNLU PLA+ 2.0"` during sync → OrcaSlicer matches by name and `filament_id` → displays the preset correctly.
---
## Quick Reference
| What | Why |
|------|-----|
| Name in OrcaSlicer and Bridge must match **exactly** | Case and special characters are compared |
| Preset must be compatible with **Anycubic Kobra X 0.4 nozzle** | Select the correct printer when saving |
| **Restart OrcaSlicer** after saving for the first time | The `filament_id` is only written persistently after a restart |
| Use **OrcaSlicer-KX v2.4.0-alpha-kx2** or newer | Older versions do not generate a unique `filament_id` for user presets |
---
---
# Cómo crear, verificar e importar perfiles de filamento personalizados para KX-Bridge
> **Requiere:** OrcaSlicer-KX v2.4.0-alpha-kx2 o superior
---
## ¿Qué es el `filament_id` y por qué es importante?
Cada perfil de filamento en OrcaSlicer tiene un `filament_id` interno. KX-Bridge usa este ID para asignar el perfil correcto durante la sincronización AMS.
- Los perfiles del sistema (p. ej. "Polymaker PolyTerra PLA") tienen un ID fijo como `GFL99` o `OGFL04`.
- Los **perfiles personalizados (usuario)** reciben automáticamente un ID único que empieza por `P` (p. ej. `P3a7f2c1`) en OrcaSlicer-KX.
Sin un ID único, OrcaSlicer mostrará siempre "Generic PLA" durante la sincronización, aunque el perfil exista.
---
## 1. Crear un perfil de filamento personalizado
1. Iniciar OrcaSlicer-KX
2. Seleccionar un perfil base adecuado en el **menú desplegable de filamento** (p. ej. "Generic PLA" o un perfil de fabricante)
3. Ajustar la configuración según sea necesario (temperaturas, refrigeración, etc.)
4. Hacer clic en el **icono de guardar** (disquete) → **"Save as new preset"**
5. Introducir un nombre — p. ej. `SUNLU PLA+ 2.0`
> Este nombre debe introducirse en la bridge exactamente igual.
6. Seleccionar impresora: **Anycubic Kobra X 0.4 nozzle** — ¡necesario para la compatibilidad!
7. Hacer clic en **Guardar**
8. **Reiniciar OrcaSlicer una vez** — el `filament_id` solo se escribe de forma permanente tras un reinicio.
---
## 2. Verificar el ID único
Tras reiniciar, comprobar que el ID se ha establecido correctamente:
**Windows:**
```
%APPDATA%\OrcaSlicer\user\default\filament\SUNLU PLA+ 2.0.json
```
**Linux:**
```
~/.config/OrcaSlicer/user/default/filament/SUNLU PLA+ 2.0.json
```
Abrir el archivo y buscar `filament_id`:
```json
{
"filament_id": "P3a7f2c1",
...
}
```
✅ Correcto: el ID empieza por `P` seguido de 7 caracteres hexadecimales
❌ Falta o está vacío: la versión de OrcaSlicer-KX es demasiado antigua — actualizar a v2.4.0-alpha-kx2 o superior
---
## 3. Transferir un perfil a otro PC (importar)
### Exportar (PC de origen)
Simplemente copiar el archivo del perfil:
**Windows:**
```
%APPDATA%\OrcaSlicer\user\default\filament\SUNLU PLA+ 2.0.json
```
**Linux:**
```
~/.config/OrcaSlicer/user/default/filament/SUNLU PLA+ 2.0.json
```
### Importar (PC de destino)
**Método A — Copiar el archivo directamente:**
1. Copiar el archivo `.json` al mismo directorio en el PC de destino
2. Reiniciar OrcaSlicer → el perfil aparece en el menú desplegable
**Método B — Función de importación de OrcaSlicer:**
1. En OrcaSlicer: **File → Import → Import Configs...**
2. Seleccionar el archivo `.json`
3. Reiniciar OrcaSlicer
> **Nota:** El `filament_id` dentro del archivo se conserva — el perfil se reconocerá en el PC de destino exactamente igual que en el de origen.
---
## 4. Vincular el perfil en KX-Bridge
1. Abrir la interfaz de KX-Bridge
2. Ir a **Gestión de filamentos** → seleccionar la ranura AMS
3. En el campo **Nombre de filamento**, introducir el nombre exacto del perfil de OrcaSlicer:
```
SUNLU PLA+ 2.0
```
4. Guardar
La bridge envía `filament_name: "SUNLU PLA+ 2.0"` durante la sincronización → OrcaSlicer busca por nombre y `filament_id` → muestra el perfil correctamente.
---
## Referencia rápida
| Qué | Por qué |
|-----|---------|
| El nombre en OrcaSlicer y en Bridge debe coincidir **exactamente** | Se comparan mayúsculas, minúsculas y caracteres especiales |
| El perfil debe ser compatible con **Anycubic Kobra X 0.4 nozzle** | Seleccionar la impresora correcta al guardar |
| **Reiniciar OrcaSlicer** tras guardar por primera vez | El `filament_id` solo se escribe de forma permanente tras un reinicio |
| Usar **OrcaSlicer-KX v2.4.0-alpha-kx2** o superior | Las versiones anteriores no generan un `filament_id` único para perfiles de usuario |

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)
@@ -4117,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)
@@ -4394,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",
}]} }]}
@@ -4448,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:
@@ -4762,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:
@@ -4771,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

@@ -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": "🚀 运动",