Compare commits

...

18 Commits

Author SHA1 Message Date
Phil Merricks
d7f4983d0d fix: slot assignment dialog now matches "PLA Silk", "Matte PLA" etc. to PLA slots
_normalizeMaterialKey now scans all space-separated words in a slot label and
returns the first known base material type found. This handles both "modifier
first" ("Matte PLA") and "modifier last" ("PLA Silk", "PLA Matte") patterns,
which arise when users label slots with full product-style names while OrcaSlicer
writes only the base type (PLA, PETG, …) in GCode comments.

Dash-suffix composites ("PLA-CF", "PETG-CF") contain no space and are unchanged,
preserving correct incompatibility with their base types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 20:47:45 +01:00
891b679230 .gitea/ISSUE_TEMPLATE/bug_report.yml hinzugefügt 2026-06-18 15:01:05 +02:00
8ae218321f .gitea/ISSUE_TEMPLATE/bug_report.ym hinzugefügt 2026-06-18 14:56:56 +02:00
ada027bc04 .gitea/ISSUE_TEMPLATE/bug_report.yml 2026-06-18 14:54:02 +02:00
eea570052f build: sources for v0.9.25 2026-06-17 07:15:31 +02:00
303297bfbf build: sources for v0.9.24 2026-06-16 21:45:34 +02:00
6b9ad9d426 build: sources for v0.9.23 2026-06-16 15:15:47 +02:00
ed30568092 build: sources for v0.9.22 2026-06-16 13:12:04 +02:00
1f300589d1 build: sources for v0.9.21 2026-06-14 11:00:32 +02:00
930e3774af docs: Portainer-Deployment-Anleitung + docker-compose.portainer.yml
Named-Volume-Compose ohne ENV-Pflichtfelder — Bridge startet im
Offline-Modus, User trägt Drucker-IP in der UI ein.
2026-06-13 00:01:07 +02:00
636889bdbc docs: Filament-Preset-Anleitung als docs/filament-preset-bridge-guide.md hinzugefügt + README-Links aktualisiert 2026-06-09 12:41:23 +02:00
3f6ea269e6 build: sources for v0.9.20 2026-06-08 23:14:50 +02:00
3fff6e25f0 docs(readme): explicit warning that standalone binaries need anycubic_slicer.crt + .key next to the executable (from anycubic-certs.zip) 2026-06-04 20:34:41 +02:00
0f5a8cbc72 sync: kobrax_moonraker_bridge.py mit Dev-Repo (VERSION-Lookup-Fix) 2026-06-04 12:17:46 +02:00
a40f14af8e build: sources for v0.9.19.1 2026-06-04 11:40:02 +02:00
466b8c518d build: sources for v0.9.19.1 2026-06-03 13:12:30 +02:00
1c5396b37d fix(spec): VERSION ins Onefile einbetten — Windows-EXE zeigte vunknown 2026-06-03 13:08:14 +02:00
c23deebde5 docs(changelog): remove internal test-profile name 'Bert - PLA' 2026-06-02 14:34:55 +02:00
24 changed files with 2450 additions and 1618 deletions

View File

@@ -0,0 +1,82 @@
name: "Bug Report"
about: "Report a problem or error"
title: "[BUG] "
labels:
- bug
body:
- type: dropdown
id: os
attributes:
label: "Operating System"
options:
- "Windows 10"
- "Windows 11"
- "Ubuntu / Debian"
- "Fedora / RHEL"
- "Arch Linux"
- "macOS"
- "Other"
validations:
required: true
- type: dropdown
id: install_type
attributes:
label: "Installation Type"
options:
- "Docker / Docker Compose"
- "EXE / Binary (Windows)"
- "Binary (Linux)"
validations:
required: true
- type: dropdown
id: slicer
attributes:
label: "Slicer"
options:
- "OrcaSlicer (KX-Patch)"
- "OrcaSlicer (Standard)"
- "BambuStudio"
- "PrusaSlicer"
- "Other"
validations:
required: true
- type: input
id: slicer_version
attributes:
label: "Slicer Version"
placeholder: "e.g. 2.4.0-dev-kx1"
validations:
required: true
- type: input
id: bridge_version
attributes:
label: "KX-Bridge Version"
placeholder: "e.g. v0.9.20"
description: "Found in the web interface or in the logs at startup."
validations:
required: true
- type: textarea
id: description
attributes:
label: "Problem Description"
description: "What is happening? What did you expect to happen?"
placeholder: |
What happened:
What was expected:
validations:
required: true
- type: textarea
id: logs
attributes:
label: "Logs / Error Message"
description: "Relevant output from the KX-Bridge logs. Docker: `docker logs <container>`"
render: text
validations:
required: false

View File

@@ -0,0 +1,85 @@
name: Bug Report
about: File a bug report
title: "[Bug]: "
body:
- type: markdown
attributes:
value: |
Please fill out all required fields to help us resolve your issue faster.
- type: dropdown
id: os
attributes:
label: Operating System
options:
- Windows 10
- Windows 11
- Ubuntu / Debian
- Fedora / RHEL
- Arch Linux
- macOS
- Other
validations:
required: true
- type: dropdown
id: install_type
attributes:
label: Installation Type
options:
- Docker / Docker Compose
- EXE / Binary (Windows)
- Binary (Linux)
validations:
required: true
- type: dropdown
id: slicer
attributes:
label: Slicer
options:
- OrcaSlicer (KX-Patch)
- OrcaSlicer (Standard)
- BambuStudio
- PrusaSlicer
- Other
validations:
required: true
- type: input
id: slicer_version
attributes:
label: Slicer Version
placeholder: "e.g. 2.4.0-dev-kx1"
validations:
required: true
- type: input
id: bridge_version
attributes:
label: KX-Bridge Version
description: "Found in the web interface or in the logs at startup."
placeholder: "e.g. v0.9.20"
validations:
required: true
- type: textarea
id: description
attributes:
label: Problem Description
description: What is happening? What did you expect to happen?
placeholder: |
What happened:
What was expected:
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / Error Message
description: "Relevant output from the KX-Bridge logs. Docker: `docker logs <container>`"
render: shell
validations:
required: false

View File

@@ -1,5 +1,142 @@
# Changelog
## [0.9.25] 2026-06-17
### Behoben
- **Zufällige Abstürze / Container-Restarts — Segfault in `libcrypto.so.3`
(Issue #53).** Der MQTT-über-TLS-Client teilte einen einzelnen SSL-Socket
zwischen dem Reader-Thread (`recv`) und den Sender-Threads (`sendall`), ohne sie
zu serialisieren. CPythons `ssl`-Modul erlaubt kein gleichzeitiges Lesen und
Schreiben auf demselben Socket — die Überlappung korrumpierte den internen
OpenSSL-Zustand und löste eine Heap-Corruption + Segfault aus, die auf manchen
Hosts timing-bedingt zuverlässig auftrat. Sämtliche Socket-Zugriffe (recv /
sendall / close / reconnect) werden nun unter einem einzigen Lock serialisiert;
der Reader prüft die Bereitschaft mit `select()` außerhalb des Locks, damit die
Sender nie ausgehungert werden. Reconnect und Disconnect tauschen den Socket
jetzt atomar. Dank an @BasK für den detaillierten Fault-Handler-Trace.
- **File-Browser akzeptierte Nicht-GCode-Uploads (Issue #59).** Drag & Drop umging
den `accept`-Filter des Dateidialogs, sodass z.B. ein JPG hochgeladen werden
konnte. Uploads werden jetzt client- und serverseitig validiert; nur `.gcode`,
`.gcode.3mf`, `.3mf` und `.bgcode` werden akzeptiert. Dank an @gangoke.
## [0.9.24] 2026-06-16
### Neu
- **Objekte überspringen in jedem Druck-Flow (Issue #57).** Der „Objekte
überspringen"-Bereich im Slot-Mapper erschien bisher nur beim Druck aus dem
Browser-Tab. Er ist jetzt in allen Flows verfügbar (inkl. Upload / Print-Leiste),
standardmäßig eingeklappt hinter einem `✂ Objekte überspringen (N)`-Header, damit
der Dialog kompakt bleibt — Klick klappt Vorschau + Checkliste auf.
- **Slot-Mapper zeigt konkreten Profilnamen (Issue #57).** Jeder Slot zeigt nun das
zugeordnete Filament-Profil (z.B. „PolyTerra PLA — Polymaker") in den Dropdown-
Optionen und als Hover-Tooltip am Slot-Marker, statt nur des generischen Typs.
Fällt auf den generischen Typ zurück, wenn kein Profil gemappt ist.
## [0.9.23] 2026-06-16
### Neu
- **Druckdialog nach Upload automatisch öffnen.** Eine neue Einstellung
`print_start_dialog` (Einstellungen → Drucker → „Druckstart-Verhalten") steuert,
was nach einem Upload bei leerlaufendem Drucker passiert: „Print-Dialog" öffnet
den Slot-Zuordnungs-Dialog automatisch, „Print-Leiste" behält das bisherige
Banner. Basiert auf PR #56 von @gangoke.
- **Auto-Leveling-Schalter pro Druck.** Der Druckdialog hat jetzt eine eigene
Auto-Leveling-Checkbox, die den globalen Standard für einen einzelnen Druck
überschreibt.
### Behoben
- **Objekt-Skip wurde beim Druckstart still ignoriert (PR #56, @gangoke).** Der
Skip-Befehl wurde gesendet, *bevor* der Drucker im `printing`-Status war, und
daher verworfen. Der Skip wird nun in einer Retry-Schleife erneut angewendet,
sobald der Druck bestätigt läuft — mit einer Pending-Sperre, damit die UI den
Skip-Status nicht vorzeitig zurücksetzt.
- **Upload während eines laufenden Drucks überschrieb die Vorschau des laufenden
Auftrags.** Ein neuer Upload während des Drucks ersetzt nicht mehr Thumbnail /
file_ready des Auftrags auf dem Druckbett.
## [0.9.22] 2026-06-16
### Neu
- **Neu strukturiertes Einstellungs-Panel.** Das Einstellungs-Modal wurde durch
ein dauerhaftes Master-Detail-Panel mit fünf Kategorien ersetzt: Verbindung,
Drucker, Darstellung, Filament und System. Das Poll-Intervall ist nun live
einstellbar.
- **Vendor-Sichtbarkeitsfilter (Issue #41).** Eine neue Checkliste in den
Filament-Einstellungen beschränkt das Slot-Profil-Dropdown auf bestimmte
Hersteller. „Generic" und eigene importierte Profile sind immer sichtbar.
- **Idle-Datei-Aktionen in der Fortschritts-Karte (Issue #55).** Nach einem
Upload bei leerlaufendem Drucker erscheinen drei Schnellaktionen direkt in der
Fortschritts-Karte: ▶ Drucken, ⚙ Slots zuordnen und ✕ Leeren.
### Behoben
- **Mobileraker-Kompatibilität (Issue #48).** Absturz in `ConfigExtruder.fromJson`
(leeres `configfile.config`), Hänger beim Refresh (Metadata-Endlosschleife) und
fehlende ETA/Restzeit behoben.
## [0.9.21] 2026-06-14
### Behoben
- **Kamera-Stream auf Android (Chrome / Firefox) nicht sichtbar.** Android-Browser
unterstützen `multipart/x-mixed-replace` (MJPEG) nicht. Die UI erkennt Android
jetzt automatisch und fällt auf Snapshot-Polling mit 5 fps zurück
(`/api/camera/snapshot` alle 200 ms) — keine Server-Änderung nötig.
### Geändert
- Docker-Image auf **Debian 12 (Bookworm)** gepinnt (`python:3.11-slim-bookworm`),
um Kompatibilitätsprobleme mit glibc 2.41 zu vermeiden, die das aktuell von
`python:3.11-slim` gezogene Debian 13 Basis-Image mitbringt.
- MQTT- und HTTP-Verbindungen erzwingen jetzt **IPv4** (`AF_INET`), um
Verbindungsfehler auf Hosts zu verhindern, bei denen der Drucker nur über IPv4
erreichbar ist, das OS aber IPv6 bevorzugt.
- Extruder-Stub in der Moonraker-`configfile`-Antwort enthält jetzt `sensor_type`
und `filament_diameter` — behebt einen Mobileraker-Absturz
(`Null is not a subtype of Object`, Issue #48).
## [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
### Neu
@@ -19,9 +156,9 @@
wird beim Sync an Orca als User-Match weitergegeben. Funktioniert
ü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
`inherits: "Generic PLA @System"` + ein paar Tweaks (z.B.
„Bert - PLA") werden korrekt erkannt — die Bridge resolved die
vererbten Felder aus dem System-Parent.
`inherits: "Generic PLA @System"` + ein paar Tweaks werden korrekt
erkannt — die Bridge resolved die vererbten Felder aus dem
System-Parent.
### Fixes
- **AMS-Sync landete hartnäckig auf „Generic PLA":** das Orca-

View File

@@ -1,5 +1,161 @@
# Changelog
## [0.9.25] 2026-06-17
### Fixed
- **Random crashes / container restarts — segfault in `libcrypto.so.3` (issue #53).**
The MQTT-over-TLS client shared a single SSL socket between the reader thread
(`recv`) and the sender threads (`sendall`) without serializing them. CPython's
`ssl` module does not allow concurrent read and write on the same socket — the
overlap corrupted OpenSSL's internal state, causing a heap corruption and a
segfault that manifested reliably on some hosts (timing-dependent). All socket
access (recv / sendall / close / reconnect) is now serialized under a single
lock; the reader probes readiness with `select()` outside the lock so senders
are never starved. Reconnect and disconnect now swap the socket atomically.
Thanks to @BasK for the detailed fault-handler trace that pinpointed this.
- **File browser accepted non-GCode uploads (issue #59).** Drag & drop bypassed
the file picker's `accept` filter, so e.g. a JPG could be uploaded. Uploads are
now validated both client- and server-side; only `.gcode`, `.gcode.3mf`, `.3mf`
and `.bgcode` are accepted. Thanks @gangoke.
## [0.9.24] 2026-06-16
### New
- **Skip Objects available in every print flow (issue #57).** The "Skip objects"
panel in the Slot Mapper used to appear only when printing from the Browser tab.
It now shows in all flows (upload / print bar included), collapsed by default
behind a `✂ Skip objects (N)` header to keep the dialog compact, expanding on
click with the object preview and checklist.
- **Slot Mapper shows the specific profile name (issue #57).** Each slot now
displays its mapped filament profile (e.g. "PolyTerra PLA — Polymaker") in the
dropdown options and as a hover tooltip on the slot marker, instead of just the
generic type. Falls back to the generic type when no profile is mapped.
## [0.9.23] 2026-06-16
### New
- **Auto-open print dialog after upload.** A new `print_start_dialog` setting
(Settings → Printer → "Start Print Behavior") controls what happens after a
file is uploaded while the printer is idle: `Print Dialog` opens the
slot-assignment dialog automatically, `Print Bar` keeps the previous banner
behaviour. Based on PR #56 by @gangoke.
- **Per-print auto-leveling toggle.** The print dialog now has its own
auto-leveling checkbox that overrides the global default for a single print.
### Fixed
- **Object skip was silently ignored at print start (PR #56, @gangoke).** The
skip command was sent *before* the printer entered the `printing` state, so it
was dropped. The skip is now re-applied in a retry loop once the print is
confirmed running, with a pending-lock so the UI doesn't reset the skip state
prematurely.
- **Upload during an active print overwrote the running job's preview.**
Uploading a new file while printing no longer replaces the thumbnail /
file_ready of the job currently on the bed.
## [0.9.22] 2026-06-16
### New
- **Restructured Settings panel.** The settings modal has been replaced by a
persistent Master-Detail panel with five categories: Connection, Printer,
Appearance, Filament, and System. Poll interval is now adjustable live.
- **Vendor visibility filter (issue #41).** A new checklist in the Filament
settings lets you restrict the slot profile dropdown to specific manufacturers.
"Generic" and your own imported profiles are always visible. The list updates
automatically after a profile import.
- **Idle file actions in the progress card (issue #55).** After uploading a file
while the printer is idle, three quick-action buttons appear directly in the
progress card: ▶ Print, ⚙ Map Slots, and ✕ Clear — matching the file browser
workflow without navigating away.
### Fixed
- **Mobileraker: app crashed with `Null is not a subtype of Object` in
`ConfigExtruder.fromJson` (issue #48).** `configfile.config` was returned as
an empty object `{}`. Mobileraker parses both `configfile.settings` and
`configfile.config` through the same strict Dart parser — both are now
populated with the same extruder/bed/stepper stub.
- **Mobileraker: app hung indefinitely on refresh (issue #48).** The WebSocket
`server.files.metadata` handler called a non-existent store method
(`get_file_by_filename`), always returning empty metadata. Mobileraker retried
this thousands of times per second. Both the HTTP and WS paths now share a
single `_build_file_metadata()` method.
- **Mobileraker: ETA / remaining time not shown (issue #48).** A side effect of
the metadata loop fix — once `currentFile` resolves, Mobileraker can calculate
ETA from `estimated_time`.
- **Mobileraker: `notify_status_update` triggered repeated `ConfigFile.parse`
(issue #48).** Static objects (`configfile`, `webhooks`, `heaters`, `history`)
were included in every live status push. They are now filtered out; only live
telemetry is broadcast.
- `motion_report` (`live_position`, `live_velocity`) added to printer objects
for Mobileraker motion display.
- Saving filament slot profiles no longer silently drops the `visible_vendors`
setting from `config.ini`.
## [0.9.21] 2026-06-14
### Fixed
- **Camera stream not visible on Android (Chrome / Firefox).** Android
browsers do not support `multipart/x-mixed-replace` (MJPEG). The UI
now detects Android and falls back to snapshot-polling at 5 fps
(`/api/camera/snapshot` every 200 ms) — no server-side change needed.
### Changed
- Docker image now pinned to **Debian 12 (Bookworm)** (`python:3.11-slim-bookworm`)
to avoid glibc 2.41 compatibility issues introduced by the Debian 13
base image that `python:3.11-slim` recently started pulling.
- MQTT and HTTP connections now **force IPv4** (`AF_INET`) to prevent
connection failures on hosts where the printer is only reachable via
IPv4 but the OS prefers IPv6.
- Extruder stub in the Moonraker `configfile` response now includes
`sensor_type` and `filament_diameter` — fixes a Mobileraker crash
(`Null is not a subtype of Object`, issue #48).
## [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
### New
@@ -19,9 +175,9 @@
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
lives on a desktop. Override-only profiles with just
`inherits: "Generic PLA @System"` + a few tweaks (e.g. "Bert - PLA")
are detected correctly — the bridge resolves the inherited fields
from the system parent.
`inherits: "Generic PLA @System"` + a few tweaks are detected
correctly — the bridge resolves the inherited fields from the
system parent.
### Fixes
- **AMS sync stuck on "Generic PLA":** the Orca data model has 68

View File

@@ -1,7 +1,9 @@
FROM python:3.11-slim
FROM python:3.11-slim-bookworm
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
COPY 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):**
```bash
chmod +x kx-bridge && ./kx-bridge
chmod +x kx-bridge-linux-amd64 && ./kx-bridge-linux-amd64
```
**EXE Windows (sin Docker):**
```
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)
> viven junto al programa. Copia toda la carpeta para mover la instalación.
> ⚠️ **Certificados TLS necesarios para el binario standalone**
>
> 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:**
```bash

View File

@@ -1 +1 @@
0.9.19
0.9.25

82
bug_report.yml Normal file
View File

@@ -0,0 +1,82 @@
name: "Bug Report"
about: "Problem oder Fehler melden"
title: "[BUG] "
labels:
- bug
body:
- type: dropdown
id: os
attributes:
label: "Betriebssystem"
options:
- "Windows 10"
- "Windows 11"
- "Ubuntu / Debian"
- "Fedora / RHEL"
- "Arch Linux"
- "macOS"
- "Anderes"
validations:
required: true
- type: dropdown
id: install_type
attributes:
label: "Installationsart"
options:
- "Docker / Docker Compose"
- "EXE / Binary (Windows)"
- "Binary (Linux)"
validations:
required: true
- type: dropdown
id: slicer
attributes:
label: "Slicer"
options:
- "OrcaSlicer (KX-Patch)"
- "OrcaSlicer (Standard)"
- "BambuStudio"
- "PrusaSlicer"
- "Anderer"
validations:
required: true
- type: input
id: slicer_version
attributes:
label: "Slicer Version"
placeholder: "z.B. 2.4.0-dev-kx1"
validations:
required: true
- type: input
id: bridge_version
attributes:
label: "KX-Bridge Version"
placeholder: "z.B. v0.9.20"
description: "Zu finden im Webinterface oder in den Logs beim Start."
validations:
required: true
- type: textarea
id: description
attributes:
label: "Problembeschreibung"
description: "Was passiert? Was hast du erwartet?"
placeholder: |
Was ist passiert:
Was wurde erwartet:
validations:
required: true
- type: textarea
id: logs
attributes:
label: "Logs / Fehlermeldung"
description: "Relevante Ausgabe aus den KX-Bridge Logs. Docker: `docker logs <container>`"
render: text
validations:
required: false

View File

@@ -61,6 +61,7 @@ def _load_config_file(path: pathlib.Path):
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
"PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"),
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
}
for env_key, (section, option) in mapping.items():
@@ -73,6 +74,18 @@ def _load_config_file(path: pathlib.Path):
pass
# Backward compatibility: old key FILE_READY_DIALOG → PRINT_START_DIALOG
if "PRINT_START_DIALOG" not in os.environ:
try:
legacy = cfg.get(CONFIG_SECTION_PRINT, "file_ready_dialog")
if legacy:
os.environ["PRINT_START_DIALOG"] = legacy
except (configparser.NoSectionError, configparser.NoOptionError):
pass
if "PRINT_START_DIALOG" not in os.environ and "FILE_READY_DIALOG" in os.environ:
os.environ["PRINT_START_DIALOG"] = os.environ["FILE_READY_DIALOG"]
def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
"""Einmalige Migration: .env → config.ini anlegen."""
env_vals: dict[str, str] = {}
@@ -227,10 +240,17 @@ def save_filament_profiles(profiles: dict[int, dict]) -> bool:
return False
cfg = configparser.ConfigParser()
cfg.read(path, encoding="utf-8")
# visible_vendors (Issue #41) ist kein Slot-Mapping — beim Ersetzen der
# Sektion erhalten, sonst geht der Vendor-Filter beim Slot-Save verloren.
preserved_vendors = None
if cfg.has_option("filament_profiles", "visible_vendors"):
preserved_vendors = cfg.get("filament_profiles", "visible_vendors")
if cfg.has_section("filament_profiles"):
cfg.remove_section("filament_profiles")
if profiles:
if profiles or preserved_vendors:
cfg["filament_profiles"] = {}
if preserved_vendors:
cfg["filament_profiles"]["visible_vendors"] = preserved_vendors
for slot_idx in sorted(profiles.keys()):
entry = profiles[slot_idx] or {}
if entry.get("vendor"):
@@ -244,6 +264,43 @@ def save_filament_profiles(profiles: dict[int, dict]) -> bool:
return True
def list_visible_vendors() -> list[str]:
"""Liest [filament_profiles] visible_vendors (komma-separiert) aus config.ini.
Vendor-Sichtbarkeitsfilter für das Slot-Profil-Dropdown (Issue #41 Option A).
Leere Liste = keine Einschränkung (rückwärtskompatibel: alle Vendoren).
"""
path = _find_config_file()
if not path:
return []
cfg = configparser.ConfigParser()
cfg.read(path, encoding="utf-8")
if not cfg.has_option("filament_profiles", "visible_vendors"):
return []
raw = cfg.get("filament_profiles", "visible_vendors")
return [v.strip() for v in raw.split(",") if v.strip()]
def save_visible_vendors(vendors: list[str]) -> bool:
"""Schreibt visible_vendors in [filament_profiles], ohne die Slot-Mappings
(slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder."""
path = _find_config_file()
if not path:
return False
cfg = configparser.ConfigParser()
cfg.read(path, encoding="utf-8")
if not cfg.has_section("filament_profiles"):
cfg.add_section("filament_profiles")
clean = [v.strip() for v in (vendors or []) if v and v.strip()]
if clean:
cfg["filament_profiles"]["visible_vendors"] = ", ".join(clean)
elif cfg.has_option("filament_profiles", "visible_vendors"):
cfg.remove_option("filament_profiles", "visible_vendors")
with open(path, "w", encoding="utf-8") as f:
cfg.write(f)
return True
def get(key: str, default: str = "") -> str:
return os.environ.get(key, default)
@@ -259,3 +316,4 @@ DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
AUTO_LEVELING = int(get("AUTO_LEVELING","1"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
# KX-Bridge — Portainer Stack
#
# Paste this into Portainer → Stacks → Add stack → Web editor
#
# No configuration needed upfront — just deploy, open http://HOST-IP:7125
# and add your printer via the UI (IP only, credentials are fetched automatically).
#
# All data (config, GCode store, database) is stored in named Docker volumes
# managed by Portainer.
services:
kx-bridge:
image: gitea.it-drui.de/viewit/kx-bridge:latest
volumes:
- kx-bridge-config:/app/config
- kx-bridge-data:/app/data
ports:
# Port 7125 = first printer. Add 7126, 7127, … for each additional printer.
- "7125:7125"
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
kx-bridge-config:
kx-bridge-data:

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

@@ -48,3 +48,4 @@ MODE_ID = get("MODE_ID", "")
DEVICE_ID = get("DEVICE_ID", "")
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))

View File

@@ -27,6 +27,7 @@ import hashlib
import json
import logging
import os
import select
import socket
import ssl
import sys
@@ -120,6 +121,10 @@ class KobraXClient:
self._buf = b""
self._pid = 1
self._lock = threading.Lock()
# Generations-Marker: wird bei jedem Socket-Swap/Close erhöht, damit der
# Reader-Thread erkennt wenn _reconnect/_do_connect den Socket unter ihm
# ersetzt hat (Issue #53). Schützt gegen recv auf einem stale fd.
self._sock_gen = 0
self._running = False
# Pending requests by msgid (for response ACK)
@@ -154,26 +159,44 @@ class KobraXClient:
# -- Connection ----------------------------------------------------------
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.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.set_ciphers("DEFAULT:@SECLEVEL=0")
ctx.load_cert_chain(CERT_FILE, KEY_FILE)
raw = socket.create_connection((self.host, self.port), timeout=5)
self._sock = ctx.wrap_socket(raw)
log.info("TLS connected cipher=%s", self._sock.cipher()[0])
# Socket als lokale Variable aufbauen — der Handshake (Connect + CONNACK)
# läuft OHNE gehaltenes Lock, damit ein langsamer Connect die Sender nicht
# einfriert. Erst der fertige Socket wird unter Lock eingeschwenkt (#53).
_ai = socket.getaddrinfo(self.host, self.port, socket.AF_INET, socket.SOCK_STREAM)
raw = socket.create_connection(_ai[0][4], timeout=5)
new_sock = ctx.wrap_socket(raw)
log.info("TLS connected cipher=%s", new_sock.cipher()[0])
self._sock.sendall(_build_connect(self.client_id, self.username, self.password))
self._sock.settimeout(3)
r = self._sock.recv(64)
new_sock.sendall(_build_connect(self.client_id, self.username, self.password))
new_sock.settimeout(3)
r = new_sock.recv(64)
if len(r) < 4 or r[0] != 0x20 or r[3] != 0:
try:
new_sock.close()
except Exception:
pass
raise RuntimeError(f"CONNACK failed: {r.hex()}")
log.info("CONNACK rc=0")
self._sock.settimeout(0.2)
self._buf = b""
self._subscribe(self._sub_topic())
new_sock.settimeout(0.2)
with self._lock:
self._sock = new_sock
self._sock_gen += 1
self._buf = b""
self._subscribe(self._sub_topic()) # nimmt das Lock selbst — nicht verschachteln
log.debug("MQTT connected to %s:%s", self.host, self.port)
def connect(self):
@@ -199,10 +222,14 @@ class KobraXClient:
def disconnect(self):
self._running = False
try:
self._sock.close()
except Exception:
pass
with self._lock:
try:
if self._sock is not None:
self._sock.close()
except Exception:
pass
self._sock = None
self._sock_gen += 1
def _reconnect(self):
"""Persistenter Reconnect: versucht endlos weiter bis der Drucker wieder
@@ -211,10 +238,16 @@ class KobraXClient:
nur DEBUG um Log-Spam bei langem Drucker-Ausfall (z.B. über Nacht
ausgeschaltet) zu vermeiden."""
log.warning("Verbindung verloren reconnect…")
try:
self._sock.close()
except Exception:
pass
# Close + Invalidierung unter Lock, damit kein Sender mitten im sendall
# auf den gerade geschlossenen Socket trifft (Issue #53).
with self._lock:
try:
if self._sock is not None:
self._sock.close()
except Exception:
pass
self._sock = None
self._sock_gen += 1
delays = [2, 4, 8, 15, 30, 60]
attempt = 0
while self._running:
@@ -238,7 +271,8 @@ class KobraXClient:
with self._lock:
pid = self._pid
self._pid += 1
self._sock.sendall(_build_subscribe(topic, pid))
if self._sock is not None:
self._sock.sendall(_build_subscribe(topic, pid))
log.info("SUB %s", topic)
# -- Read loop -----------------------------------------------------------
@@ -248,17 +282,52 @@ class KobraXClient:
_empty_count = 0
while self._running:
if time.time() - last_ping > 30:
ping_ok = False
with self._lock:
try:
self._sock.sendall(_build_pingreq())
if self._sock is not None:
self._sock.sendall(_build_pingreq())
ping_ok = True
except Exception:
if self._running and not self._reconnect():
break
last_ping = time.time()
continue
ping_ok = False
# _reconnect() AUSSERHALB des Locks aufrufen — es nimmt das Lock
# selbst, und threading.Lock ist nicht reentrant (sonst Deadlock).
if not ping_ok:
if self._running and not self._reconnect():
break
last_ping = time.time()
# Aktuellen Socket + Generation unter Lock greifen, damit ein
# paralleler _reconnect/_do_connect-Swap uns nicht auf einem stale
# fd pollen lässt (Issue #53).
with self._lock:
sock = self._sock
gen = self._sock_gen
if sock is None:
time.sleep(0.05)
continue
# Idle-Wartezeit OHNE Lock — select probt nur die Bereitschaft, so
# blockiert der Reader während Leerlauf nie das gemeinsame Lock.
try:
data = self._sock.recv(65536)
ready, _, _ = select.select([sock], [], [], 0.2)
except (OSError, ValueError):
# fd geschlossen/ungültig (Reconnect oder Disconnect mitten im select)
if not self._running:
break
time.sleep(0.05)
continue
if not ready:
continue # Leerlauf, kein Lock gehalten
# Daten liegen an: Lock kurz greifen für das eine recv, serialisiert
# gegen alle sendall-Caller. recv blockiert nicht lange (select sagte
# ready, Socket-Timeout ist 0.2s).
try:
with self._lock:
# Socket könnte zwischen select und hier ersetzt worden sein.
if self._sock_gen != gen or self._sock is not sock:
continue
data = sock.recv(65536)
if not data:
# Windows SSL kann kurzzeitig b"" liefern ohne echten EOF
_empty_count += 1
@@ -267,7 +336,7 @@ class KobraXClient:
continue
_empty_count = 0
self._buf += data
self._drain()
self._drain() # außerhalb des Locks — Dispatch/event.set() bleibt prompt
except ssl.SSLWantReadError:
continue
except socket.timeout:
@@ -589,7 +658,8 @@ class KobraXClient:
# langsamerem WLAN am Drucker dauert das Schieben sonst >30 s und
# würde den Connect-Timeout fälschlich auslösen. Read-Timeout danach
# generös (Drucker verarbeitet die Datei bevor er antwortet).
sock = socket.create_connection((self.host, 18910), timeout=10)
_ai = socket.getaddrinfo(self.host, 18910, socket.AF_INET, socket.SOCK_STREAM)
sock = socket.create_connection(_ai[0][4], timeout=10)
sock.settimeout(None) # blocking während Send
sock.sendall(headers + body)
sock.settimeout(180)

View File

@@ -34,6 +34,7 @@ except ImportError:
import env_loader
import asyncio
import hashlib
import copy
import json
import logging
import os
@@ -222,7 +223,7 @@ def _parse_gcode_estimated_time(data: bytes) -> int:
elif unit == "m": secs += int(val) * 60
elif unit == "s": secs += int(val)
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
@@ -751,6 +752,13 @@ class KobraXBridge:
self._filament_profiles: dict[int, dict] = _cl.list_filament_profiles()
except Exception:
self._filament_profiles = {}
# Vendor-Sichtbarkeitsfilter fürs Slot-Profil-Dropdown (Issue #41 Option A).
# Leere Liste = alle Vendoren sichtbar (rückwärtskompatibel).
try:
import config_loader as _cl
self._visible_vendors: list[str] = _cl.list_visible_vendors()
except Exception:
self._visible_vendors = []
self._last_state: dict = {}
self._state = {
"nozzle_temp": 0.0,
@@ -783,6 +791,7 @@ class KobraXBridge:
"print_speed_mode": 2,
"connection_error": "",
"file_ready": "",
"print_start_dialog": getattr(args, "print_start_dialog", 1),
"filament_mode": "toolhead",
"ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None},
}
@@ -798,6 +807,7 @@ class KobraXBridge:
self._serve_dir_path: str = self._store._gcode_dir
self._current_job_id: str = ""
self._camera_autostarted: bool = False
self._camera_user_stopped: bool = False # User hat Kamera während Druck manuell gestoppt
self.camera_cache: CameraCache = CameraCache()
self._thumbnail_b64: str = ""
@@ -805,11 +815,14 @@ class KobraXBridge:
# Part-Skip: zuletzt vom Drucker gemeldete Skip-Liste (v0.9.10)
self._skip_state: dict = {"objects": [], "skipped": [], "ts": 0}
# Pre-Print-Skip: pending until printer enters printing state
self._pending_preprint_skip: list[str] = []
self._pending_preprint_skip_deadline: float = 0.0
# Theme-Name prüfen (keine Sonderzeichen oder Umlaute)
raw_theme = (getattr(args, "ui_theme", None) or "default").strip()
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"
self._ui_theme = raw_theme
self._index_tpl_cache: str | None = None
@@ -914,7 +927,9 @@ class KobraXBridge:
# Zentral hier, damit es alle Druck-Startwege abdeckt (OrcaSlicer + UI).
# _camera_autostarted verhindert Mehrfach-Trigger pro Druck.
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
try:
self.client.start_camera()
@@ -923,6 +938,7 @@ class KobraXBridge:
log.warning(f"Kamera-Autostart fehlgeschlagen: {e}")
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False
self._camera_user_stopped = False # für nächsten Druck freigeben
# Job-History: Druckstart erkennen
if kobra_state == "printing" and not self._current_job_id:
@@ -934,7 +950,7 @@ class KobraXBridge:
gcode_file_id=gf["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
if kobra_state in ("finished",) and self._current_job_id:
@@ -1001,7 +1017,9 @@ class KobraXBridge:
# Kamera-Autostart auch hier (OrcaSlicer meldet Start oft via info/report).
# _camera_autostarted-Guard verhindert Doppel-Start mit _on_print.
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
try:
self.client.start_camera()
@@ -1010,6 +1028,7 @@ class KobraXBridge:
log.warning(f"Kamera-Autostart fehlgeschlagen: {e}")
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False
self._camera_user_stopped = False # für nächsten Druck freigeben
if project:
if "filename" in project:
self._state["filename"] = project["filename"]
@@ -1052,7 +1071,16 @@ class KobraXBridge:
"""
d = payload.get("data") or {}
skipped = d.get("objects_skip_parts") or d.get("skipped") or d.get("skipped_parts") or []
# Liste immer (auch leer) übernehmen sonst bleibt sie auf alten Stand
# Während ein Pre-Print-Skip noch pending ist, leere Früh-Reports ignorieren
# damit die UI nicht sofort zurückspringt bevor der Drucker den Skip bestätigt.
now = time.time()
if (not skipped and self._pending_preprint_skip
and now <= self._pending_preprint_skip_deadline):
return
# Pending-Lock aufheben sobald Drucker die gewünschten Objekte bestätigt
if self._pending_preprint_skip and set(skipped) >= set(self._pending_preprint_skip):
self._pending_preprint_skip = []
self._pending_preprint_skip_deadline = 0.0
self._skip_state = {
"skipped": list(skipped),
"ts": int(time.time()),
@@ -1064,22 +1092,54 @@ class KobraXBridge:
d = payload.get("data") or {}
details = d.get("file_details") or {}
thumb = details.get("thumbnail") or details.get("png_image") or ""
if thumb:
file_name = d.get("filename") or details.get("filename") or self._last_uploaded_file
active_print = self._state.get("print_state") in ("printing", "paused")
current_print_file = self._state.get("filename") or ""
# Uploads während eines laufenden Drucks dürfen die aktive
# Fortschritts-Vorschau nicht überschreiben.
if thumb and (not active_print or (file_name and file_name == current_print_file)):
self._thumbnail_b64 = thumb
log.info(f"Vorschaubild empfangen: {len(thumb)} Zeichen base64")
# Part-Skip: Objekt-Liste + optionales SVG (v0.9.10)
objs = details.get("objects_skip_parts") or []
svg = details.get("svg_image") or ""
if objs:
filename = d.get("filename") or details.get("filename") or self._last_uploaded_file
filename = file_name
if filename:
try:
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:
log.warning(f"update_file_objects fehlgeschlagen: {e}")
self._push_status_update()
def _apply_preprint_skip_after_start(self, names: list[str], retries: int = 20, delay_s: float = 0.75):
"""Sendet Skip-Befehl erst nachdem Drucker in printing-State gewechselt hat.
Vorher sendet der Drucker den Befehl ins Leere (kein aktiver Druck).
"""
wanted = [str(n) for n in (names or []) if isinstance(n, str) and n]
if not wanted:
return False
for i in range(max(1, int(retries))):
try:
if self._state.get("kobra_state") != "printing":
time.sleep(max(0.1, float(delay_s)))
continue
resp = self.client.skip_objects(wanted)
if resp is not None:
log.info(f"Pre-Print skip applied ({len(wanted)} objects) on attempt {i+1}/{retries}")
self._pending_preprint_skip = []
self._pending_preprint_skip_deadline = 0.0
return True
except Exception as e:
log.debug(f"Pre-Print skip attempt {i+1}/{retries} failed: {e}")
time.sleep(max(0.1, float(delay_s)))
log.warning(f"Pre-Print skip could not be confirmed after {retries} attempts")
self._pending_preprint_skip = []
self._pending_preprint_skip_deadline = 0.0
return False
@staticmethod
def _detect_filament_mode(boxes: list, head_tools_model: int = -1) -> str:
"""Detect active filament topology mode.
@@ -1687,13 +1747,22 @@ class KobraXBridge:
# WebSocket push
# -------------------------------------------------------------------------
# Statische Objekte, die sich zur Laufzeit nie ändern. Sie werden einmalig
# per objects.query/subscribe ausgeliefert, aber NICHT in jedem
# notify_status_update mitgeschickt — sonst läuft Mobilerakers
# ConfigFile.parse (teuer + strikt) bei jedem Status-Tick erneut und die App
# hängt/crasht beim Refresh (Issue #48).
_STATIC_STATUS_OBJECTS = ("configfile", "webhooks", "heaters", "history")
def _push_status_update(self):
if not self.ws_clients:
return
objs = self._build_printer_objects()
live = {k: v for k, v in objs.items() if k not in self._STATIC_STATUS_OBJECTS}
msg = {
"jsonrpc": "2.0",
"method": "notify_status_update",
"params": [self._build_printer_objects(), time.time()],
"params": [live, time.time()],
}
text = json.dumps(msg)
dead = set()
@@ -1851,6 +1920,17 @@ class KobraXBridge:
"homing_origin": [0, 0, 0, 0],
"position": [0, 0, self._estimate_current_z(), 0],
},
# motion_report: Mobileraker liest die Live-Geschwindigkeit hier
# (live_velocity). Der Kobra-X-MQTT liefert KEINE echte mm/s, nur
# einen print_speed_mode (1-4). live_velocity bleibt daher 0 — das
# Objekt muss aber existieren, sonst zeigt Mobileraker nichts an
# (motion_report war zuvor null). live_position spiegelt die
# geschätzte Z-Höhe (wie gcode_move).
"motion_report": {
"live_position": [0, 0, self._estimate_current_z(), 0],
"live_velocity": 0.0,
"live_extruder_velocity": 0.0,
},
"fan": {
"speed": (int(s.get("fan_speed") or 0)) / 100.0,
"rpm": None,
@@ -1884,6 +1964,81 @@ class KobraXBridge:
"gcode_macro TIMELAPSE_TAKE_FRAME": {
"is_paused": False,
},
# configfile stub — Mobileraker und andere Clients crashen ohne
# dieses Objekt (Missing field: configFile). Werte aus der
# entschlüsselten avata_main.conf (ACCFG1.0 — Kobra X Firmware).
# Mobileraker (Issue #48) parst BEIDE Zweige config + settings durch
# denselben ConfigFile.parse → ConfigExtruder.fromJson; ein leeres
# config:{} ließ den nicht-nullbaren Dart-Parser crashen. Daher wird
# config identisch zu settings gespiegelt.
"configfile": self._klipper_configfile_stub(),
}
def _klipper_configfile_stub(self) -> dict:
"""Minimaler Klipper-configfile-Stub für Mobileraker/OctoApp (Issue #48).
Mobileraker parst BEIDE Zweige `config` und `settings` durch denselben
ConfigFile.parse → ConfigExtruder.fromJson. Ein leeres `config: {}`
ließ den nicht-nullbaren Dart-Parser crashen, daher wird `config`
identisch zu `settings` gespiegelt. Werte aus der entschlüsselten
avata_main.conf (ACCFG1.0 — Kobra X Firmware).
"""
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,
"filament_diameter": 1.75,
"sensor_type": "ATC Semitec 104GT-2",
"min_temp": 0,
"max_temp": 320,
"min_extrude_temp": 10,
# Mobileraker ConfigExtruder erwartet diese Felder non-nullable
# (max_extrude_only_distance, max_power) bzw. als Key präsent
# (max_extrude_only_velocity/accel dürfen null sein). Fehlen =
# Crash in ConfigExtruder.fromJson (Issue #48).
"max_extrude_only_distance": 100.0,
"max_power": 1.0,
"max_extrude_only_velocity": None,
"max_extrude_only_accel": None,
},
"heater_bed": {
# Mobileraker ConfigHeaterBed: heater_pin, sensor_type, control
# sind non-nullable. Werte sind Platzhalter (Bridge kennt die
# echten Pins nicht — Anycubic-Firmware, kein Klipper-printer.cfg).
"heater_pin": "PA0",
"sensor_type": "ATC Semitec 104GT-2",
"control": "pid",
"min_temp": 0,
"max_temp": 120,
},
# stepper_* mit non-nullable Pflichtfeldern (step_pin, dir_pin,
# rotation_distance) füllen, sonst crasht ConfigStepper.fromJson.
"stepper_x": {"step_pin": "PA1", "dir_pin": "PA2", "rotation_distance": 40,
"position_min": -18.5, "position_max": 280},
"stepper_y": {"step_pin": "PA3", "dir_pin": "PA4", "rotation_distance": 40,
"position_min": -6.5, "position_max": 272.5},
"stepper_z": {"step_pin": "PA5", "dir_pin": "PA6", "rotation_distance": 8,
"position_min": -4, "position_max": 262},
"virtual_sdcard": {"path": "/data/gcodes"},
"pause_resume": {},
"display_status": {},
}
# config + settings müssen dieselben Felder enthalten — Mobileraker
# parst beide. deepcopy, damit kein Client durch geteilte Referenz
# versehentlich beide Zweige mutiert.
return {
"config": copy.deepcopy(settings),
"settings": settings,
"warnings": [],
"save_config_pending": False,
"save_config_pending_items": {},
}
# -------------------------------------------------------------------------
@@ -2203,6 +2358,31 @@ class KobraXBridge:
"name": entry.get("name", ""),
"id": entry.get("id", "")})
async def handle_kx_visible_vendors(self, request):
"""GET/POST /kx/filament/visible_vendors — Vendor-Sichtbarkeitsfilter
fürs Slot-Profil-Dropdown (Issue #41 Option A).
GET → {"result": ["Polymaker", "eSUN", ...]}
POST {"vendors": [...]} → speichert in config.ini [filament_profiles]
visible_vendors. Leere Liste = alle sichtbar. KEIN Bridge-Neustart
nötig (nur Anzeigefilter)."""
if request.method == "POST":
try:
data = await request.json()
except Exception:
data = {}
vendors = data.get("vendors") or []
if not isinstance(vendors, list):
return self._json_cors({"error": "vendors must be a list"}, status=400)
self._visible_vendors = [str(v).strip() for v in vendors if str(v).strip()]
try:
import config_loader as _cl
_cl.save_visible_vendors(self._visible_vendors)
except Exception as e:
log.warning(f"save_visible_vendors failed: {e}")
return self._json_cors({"error": str(e)}, status=500)
return self._json_cors({"result": self._visible_vendors})
def _load_orca_filaments(self) -> list[dict]:
"""Lädt System- + User-Profile aus dem Cache. System-Profile kommen
aus bridge/data/orca_filaments.json (Image-embedded), User-Profile
@@ -2403,7 +2583,7 @@ class KobraXBridge:
ams_box_mapping = self._build_auto_ams_box_mapping()
use_ams = len(ams_box_mapping) > 0
auto_leveling = getattr(self._args, "auto_leveling", 1)
auto_leveling = int(body.get("auto_leveling", getattr(self._args, "auto_leveling", 1)))
filename = gcode_file["filename"]
file_path = gcode_file["path"]
@@ -2435,6 +2615,15 @@ class KobraXBridge:
},
}
# Pre-Print-Skip sofort im UI-Status spiegeln
self._skip_state = {"skipped": list(excluded_objects), "ts": int(time.time())}
if excluded_objects:
self._pending_preprint_skip = [str(n) for n in excluded_objects if isinstance(n, str) and n]
self._pending_preprint_skip_deadline = time.time() + 12.0
else:
self._pending_preprint_skip = []
self._pending_preprint_skip_deadline = 0.0
log.info(f"KX-Store Druckstart: {filename} ams={len(ams_box_mapping)} slots assignments={bool(assignments)} excluded={len(excluded_objects)}")
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
@@ -2443,6 +2632,9 @@ class KobraXBridge:
if result is None:
return self._json_cors({"error": "Keine Antwort vom Drucker"}, status=504)
if excluded_objects:
loop.run_in_executor(None, lambda: self._apply_preprint_skip_after_start(excluded_objects))
# Job in History starten
self._current_job_id = self._store.start_job(
gcode_file_id=gcode_file["id"],
@@ -2547,18 +2739,16 @@ class KobraXBridge:
})
return web.json_response({"result": files})
async def handle_files_metadata(self, request):
"""Moonraker /server/files/metadata — moonraker-obico-Plugin holt das
einmal pro Druck und liest daraus `object_height` (für `currentZ`-
Anzeige im Obico-UI: `mmProgress` braucht maxZ), `layer_count`,
`layer_height` und `first_layer_height` (für die Layer-Berechnung).
def _build_file_metadata(self, filename: str) -> dict:
"""Baut die Moonraker-file-metadata für eine Datei. Gemeinsame Quelle
für HTTP /server/files/metadata UND den WS-RPC server.files.metadata
(vorher hatte der WS-Pfad eigene, kaputte Logik mit einer nicht
existierenden Store-Methode → leere Antwort → Mobileraker fragte in
Endlosschleife, App hing beim Refresh, Issue #48).
Quelle: aktueller `_state` + GCode-Store-Eintrag wenn vorhanden.
Wenn Layer-Heights weder im State noch im Store sind, Fallback auf die
OrcaSlicer-Default-Filename-Heuristik (`_layer_height_from_filename`)."""
filename = request.rel_url.query.get("filename", "") or self._state.get("filename", "")
if not filename:
return web.json_response({"result": {}})
Liefert Mobileraker-kompatible Pflichtfelder: `filename`, `size`,
`modified` sind in GCodeFile non-nullable; `print_start_time` und die
Slicer-Felder optional."""
s = self._state
layer_h = float(s.get("layer_height") or 0.0)
first_h = float(s.get("first_layer_height") or 0.0)
@@ -2582,16 +2772,27 @@ class KobraXBridge:
if layer_h and not first_h:
first_h = layer_h
object_height = round(first_h + max(0, total_layers - 1) * layer_h, 3) if (layer_h and total_layers) else 0.0
return web.json_response({"result": {
return {
"filename": filename,
"size": size_bytes,
# GCodeFile (Mobileraker) verlangt size als non-nullable int.
"size": size_bytes or 1,
"modified": time.time(),
"estimated_time": est_time or None,
"layer_height": layer_h or None,
"first_layer_height": first_h or None,
"layer_count": total_layers or None,
"object_height": object_height or None,
}})
"thumbnails": [],
}
async def handle_files_metadata(self, request):
"""Moonraker /server/files/metadata — moonraker-obico + Mobileraker
holen Datei-Metadaten (Slicer-Zeit, Layer, object_height).
Logik in _build_file_metadata (gemeinsam mit WS-RPC)."""
filename = request.rel_url.query.get("filename", "") or self._state.get("filename", "")
if not filename:
return web.json_response({"result": {}})
return web.json_response({"result": self._build_file_metadata(filename)})
# ── Moonraker-Stubs für moonraker-obico ──────────────────────────────────
async def handle_access_api_key(self, request):
@@ -2655,7 +2856,20 @@ class KobraXBridge:
return web.json_response({"result": {"count": len(result_jobs), "jobs": result_jobs}})
async def handle_webcams_list(self, request):
"""Moonraker /server/webcams/list — Obico holt die Webcam-URLs hier."""
"""Moonraker /server/webcams/list — Obico 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({
"result": {
"webcams": [
@@ -2667,8 +2881,8 @@ class KobraXBridge:
"icon": "mdiWebcam",
"target_fps": 5,
"target_fps_idle": 2,
"stream_url": "/api/camera/stream",
"snapshot_url": "/api/camera/snapshot",
"stream_url": stream_url,
"snapshot_url": snapshot_url,
"flip_horizontal": False,
"flip_vertical": False,
"rotation": 0,
@@ -2711,6 +2925,18 @@ class KobraXBridge:
if not file_data:
return web.json_response({"error": "no file received"}, status=400)
# Nur druckbare Dateien zulassen (Issue #59) — verhindert dass z.B. ein
# JPG im File-Browser landet. OrcaSlicer-Uploads sind .gcode/.gcode.3mf,
# der Kobra X akzeptiert .gcode/.3mf/.bgcode.
_allowed_ext = (".gcode", ".gcode.3mf", ".3mf", ".bgcode")
_fn_lower = (remote_filename or "").lower()
if not _fn_lower.endswith(_allowed_ext):
log.warning(f"Upload abgelehnt (kein GCode): {remote_filename}")
return web.json_response(
{"error": f"only GCode files allowed ({', '.join(_allowed_ext)})"},
status=400,
)
file_md5 = hashlib.md5(file_data).hexdigest()
file_size = len(file_data)
@@ -2853,7 +3079,7 @@ class KobraXBridge:
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)
if result:
log.info(f"Druckstart bestätigt: state={result.get('state')}")
log.info(f"Print start confirmed: state={result.get('state')}")
else:
log.warning("Druckstart: keine Antwort vom Drucker")
@@ -3108,7 +3334,7 @@ class KobraXBridge:
return web.json_response({"result": "disconnected"})
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"})
asyncio.get_event_loop().call_later(0.3, self._restart_bridge)
return response
@@ -3388,6 +3614,9 @@ class KobraXBridge:
await loop.run_in_executor(None, lambda: self.client.publish(
"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"})
async def handle_api_camera_snapshot(self, request):
@@ -3447,7 +3676,7 @@ class KobraXBridge:
stderr=asyncio.subprocess.DEVNULL,
)
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")
except Exception as e:
log.warning(f"Kamera: ffmpeg konnte nicht gestartet werden: {e}")
@@ -3542,7 +3771,7 @@ class KobraXBridge:
if not os.path.isfile(serve_path):
return web.Response(status=404, text="not found")
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={
"Content-Disposition": f'attachment; filename="{filename}"'
})
@@ -3580,6 +3809,7 @@ class KobraXBridge:
"remain_time": s["remain_time"],
"curr_layer": s["curr_layer"],
"total_layers": s["total_layers"],
"z_mm": self._estimate_current_z(),
"filename": s["filename"],
"slicer_time": slicer_time,
"camera_url": s["camera_url"],
@@ -3598,6 +3828,7 @@ class KobraXBridge:
"thumbnail": thumbnail,
"connection_error": s["connection_error"],
"file_ready": s["file_ready"],
"print_start_dialog": s.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)),
"version": self._read_version(),
})
@@ -3736,6 +3967,10 @@ class KobraXBridge:
"auto_leveling": getattr(self._args, "auto_leveling", 1),
"camera_on_print": getattr(self._args, "camera_on_print", 0),
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
"print_start_dialog": getattr(self._args, "print_start_dialog", 1),
"poll_interval": getattr(self._args, "poll_interval", 3),
"filament_profiles": {str(k): v for k, v in self._filament_profiles.items()},
"visible_vendors": self._visible_vendors,
"ace_dry_presets": self._ace_dry_presets,
})
@@ -3766,7 +4001,14 @@ class KobraXBridge:
cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1))))
cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0))))))
cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1))))))
if not cfg.has_option("bridge", "poll_interval"):
cfg.set("print", "print_start_dialog", str(int(bool(data.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1))))))
if "poll_interval" in data:
try:
pi = max(1, min(60, int(data["poll_interval"])))
except (TypeError, ValueError):
pi = 3
cfg.set("bridge", "poll_interval", str(pi))
elif not cfg.has_option("bridge", "poll_interval"):
cfg.set("bridge", "poll_interval", "3")
printer_name = str(data.get("printer_name", "")).strip()
if printer_name:
@@ -3857,7 +4099,7 @@ class KobraXBridge:
with open(config_path, "w", encoding="utf-8") as f:
f.write("# KX-Bridge Konfigurationsdatei\n\n")
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})
asyncio.get_event_loop().call_later(0.5, self._restart_bridge)
return response
@@ -3932,13 +4174,14 @@ class KobraXBridge:
# die alten Werte statt der geänderten config.ini.
for _k in ("PRINTER_IP", "MQTT_PORT", "MQTT_USERNAME", "MQTT_PASSWORD",
"MODE_ID", "DEVICE_ID", "DEFAULT_AMS_SLOT", "AUTO_LEVELING",
"BRIDGE_PRINTER_NAME"):
"CAMERA_ON_PRINT", "WEB_UPLOAD_WARNING", "PRINT_START_DIALOG",
"FILE_READY_DIALOG", "BRIDGE_PRINTER_NAME"):
os.environ.pop(_k, None)
in_docker = os.path.exists("/.dockerenv") or os.environ.get("KX_IN_DOCKER")
if in_docker:
# 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)
frozen = getattr(sys, "frozen", False)
@@ -3973,7 +4216,9 @@ class KobraXBridge:
GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag"
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"
if p.is_file():
return p.read_text(encoding="utf-8").strip()
@@ -4115,7 +4360,7 @@ class KobraXBridge:
if fname == "kobrax_moonraker_bridge.py":
return web.json_response(
{"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
downloaded.append((app_dir / fname, await resp.read()))
# Phase 2: atomar ersetzen (erst nach komplettem, erfolgreichem Download)
@@ -4392,11 +4637,14 @@ class KobraXBridge:
# Obico registriert obico_remote_event-Callback. Wir akzeptieren leer.
result = "ok"
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": [{
"name": "KX-Bridge", "location": "printer", "service": "mjpegstreamer",
"enabled": True, "stream_url": "/api/camera/stream",
"snapshot_url": "/api/camera/snapshot",
"enabled": True,
"stream_url": f"{_base}/api/camera/stream",
"snapshot_url": f"{_base}/api/camera/snapshot",
"flip_horizontal": False, "flip_vertical": False, "rotation": 0,
"target_fps": 5, "aspect_ratio": "16:9",
}]}
@@ -4425,28 +4673,18 @@ class KobraXBridge:
elif method == "machine.update.status":
result = {"busy": False, "version_info": {}}
elif method == "server.files.metadata":
# Obico fragt nach Metadaten zu einer Datei (filename in params)
# Obico + Mobileraker fragen Metadaten zu einer Datei. Dieselbe
# Logik wie der HTTP-Endpoint (vorher eigener kaputter Pfad mit
# nicht existierender Store-Methode → leere Antwort →
# Mobileraker-Endlosschleife, Issue #48).
fname = (params or {}).get("filename") if isinstance(params, dict) else None
meta = {}
if fname:
try:
rec = self._store.get_file_by_filename(fname) if hasattr(self._store, "get_file_by_filename") else None
except Exception:
rec = None
if rec:
meta = {
"filename": rec.get("filename"),
"size": rec.get("size_bytes") or 0,
"modified": time.time(),
"estimated_time": rec.get("est_print_time_sec") or 0,
"thumbnails": [],
}
result = meta
fname = fname or self._state.get("filename", "")
result = self._build_file_metadata(fname) if fname else {}
else:
log.debug(f"Unbekannte RPC-Methode: {method}")
result = {}
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)}
if rpc_id is not None:
@@ -4645,6 +4883,8 @@ def build_app(bridge: KobraXBridge) -> web.Application:
r.add_get("/kx/filament/slots", bridge.handle_kx_filament_slots)
r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles)
r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile)
r.add_get("/kx/filament/visible_vendors", bridge.handle_kx_visible_vendors)
r.add_post("/kx/filament/visible_vendors", bridge.handle_kx_visible_vendors)
# Custom-Profile-Import (Issue #41) — User lädt eigene Orca-Filament-
# Profile als ZIP/JSON hoch (z.B. aus ~/.config/OrcaSlicer/user/<id>/filament/),
# weil die Bridge typischerweise nicht auf demselben Host wie OrcaSlicer läuft.
@@ -4760,7 +5000,7 @@ async def run_bridge(args):
site = web.TCPSite(runner, args.host, per_args.port)
await site.start()
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
try:
@@ -4769,6 +5009,9 @@ async def run_bridge(args):
_local_ip = _s.getsockname()[0]
except Exception:
_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: " +
", ".join(str(getattr(b._args, 'port', 0)) for b in all_bridges.values()))
log.info("Ctrl-C zum Beenden")
@@ -4817,6 +5060,8 @@ def main():
parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING)
parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT)
parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING)
parser.add_argument("--print-start-dialog", dest="print_start_dialog", type=int, default=env_loader.PRINT_START_DIALOG)
parser.add_argument("--file-ready-dialog", dest="print_start_dialog", type=int)
parser.add_argument("--host", default="0.0.0.0",
help="Bind-Adresse für den Bridge-Server")

View File

@@ -6,7 +6,7 @@
# ein → zur Laufzeit über sys._MEIPASS lesbar (_WEB_BASE in der Bridge).
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 = []
hiddenimports = []

View File

@@ -1,11 +1,17 @@
// ── State ──
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,
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,
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 camOn=false;
var camUserStopped=false; // user stopped camera manually — suppress auto-restart for this print
var _camPollInterval=null; // snapshot-polling interval for Android (no MJPEG support)
var _lastLoadedFile=null; // zuletzt geladene/gedruckte Datei für Progress-Karten-Aktionen (Issue #55)
var _fdDialogOpen=false; // Dialog ist gerade offen
var _fdAutoOpenedFile=null; // Dateiname für den der Dialog auto-geöffnet wurde
var _fdUserCancelled=false; // User hat den Auto-Open-Dialog abgebrochen
var currentStep=1;
var currentPanel='dashboard';
var aceAutoRefillPrefs=(function(){
@@ -101,6 +107,7 @@ function tr(key,fallback){
function _langToggleLabel(lang){
if(lang==='de')return 'Deutsch';
if(lang==='en')return 'English';
if(lang==='fr')return 'Français';
if(lang==='zh-cn')return '简体中文';
return 'Espanol';
}
@@ -108,10 +115,10 @@ function _langToggleLabel(lang){
function _mapSupportedLang(lang){
if(!lang)return '';
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];
if(base==='de'||base==='en'||base==='es')return base;
if(base==='de'||base==='en'||base==='es'||base==='fr')return base;
if(base==='zh'){
if(l.indexOf('cn')>=0||l.indexOf('hans')>=0||l==='zh')return 'zh-cn';
@@ -164,6 +171,8 @@ async function setLanguage(lang){
localStorage.setItem('lang',l);
var langSel=document.getElementById('lang-select');
if(langSel)langSel.value=l;
var sLangSel=document.getElementById('s-lang-select');
if(sLangSel)sLangSel.value=l;
document.documentElement.setAttribute('lang',l);
applyLang();
}
@@ -250,7 +259,7 @@ function applyLang(){
setText('skip-title',T.skip_title);
setText('skip-hint',T.skip_hint);
setText('d-btn-skip-label',T.skip_btn_label);
setText('fd-objects-hint',T.fd_objects_hint);
setText('fd-objects-toggle-lbl',T.fd_objects_toggle);
setText('apd-lbl-ip',T.apd_lbl_ip);
setText('apd-lbl-name',T.apd_lbl_name);
var apn=document.getElementById('apd-name');if(apn)apn.setAttribute('placeholder',T.apd_placeholder_name);
@@ -282,6 +291,7 @@ function applyLang(){
setText('d-lbl-remain',T.lbl_remaining);
setText('d-slicer-label',T.lbl_slicer_time);
setText('d-lbl-layers',T.lbl_layers);
setText('d-lbl-zpos',T.lbl_zpos);
setText('d-lbl-light',T.lbl_light);
setText('d-lbl-nozzle',T.label_nozzle);
setText('d-lbl-bed',T.label_bed);
@@ -306,12 +316,26 @@ function applyLang(){
document.querySelectorAll('.temp-input').forEach(e=>e.setAttribute('placeholder',T.label_target_c.replace(':','')));
// Console
setText('ptitle-console',T.panel_console_title);
// Settings modal
setText('modal-title-settings',T.settings_title);
// Settings-Panel
setText('modal-sec-connection',T.settings_connection);
setText('modal-sec-print',T.settings_print);
setText('modal-sec-poll',T.settings_poll);
setText('modal-sec-version',T.settings_version);
// Nav + Kategorie-Labels (mit Fallback falls i18n-Key noch fehlt)
setText('nav-settings',T.nav_settings||'Einstellungen');
setText('setcat-lbl-connection',T.settings_connection||'Verbindung');
setText('setcat-lbl-printer',T.settings_print||'Drucker');
setText('setcat-lbl-display',T.settings_cat_display||'Darstellung');
setText('setcat-lbl-display2',T.settings_cat_display||'Darstellung');
setText('setcat-lbl-filament',T.settings_cat_filament||'Filament');
setText('setcat-lbl-system',T.settings_version||'System');
setText('lbl-set-lang',T.settings_cat_language||'Sprache');
setText('lbl-set-theme',T.settings_cat_theme||'Hell / Dunkel umschalten');
setText('lbl-poll-interval',T.settings_poll||'Poll-Intervall (Sekunden)');
setText('lbl-filament-mapping',T.settings_filament_mapping||'Filament-Profil-Mapping (pro Slot)');
setText('lbl-filament-mapping-save',T.settings_filament_mapping_save||'Mapping speichern');
setText('lbl-visible-vendors',T.settings_visible_vendors||'Sichtbare Hersteller (Profil-Dropdown)');
setText('visible-vendors-hint',T.settings_visible_vendors_hint||'Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic" und eigene Profile sind immer sichtbar.');
setText('lbl-visible-vendors-save',T.settings_visible_vendors_save||'Auswahl speichern');
// Custom-Profile-Import (Issue #41)
setText('modal-sec-orca-profiles',T.orca_profile_section);
setText('orca-profiles-hint',T.orca_profile_hint);
@@ -334,11 +358,20 @@ function applyLang(){
setText('lbl-default-slot',T.settings_default_slot);
setText('opt-slot-auto',T.settings_slot_auto);
setText('lbl-auto-leveling',T.settings_auto_leveling);
setText('lbl-file-ready-mode',T.settings_file_ready_mode);
setText('opt-file-ready-dialog',T.settings_file_ready_dialog);
setText('opt-file-ready-banner',T.settings_file_ready_banner);
setText('lbl-camera-on-print',T.settings_camera_on_print);
setText('lbl-web-upload-warning',T.settings_web_upload_warning);
setText('fd-options-title',T.fd_options_title);
setText('fd-lbl-auto-leveling',T.print_auto_leveling);
setText('lbl-update-check',T.update_check);
setText('lbl-update-apply',T.update_apply);
// Progress-Karten-Aktionen für geladene/idle Datei (Issue #55)
setText('d-idle-print-lbl',T.progress_action_print||'Drucken');
setText('d-idle-slots-lbl',T.progress_action_slots||'Slots zuordnen');
setText('d-idle-clear-lbl',T.progress_action_clear||'Leeren');
// Speed buttons
setText('d-spd-lbl-1',T.speed_silent.replace(/^\S+\s/,''));
setText('d-spd-lbl-2',T.speed_normal.replace(/^\S+\s/,''));
@@ -458,6 +491,15 @@ function showPanel(id){
var nb=document.getElementById('nb-'+id);if(nb)nb.classList.add('active');
var bnb=document.getElementById('bnb-'+id);if(bnb)bnb.classList.add('active');
currentPanel=id;
if(id==='settings')openSettings();
}
// Settings-Kategorie umschalten (Master-Detail)
function showSettingsCat(name){
document.querySelectorAll('.set-group').forEach(g=>g.classList.remove('active'));
document.querySelectorAll('.set-cat').forEach(b=>b.classList.remove('active'));
var g=document.getElementById('setgrp-'+name);if(g)g.classList.add('active');
var c=document.getElementById('setcat-'+name);if(c)c.classList.add('active');
}
// ── Console log ──
@@ -615,11 +657,21 @@ function applyState(){
// connection error banner nur wenn überhaupt ein Drucker konfiguriert ist
var banner=document.getElementById('conn-error-banner');
if(banner){if(s.connection_error&&_printers.length>0){banner.textContent='⚠ '+tr('lbl_conn_error')+' '+s.connection_error;banner.style.display='block';}else{banner.style.display='none';}}
var bannerVisible=false;
var frb=document.getElementById('file-ready-banner');
if(frb){
var shouldAutoOpen=(s.print_start_dialog===undefined?true:!!s.print_start_dialog);
if(s.file_ready&&s.print_state==='standby'){
document.getElementById('file-ready-name').textContent=s.file_ready;
frb.style.display='flex';
// Neue Datei → Abbruch-Sperre aufheben
if(_fdAutoOpenedFile&&_fdAutoOpenedFile!==s.file_ready) _fdUserCancelled=false;
if(shouldAutoOpen&&!_fdDialogOpen&&!_fdUserCancelled&&_fdAutoOpenedFile!==s.file_ready){
_fdAutoOpenedFile=s.file_ready;
startReadyFileWithSlots(s.file_ready,true);
} else {
frb.style.display='flex';
bannerVisible=true;
}
}else{frb.style.display='none';}
}
// skip-button (mid-print) nur sichtbar wenn aktuell gedruckt wird
@@ -631,6 +683,25 @@ function applyState(){
var ctrlBtns=document.getElementById('d-ctrl-btns');
if(ctrlBtns) ctrlBtns.style.display=printing?'':'none';
updatePauseResumeBtn();
// Zuletzt geladene Datei merken (Issue #55): solange sie über den State
// sichtbar ist. Beim Druckende/Abbruch leert die Bridge file_ready+filename
// (Issue #29) — die gemerkte Referenz bleibt für die Karten-Aktionen.
if(s.file_ready) _lastLoadedFile=s.file_ready;
else if(s.filename) _lastLoadedFile=s.filename;
// Idle-Aktionen (Drucken/Slots/Leeren) nur wenn nicht gedruckt wird, eine
// Datei bekannt ist und der grüne Banner nicht ohnehin schon dieselbe Aktion
// anbietet.
var idleBtns=document.getElementById('d-idle-btns');
if(idleBtns){
var showIdle=(!printing && _lastLoadedFile && !bannerVisible);
idleBtns.style.display=showIdle?'':'none';
if(showIdle){
var dfn=document.getElementById('d-fname');
if(dfn && (!dfn.textContent || dfn.textContent==='')){
dfn.textContent=_lastLoadedFile;dfn.title=_lastLoadedFile;
}
}
}
// header
var b=document.getElementById('h-badge');
@@ -659,6 +730,7 @@ function applyState(){
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 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 dremain=document.getElementById('d-remain');if(dremain)dremain.textContent=s.remain_time>0?fmtTime(s.remain_time):'';
@@ -822,10 +894,14 @@ function applyState(){
var co=document.getElementById('cam-overlay');
if(co)co.style.display=(s.print_state==='printing'&&camOn)?'block':'none';
// auto-start camera during print
if(s.print_state==='printing'&&!camOn&&s.camera_url){
// auto-start camera during print (unless user explicitly stopped it)
if(s.print_state==='printing'&&!camOn&&s.camera_url&&!camUserStopped){
camStart();
}
// reset user-stopped flag when print ends so next print auto-starts again
if(s.print_state!=='printing'){
camUserStopped=false;
}
updateConnBtn();
}
@@ -887,10 +963,6 @@ function drawChart(id,_,series){
var _updateTag='';
var _updateUrl='';
function openSettings(){
// Titel mit aktivem Drucker-Namen aktualisieren
var pname=_activePrinter&&_activePrinter.name?_activePrinter.name:null;
var title=document.getElementById('modal-title-settings');
if(title)title.textContent=T.settings_title+(pname?' '+pname:'');
fetch(_apiUrl('/api/settings')).then(function(r){return r.json()}).then(function(d){
document.getElementById('s-printer-name').value=d.printer_name||'';
document.getElementById('s-printer-ip').value=d.printer_ip||'';
@@ -902,24 +974,120 @@ function openSettings(){
document.getElementById('s-default-slot').value=d.default_ams_slot||'auto';
document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling);
var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print;
var frm=document.getElementById('s-file-ready-mode');if(frm)frm.value=(d.print_start_dialog===undefined?'1':String(d.print_start_dialog?1:0));
var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning);
// Poll-Intervall (Sekunden) — Backend hat Vorrang vor localStorage
var pi=document.getElementById('s-poll-interval');
if(pi){
var sec=d.poll_interval||Math.round((parseInt(localStorage.getItem('pollInterval')||'2000'))/1000)||3;
pi.value=sec;
}
renderFilamentMapping(d.filament_profiles||{});
});
var v=localStorage.getItem('pollInterval')||'2000';
document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')});
var pb=document.getElementById('poll-'+Math.round(parseInt(v)/1000));
if(pb)pb.classList.add('active');
// Sprach-Select im Settings-Panel mit aktueller Sprache spiegeln
var ls=document.getElementById('s-lang-select');
if(ls)ls.value=(localStorage.getItem('lang')||document.documentElement.lang||'de');
document.getElementById('s-version-label').textContent='v'+('__VERSION__'||'?');
document.getElementById('update-status').textContent='';
document.getElementById('btn-update-apply').style.display='none';
var cl=document.getElementById('update-changelog');if(cl)cl.style.display='none';
_updateTag='';_updateUrl='';
document.getElementById('settings-modal').classList.add('open');
// Custom-Profile-Liste laden (Issue #41 — Verwaltung von User-importierten
// OrcaSlicer-Filament-Profilen)
// Custom-Profile-Liste laden (Issue #41)
refreshUserProfileList();
// Vendor-Sichtbarkeitsfilter (Issue #41 Option A)
loadVendorChecklist();
}
function closeSettings(){
document.getElementById('settings-modal').classList.remove('open');
// Panel-Variante: zurück zum Dashboard
showPanel('dashboard');
}
// Poll-Intervall-Feld → Live-Poll sofort übernehmen (Persistenz erst beim Speichern)
function onPollIntervalInput(){
var pi=document.getElementById('s-poll-interval');
if(!pi)return;
var sec=parseInt(pi.value,10);
if(sec>=1&&sec<=60)setPoll(sec*1000);
}
// ── Filament-Profil-Mapping pro Slot ([filament_profiles]) ──
function renderFilamentMapping(map){
var el=document.getElementById('filament-mapping-list');
if(!el)return;
var rows='';
for(var i=0;i<4;i++){
var m=map[i]||map[String(i)]||{};
var idHint=m.id?' <span style="color:var(--txt2);font-size:11px">('+m.id+')</span>':'';
rows+='<div class="modal-field" style="margin-bottom:8px">'
+'<label>Slot '+(i+1)+idHint+'</label>'
+'<div style="display:flex;gap:6px;flex-wrap:wrap">'
+'<input type="text" id="fmap-'+i+'-vendor" placeholder="Vendor (z.B. Polymaker)" value="'+(m.vendor||'')+'" style="flex:1;min-width:120px">'
+'<input type="text" id="fmap-'+i+'-name" placeholder="Name (z.B. PolyTerra PLA)" value="'+(m.name||'')+'" style="flex:1;min-width:140px">'
+'</div></div>';
}
el.innerHTML=rows;
}
function saveFilamentMapping(){
// Nutzt den per-Slot-Endpoint (vendor,name → ID-Lookup im Backend).
// Leere Felder = Mapping entfernen.
var chain=Promise.resolve();
for(var i=0;i<4;i++){
(function(slot){
var vendor=((document.getElementById('fmap-'+slot+'-vendor')||{}).value||'').trim();
var name=((document.getElementById('fmap-'+slot+'-name')||{}).value||'').trim();
chain=chain.then(function(){
return fetch(_apiUrl('/kx/filament/slots/'+slot+'/profile'),
{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({vendor:vendor,name:name})});
});
})(i);
}
chain.then(function(){
clog(tr('log_filament_mapping_saved')||'Filament-Mapping gespeichert','msg-ok');
openSettings(); // neu laden → ID-Hints aktualisieren
}).catch(function(e){clog('Mapping-Fehler: '+e,'msg-err');});
}
// ── Vendor-Sichtbarkeitsfilter (Issue #41 Option A) ──
var _vendorChecklistSel={}; // {vendor:true} — laufende Auswahl im UI
function loadVendorChecklist(){
// aktuelle Auswahl aus Backend, dann alle verfügbaren Vendoren rendern
_visibleVendors=null; // Cache invalidieren
_loadVisibleVendors(function(vis){
_vendorChecklistSel={};
(vis||[]).forEach(function(v){_vendorChecklistSel[v]=true;});
renderVendorChecklist();
});
}
function renderVendorChecklist(){
var el=document.getElementById('visible-vendors-list');
if(!el)return;
_loadOrcaFilaments(function(profiles){
// alle System-Vendoren (ohne Generic — der ist immer sichtbar) sammeln
var set={};
profiles.forEach(function(p){ if(!p.is_user && p.vendor && p.vendor!=='Generic') set[p.vendor]=1; });
var vendors=Object.keys(set).sort();
var q=((document.getElementById('vendor-filter-search')||{}).value||'').toLowerCase();
if(q)vendors=vendors.filter(function(v){return v.toLowerCase().indexOf(q)>=0;});
el.innerHTML=vendors.map(function(v){
var ck=_vendorChecklistSel[v]?'checked':'';
var safe=v.replace(/"/g,'&quot;');
return '<label style="display:flex;align-items:center;gap:8px;padding:3px 0;cursor:pointer;font-size:13px">'
+'<input type="checkbox" data-vendor="'+safe+'" '+ck+' onchange="_vendorCheck(this)" style="width:auto;margin:0"> '+v+'</label>';
}).join('')||'<i style="color:var(--txt2)"></i>';
});
}
function _vendorCheck(cb){
var v=cb.getAttribute('data-vendor');
if(cb.checked)_vendorChecklistSel[v]=true; else delete _vendorChecklistSel[v];
}
function saveVisibleVendors(){
var vendors=Object.keys(_vendorChecklistSel);
fetch(_apiUrl('/kx/filament/visible_vendors'),{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({vendors:vendors})}).then(function(r){return r.json();}).then(function(){
_visibleVendors=vendors.slice(); // Dropdown-Cache aktualisieren
clog(tr('log_visible_vendors_saved')||'Hersteller-Auswahl gespeichert','msg-ok');
}).catch(function(e){clog('Vendor-Filter-Fehler: '+e,'msg-err');});
}
// ── Custom Filament Profile Import (Issue #41) ──
@@ -993,6 +1161,9 @@ function doProfileImportUpload(files){
_orcaFilamentCache=null;
refreshImportDialogList();
refreshUserProfileList();
// Vendor-Checkliste neu aufbauen — ein Import kann einen bisher
// unbekannten System-Vendor mitbringen (Issue #41).
if(document.getElementById('visible-vendors-list')) renderVendorChecklist();
// Wenn Slot-Edit offen ist, Dropdown gleich neu befüllen
var mat=document.getElementById('slot-edit-mat');
if(mat && document.getElementById('slot-edit-modal').classList.contains('open')){
@@ -1022,6 +1193,7 @@ function doProfileImportUpload(files){
var _slotEditIndex=-1;
var _slotEditLoaded=false;
var _MAT_PRESETS=['PLA','PETG','ABS','ASA','TPU','PA','PC','HIPS'];
var _BASE_MATERIAL_TYPES=['PLA','PETG','ABS','ASA','TPU','TPE','PA','PC','HIPS','PEI','PEEK'];
function updateSlotEditFeedButton(){
var btn=document.getElementById('btn-slot-edit-feed');
if(!btn)return;
@@ -1033,6 +1205,14 @@ function updateSlotEditFeedButton(){
btn.textContent=_slotEditLoaded?tr('slot_edit_unload'):tr('slot_edit_load');
}
var _orcaFilamentCache=null; // [{id,name,vendor,type,color}, …]
var _visibleVendors=null; // Vendor-Sichtbarkeitsfilter (Issue #41); [] = alle
function _loadVisibleVendors(cb){
if(_visibleVendors!==null){ cb(_visibleVendors); return; }
fetch(_apiUrl('/kx/filament/visible_vendors')).then(function(r){return r.json();}).then(function(d){
_visibleVendors=d.result||[];
cb(_visibleVendors);
}).catch(function(){ _visibleVendors=[]; cb([]); });
}
function _loadOrcaFilaments(cb){
if(_orcaFilamentCache){ cb(_orcaFilamentCache); return; }
fetch(_apiUrl('/kx/filament/profiles')).then(function(r){return r.json();}).then(function(d){
@@ -1078,13 +1258,23 @@ function _fillSlotProfileDropdown(material, currentVendor, currentName){
userProfs.forEach(function(p){ _appendOption(gUser, p); });
sel.appendChild(gUser);
}
// System-Profile nach Vendor gruppieren
var byVendor={};
systemProfs.forEach(function(p){ (byVendor[p.vendor]=byVendor[p.vendor]||[]).push(p); });
Object.keys(byVendor).sort().forEach(function(v){
var g=document.createElement('optgroup'); g.label=v;
byVendor[v].forEach(function(p){ _appendOption(g, p); });
sel.appendChild(g);
// Vendor-Sichtbarkeitsfilter (Issue #41 Option A): nur gewählte Vendoren +
// Generic. Leere Liste = alle (rückwärtskompatibel). Eigene Profile (is_user)
// sind oben bereits unkonditional drin.
_loadVisibleVendors(function(vis){
var filtered=systemProfs;
if(vis&&vis.length){
var allow={};vis.forEach(function(v){allow[v]=1;});
allow['Generic']=1;
filtered=systemProfs.filter(function(p){return allow[p.vendor];});
}
var byVendor={};
filtered.forEach(function(p){ (byVendor[p.vendor]=byVendor[p.vendor]||[]).push(p); });
Object.keys(byVendor).sort().forEach(function(v){
var g=document.createElement('optgroup'); g.label=v;
byVendor[v].forEach(function(p){ _appendOption(g, p); });
sel.appendChild(g);
});
});
});
}
@@ -1138,15 +1328,16 @@ function slotEditFeed(){
})
.catch(function(){});
}
function startReadyFile(){
var currentFile=(storeFiles||[]).find(function(f){return f.filename===S.file_ready;});
function startReadyFile(filename){
var fn=filename||S.file_ready;
var currentFile=(storeFiles||[]).find(function(f){return f.filename===fn;});
if(currentFile && currentFile.web_unverified && webUploadWarningEnabled()){
maybeGateWebUpload(currentFile, function(){ startReadyFile(); });
maybeGateWebUpload(currentFile, function(){ startReadyFile(fn); });
return;
}
var btn=document.getElementById('file-ready-btn');
if(btn){btn.disabled=true;btn.textContent='…';}
post('/printer/print/start',{filename:S.file_ready})
post('/printer/print/start',{filename:fn})
.then(function(r){return r.json();})
.then(function(r){
document.getElementById('file-ready-banner').style.display='none';
@@ -1161,6 +1352,20 @@ function cancelReadyFile(){
post('/api/file_ready/clear',{})
.then(function(){document.getElementById('file-ready-banner').style.display='none';});
}
// ── Aktionen für geladene/idle Datei in der Progress-Karte (Issue #55) ──
function startIdleFile(){
if(_lastLoadedFile) startReadyFile(_lastLoadedFile);
}
function startIdleFileWithSlots(){
if(_lastLoadedFile) startReadyFileWithSlots(_lastLoadedFile);
}
function clearIdleFile(){
_lastLoadedFile=null;
var ib=document.getElementById('d-idle-btns');if(ib)ib.style.display='none';
var fn=document.getElementById('d-fname');if(fn){fn.textContent='';fn.title='';}
post('/api/file_ready/clear',{}).catch(function(){});
}
function selectMatPreset(m){
document.getElementById('slot-edit-mat').value=m;
highlightMatBtn(m);
@@ -1248,9 +1453,6 @@ document.addEventListener('DOMContentLoaded',function(){
});
});
function setPoll(ms){
document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')});
var id='poll-'+Math.round(ms/1000);
var pb=document.getElementById(id);if(pb)pb.classList.add('active');
localStorage.setItem('pollInterval',ms);
clearInterval(pollTimer);
pollTimer=setInterval(poll,ms);
@@ -1271,7 +1473,9 @@ function saveSettings(){
default_ams_slot: document.getElementById('s-default-slot').value,
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10),
web_upload_warning:webUploadWarning,
poll_interval: Math.min(60,Math.max(1,parseInt((document.getElementById('s-poll-interval')||{}).value,10)||3)),
}).then(function(){
btn.textContent=T.update_restarting;
setTimeout(function(){
@@ -1437,7 +1641,8 @@ function setBed(){
function setLight(){
var on=document.getElementById('d-light-toggle').checked;
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')});
}
@@ -1492,23 +1697,24 @@ function camStart(){
img.style.display='none';
sp.style.display='block';
post('/api/camera/start',{}).then(function(){
img.onerror=function(){
sp.style.display='none';
img.style.display='none';
ph.style.display='flex';
camOn=false;
document.getElementById('cam-toggle-btn').textContent=tr('btn_cam_start');
clog(tr('log_error')+' '+tr('cam_stream_unavailable'),'msg-err');
};
img.src='/api/camera/stream?t='+Date.now();
camOn=true;
document.getElementById('cam-toggle-btn').textContent=tr('btn_cam_stop');
clog(tr('log_cam_start'),'msg-ok');
// MJPEG liefert kein onload Spinner nach kurzem Timeout ausblenden
setTimeout(function(){
sp.style.display='none';
img.style.display='block';
},1200);
setTimeout(function(){ sp.style.display='none'; img.style.display='block'; },1200);
if(/Android/i.test(navigator.userAgent)){
// Android browsers don't support multipart/x-mixed-replace (MJPEG) — poll snapshots instead
_camPollInterval=setInterval(function(){ img.src='/api/camera/snapshot?t='+Date.now(); },200);
} else {
img.onerror=function(){
sp.style.display='none';
img.style.display='none';
ph.style.display='flex';
camOn=false;
document.getElementById('cam-toggle-btn').textContent=tr('btn_cam_start');
clog(tr('log_error')+' '+tr('cam_stream_unavailable'),'msg-err');
};
img.src='/api/camera/stream?t='+Date.now();
}
}).catch(function(e){
sp.style.display='none';
ph.style.display='flex';
@@ -1518,11 +1724,14 @@ function camStart(){
function camStop(){
var img=document.getElementById('cam-img');
if(_camPollInterval){clearInterval(_camPollInterval);_camPollInterval=null;}
img.onerror=null; // deregister error handler before clearing src to avoid spurious error toast
post('/api/camera/stop',{}).catch(function(){});
img.src='';
img.style.display='none';
document.getElementById('cam-placeholder').style.display='flex';
camOn=false;
camUserStopped=true; // suppress auto-restart for remainder of this print
document.getElementById('cam-toggle-btn').textContent=tr('btn_cam_start');
clog(tr('log_cam_stop'),'msg-ok');
}
@@ -1798,6 +2007,14 @@ function uploadGcode(file){
var zone=document.getElementById('store-upload-zone');
var status=document.getElementById('store-upload-status');
var label=document.getElementById('store-upload-label');
// Nur druckbare Dateien zulassen (Issue #59) — Drag&Drop umgeht das
// accept-Attribut, daher hier explizit prüfen.
var _fn=(file.name||'').toLowerCase();
if(!/\.(gcode|gcode\.3mf|3mf|bgcode)$/.test(_fn)){
if(status){ status.textContent=T.store_upload_only_gcode||'Only GCode files allowed'; status.style.display=''; status.className='upload-status-err'; }
clog('Upload abgelehnt (kein GCode): '+file.name,'msg-err');
return;
}
if(status) { status.textContent=T.store_upload_busy; status.style.display=''; status.className='upload-status-busy'; }
if(label) label.style.display='none';
if(zone) zone.style.pointerEvents='none';
@@ -2049,22 +2266,30 @@ function confirmStoreWebVerify(){
});
}
function startReadyFileWithSlots(){
var currentFile=(storeFiles||[]).find(function(f){return f.filename===S.file_ready;});
function startReadyFileWithSlots(filename,_autoOpen){
if(!_autoOpen) _fdAutoOpenedFile=null; // manueller Aufruf → Auto-Open-Sperre aufheben
var fn=filename||S.file_ready;
var currentFile=(storeFiles||[]).find(function(f){return f.filename===fn;});
if(currentFile && currentFile.web_unverified && webUploadWarningEnabled()){
maybeGateWebUpload(currentFile, function(){ startReadyFileWithSlots(); });
maybeGateWebUpload(currentFile, function(){ startReadyFileWithSlots(fn); });
return;
}
_filamentDialogMode='banner';
_storeFilename=S.file_ready||'';
_storeFilename=fn||'';
// Banner must never reuse stale store-file context.
_storeFileId=null;
_gcodeFilaments=[];
var _autoOpenFile=_autoOpen?fn:null;
if(_autoOpen) _fdDialogOpen=true; // bereits während Fetch sperren
function openWithSlots(){
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){
if(_autoOpenFile && _fdUserCancelled){_fdDialogOpen=false;return;}
openFilamentDialog(d.result||[]);
}).catch(function(){openFilamentDialog([]);});
}).catch(function(){
if(_autoOpenFile && _fdUserCancelled){_fdDialogOpen=false;return;}
openFilamentDialog([]);
});
}
var fileObj=(storeFiles||[]).find(function(f){return f.filename===_storeFilename;});
@@ -2108,11 +2333,37 @@ function _normalizeMaterialKey(material){
var key=(material||'').toUpperCase().replace(/[^A-Z0-9+]/g,'');
// Orca often uses PLA for PLA+, while AMS may report PLA+.
if(key==='PLA+'||key==='PLAPLUS') return 'PLA';
// Handle modifier+base patterns in either order: "Matte PLA", "Silk PETG",
// "PLA Silk", "PLA Matte". OrcaSlicer always writes the base type in GCode
// (filament_type = PLA), but users label slots with the full product-style name.
// Scan each space-separated word; return the first one that is a known base material.
// Dash-suffix variants ("PLA-CF", "PETG-CF") contain no space and fall through
// unchanged, preserving correct incompatibility with their base types.
var trimmed=(material||'').trim();
if(trimmed.indexOf(' ')>=0){
var words=trimmed.toUpperCase().split(/\s+/);
for(var i=0;i<words.length;i++){
var w=words[i].replace(/[^A-Z0-9+]/g,'');
if(_BASE_MATERIAL_TYPES.indexOf(w)>=0) return w;
}
}
return key;
}
function _materialsCompatible(a,b){
return _normalizeMaterialKey(a)===_normalizeMaterialKey(b);
}
// Issue #57 Punkt 4: konkreter Profilname (User-Override) statt generischem Typ.
// Fällt auf den Material-Typ zurück wenn kein Profil gemappt ist.
function _slotProfileLabel(slot){
if(!slot)return '';
if(slot.filament_name){
return slot.filament_name+(slot.filament_vendor?' — '+slot.filament_vendor:'');
}
return slot.material||'';
}
function _escAttr(s){
return String(s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function _updateSlotMarker(sel){
var opt=sel.options[sel.selectedIndex];
var color=opt&&opt.dataset.color?opt.dataset.color:'#888';
@@ -2123,6 +2374,7 @@ function _updateSlotMarker(sel){
marker.style.background=color;
marker.style.color=_contrastText(color);
marker.textContent=(slotIdx+1);
marker.title=(opt&&opt.dataset.profile)?opt.dataset.profile:'';
}
}
@@ -2134,12 +2386,22 @@ function openFilamentDialog(slots){
var title=document.getElementById('fd-title');
var body=document.getElementById('fd-slots');
if(title)title.textContent='▶ '+_storeFilename;
// Objekt-Liste laden (nur Store-Modus: per File-ID; Banner-Modus hat keine ID)
// Auto-Leveling-Checkbox mit globalem Default vorbelegen
var fdAl=document.getElementById('fd-auto-leveling');
if(fdAl) fdAl.checked=(S.auto_leveling===undefined?true:!!S.auto_leveling);
// Objekt-Liste laden — sobald eine File-ID auflösbar ist (Issue #57 Punkt 3:
// Skip-Parität auch im Banner-/Upload-Modus, nicht nur im Store-Modus).
// startReadyFileWithSlots() setzt _storeFileId auch im Banner-Modus per
// filename→fileObj-Lookup, daher reicht hier die _storeFileId-Prüfung.
_printObjects=[];
_printObjectsSvg='';
var objSection=document.getElementById('fd-objects-section');
var objBody=document.getElementById('fd-objects-body');
var objArrow=document.getElementById('fd-objects-arrow');
if(objSection)objSection.style.display='none';
if(_filamentDialogMode==='store'&&_storeFileId){
if(objBody)objBody.style.display='none'; // immer eingeklappt starten
if(objArrow)objArrow.style.transform='';
if(_storeFileId){
fetch(_apiUrl('/kx/files/'+encodeURIComponent(_storeFileId)+'/objects'))
.then(function(r){return r.json()})
.then(function(d){
@@ -2148,6 +2410,8 @@ function openFilamentDialog(slots){
if(names.length>=2){
_printObjects=names.map(function(n){return {name:n,skip:false};});
renderObjectChecklist(); renderObjectSvg();
var cnt=document.getElementById('fd-objects-count');
if(cnt)cnt.textContent='('+names.length+')';
if(objSection)objSection.style.display='block';
}
}).catch(function(){});
@@ -2211,8 +2475,8 @@ function openFilamentDialog(slots){
var defaultSlot=compatible.find(function(s){return s.slot_index===defaultSlotIndex;})||null;
var opts=compatible.map(function(s){
var sel=(defaultSlot&&s.slot_index===defaultSlot.slot_index)?'selected':'';
return '<option value="'+s.slot_index+'" data-color="'+s.color_hex+'" data-material="'+s.material+'" '+sel+'>'+
'● '+T.fd_slot+' '+(s.slot_index+1)+' · '+s.material+'</option>';
return '<option value="'+s.slot_index+'" data-color="'+s.color_hex+'" data-material="'+s.material+'" data-profile="'+_escAttr(_slotProfileLabel(s))+'" '+sel+'>'+
'● '+T.fd_slot+' '+(s.slot_index+1)+' · '+_slotProfileLabel(s)+'</option>';
}).join('');
if(!compatible.length){
opts='<option value="-1" data-color="#888888" data-material="" selected>⚠ '+T.fd_no_matching_material+'</option>';
@@ -2229,7 +2493,7 @@ function openFilamentDialog(slots){
'<span style="font-size:11px;color:var(--txt2);min-width:36px">'+gc.material+'</span>'+
usedBadge+
'<span style="font-size:16px;color:var(--txt2)">→</span>'+
'<span class="fd-slot-marker" data-for-paint="'+i+'" style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:5px;background:'+slotColor+';color:'+slotTxt+';font-weight:700;font-size:12px;border:1px solid var(--border);flex-shrink:0">'+(defaultSlot?defaultSlot.slot_index+1:'?')+'</span>'+
'<span class="fd-slot-marker" data-for-paint="'+i+'" title="'+_escAttr(defaultSlot?_slotProfileLabel(defaultSlot):'')+'" style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:5px;background:'+slotColor+';color:'+slotTxt+';font-weight:700;font-size:12px;border:1px solid var(--border);flex-shrink:0">'+(defaultSlot?defaultSlot.slot_index+1:'?')+'</span>'+
'<select data-paint="'+i+'" data-paint-color="'+gc.color_hex+'" data-is-used="'+(isUsed?'1':'0')+'" data-has-compatible="'+(compatible.length?'1':'0')+'" '+(compatible.length?'':'disabled')+' onchange="_updateSlotMarker(this)" style="flex:1;min-width:0;padding:4px 6px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt);font-size:12px">'+
opts+'</select>'+
'</div>';
@@ -2241,6 +2505,8 @@ function openFilamentDialog(slots){
function closeFilamentDialog(){
var dlg=document.getElementById('filament-dialog');
if(dlg)dlg.classList.remove('open');
_fdDialogOpen=false;
if(_fdAutoOpenedFile) _fdUserCancelled=true;
}
function confirmFilamentPrint(){
@@ -2280,12 +2546,14 @@ function confirmFilamentPrint(){
}
// Pre-Print Skip: Namen der abgehakten Objekte sammeln
var excludedObjects=_printObjects.filter(function(o){return o.skip;}).map(function(o){return o.name;});
var fdAlEl=document.getElementById('fd-auto-leveling');
var fdAutoLeveling=fdAlEl?( fdAlEl.checked?1:0):(S.auto_leveling===undefined?1:S.auto_leveling?1:0);
closeFilamentDialog();
if(_filamentDialogMode==='banner'){
// Banner-Modus: normaler print/start mit Slot-Override
var btn=document.getElementById('file-ready-btn');
if(btn){btn.disabled=true;btn.textContent='…';}
post('/printer/print/start',{filename:S.file_ready,filament_assignments:assignments,excluded_objects:excludedObjects})
post('/printer/print/start',{filename:S.file_ready,filament_assignments:assignments,excluded_objects:excludedObjects,auto_leveling:fdAutoLeveling})
.then(function(r){return r.json();})
.then(function(){
document.getElementById('file-ready-banner').style.display='none';
@@ -2300,7 +2568,7 @@ function confirmFilamentPrint(){
fetch(_apiUrl('/kx/print'),{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({file_id:_storeFileId,filament_assignments:assignments,excluded_objects:excludedObjects})
body:JSON.stringify({file_id:_storeFileId,filament_assignments:assignments,excluded_objects:excludedObjects,auto_leveling:fdAutoLeveling})
}).then(function(r){return r.json()}).then(function(d){
if(d.result==='ok'){clog('Druckstart: '+_storeFilename,'msg-ok');showPanel('dashboard');}
else{clog('Druckfehler: '+(d.error||'?'),'msg-err');}
@@ -2326,6 +2594,15 @@ function _toggleObjectSkip(idx,val){
if(_printObjects[idx])_printObjects[idx].skip=!!val;
renderObjectSvg();
}
// Issue #57 Punkt 3: Skip-Objekte-Bereich ein-/ausklappen
function toggleFdObjects(){
var body=document.getElementById('fd-objects-body');
var arrow=document.getElementById('fd-objects-arrow');
if(!body)return;
var open=body.style.display!=='none';
body.style.display=open?'none':'block';
if(arrow)arrow.style.transform=open?'':'rotate(90deg)';
}
function renderObjectSvg(){
var box=document.getElementById('fd-objects-svg');
if(!box)return;

View File

@@ -38,118 +38,16 @@
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="es">Espanol</option>
<option value="fr">Français</option>
<option value="zh-cn">中文(简体)</option>
</select>
</div>
<button class="theme-btn" onclick="openSettings()" id="settings-btn" title="Einstellungen"></button>
<button class="theme-btn" onclick="showPanel('settings')" id="settings-btn" title="Einstellungen"></button>
<button class="conn-btn disconnected" id="conn-btn" onclick="toggleConnection()">⚡ Verbinden</button>
</header>
<!-- ═══ SETTINGS MODAL ═══ -->
<div class="modal-overlay" id="settings-modal" onclick="if(event.target===this)closeSettings()">
<div class="modal-box">
<div class="modal-header">
<span class="modal-title" id="modal-title-settings">Einstellungen</span>
<button class="modal-close" onclick="closeSettings()"></button>
</div>
<div>
<div class="modal-field" style="margin-bottom:12px">
<label id="lbl-printer-name" style="font-weight:600">Drucker-Name</label>
<input type="text" id="s-printer-name" placeholder="z.B. Kobra X Links">
</div>
<div class="modal-section" id="modal-sec-connection">Verbindung</div>
<div class="modal-field">
<label id="lbl-printer-ip">Drucker-IP</label>
<input type="text" id="s-printer-ip" placeholder="192.168.x.x">
<small id="lbl-ip-hint" style="color:#f80;display:none"></small>
</div>
<div class="modal-field">
<label id="lbl-mqtt-port">MQTT-Port</label>
<input type="number" id="s-mqtt-port" placeholder="9883">
</div>
<div class="modal-field">
<label id="lbl-username">MQTT-Benutzername</label>
<input type="text" id="s-username" placeholder="userXXXXXXXX" autocomplete="new-password">
</div>
<div class="modal-field">
<label id="lbl-password">MQTT-Passwort</label>
<input type="password" id="s-password" autocomplete="new-password">
</div>
<div class="modal-field">
<label id="lbl-device-id">Device-ID</label>
<input type="text" id="s-device-id" placeholder="32 Hex-Zeichen">
</div>
<div class="modal-field">
<label id="lbl-mode-id">Mode-ID</label>
<input type="text" id="s-mode-id" placeholder="20030">
</div>
</div>
<div>
<div class="modal-section" id="modal-sec-print">Druckeinstellungen</div>
<div class="modal-field">
<label id="lbl-default-slot">Standard-Slot (Einfarbdruck)</label>
<select id="s-default-slot">
<option value="auto" id="opt-slot-auto">Auto (alle belegten Slots)</option>
<option value="0" id="opt-slot-0">Slot 1</option>
<option value="1" id="opt-slot-1">Slot 2</option>
<option value="2" id="opt-slot-2">Slot 3</option>
<option value="3" id="opt-slot-3">Slot 4</option>
</select>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0">
<label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-camera-on-print" style="width:auto;margin:0">
<label id="lbl-camera-on-print" style="margin:0;cursor:pointer" for="s-camera-on-print">Kamera bei Druckstart einschalten</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-web-upload-warning" style="width:auto;margin:0">
<label id="lbl-web-upload-warning" style="margin:0;cursor:pointer" for="s-web-upload-warning">Warnung bei Web-Upload-Druck anzeigen</label>
</div>
</div>
<div>
<div class="modal-section" id="modal-sec-poll">Poll-Intervall</div>
<div class="poll-btns">
<button class="poll-btn" onclick="setPoll(1000)" id="poll-1">1s</button>
<button class="poll-btn active" onclick="setPoll(2000)" id="poll-2">2s</button>
<button class="poll-btn" onclick="setPoll(5000)" id="poll-5">5s</button>
</div>
</div>
<!-- OrcaSlicer-Profile (Custom-Profile-Import, Issue #41) -->
<div>
<div class="modal-section" id="modal-sec-orca-profiles">OrcaSlicer-Profile</div>
<div style="font-size:11px;color:var(--txt2);margin-bottom:8px" id="orca-profiles-hint">
Eigene Profile aus OrcaSlicer importieren (User-Dir öffnen via Help → Show Configuration Folder)
</div>
<div id="orca-profiles-list" style="margin-bottom:8px;font-size:12px;color:var(--txt2)"></div>
<button class="btn btn-sm" id="btn-orca-profiles-import" onclick="openProfileImport()"
style="background:var(--raised);color:var(--txt)">
<span id="lbl-orca-profiles-import">Profile importieren</span>
</button>
</div>
<div>
<div class="modal-section" id="modal-sec-version">Version</div>
<div class="update-row">
<span id="s-version-label" style="font-size:13px;color:var(--txt)"></span>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="checkUpdate()" id="btn-update-check">🔄 <span id="lbl-update-check">Auf Updates prüfen</span></button>
</div>
<div class="update-status" id="update-status" style="margin-top:6px"></div>
<button class="btn btn-sm btn-accent" id="btn-update-apply" style="display:none;margin-top:8px" onclick="applyUpdate()">
<span id="lbl-update-apply">Jetzt installieren</span>
</button>
<div id="update-changelog" style="display:none;margin-top:10px;background:var(--raised);border-radius:6px;padding:10px;font-size:11px;font-family:var(--mono);color:var(--txt2);white-space:pre-wrap;max-height:180px;overflow-y:auto;line-height:1.6"></div>
</div>
<button class="modal-save" onclick="saveSettings()" id="btn-save-settings">Speichern &amp; Neustart</button>
</div>
</div>
<!-- Settings-Modal entfernt — jetzt #panel-settings (Master-Detail im Main-Bereich) -->
<!-- ═══ AMS SLOT EDIT DIALOG ═══ -->
<div class="modal-overlay" id="slot-edit-modal" onclick="if(event.target===this)closeSlotEdit()">
@@ -229,6 +127,8 @@
<span class="nav-icon">🗂</span><span class="nav-text">Browser</span></button>
<button class="nav-btn" onclick="showPanel('console');clearLogBadge()" id="nb-console">
<span class="nav-icon"></span><span class="nav-text">Konsole</span><span id="log-badge" style="display:none;margin-left:4px;background:var(--err);color:#fff;border-radius:10px;font-size:10px;padding:1px 5px;font-weight:700"></span></button>
<button class="nav-btn" onclick="showPanel('settings')" id="nb-settings">
<span class="nav-icon"></span><span class="nav-text" id="nav-settings">Einstellungen</span></button>
</nav>
<main>
@@ -266,9 +166,15 @@
<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 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 class="time-label" id="d-lbl-layers"></div>
<div class="time-val" style="font-size:16px" id="d-layers"></div>
<div style="display:flex;flex-direction:column;gap:4px;flex-shrink:0">
<div class="time-block" style="padding:6px 10px;min-width:72px;text-align:center">
<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 class="time-grid">
@@ -291,6 +197,12 @@
<button class="btn btn-skip btn-sm" id="d-btn-skip" onclick="openSkipDialog()" style="display:none"><span id="d-btn-skip-label">Objekte</span></button>
<button class="btn btn-cancel btn-sm" id="d-btn-cancel" onclick="confirmCancel()">✕ Stopp</button>
</div>
<!-- Aktionen für eine geladene, aber nicht laufende Datei (Issue #55) -->
<div class="ctrl-btns" id="d-idle-btns" style="margin-top:12px;display:none">
<button class="btn btn-accent btn-sm" id="d-idle-print" onclick="startIdleFile()"><span id="d-idle-print-lbl">Drucken</span></button>
<button class="btn btn-sm" id="d-idle-slots" onclick="startIdleFileWithSlots()" style="background:var(--raised);color:var(--txt)"><span id="d-idle-slots-lbl">Slots zuordnen</span></button>
<button class="btn btn-cancel btn-sm" id="d-idle-clear" onclick="clearIdleFile()"><span id="d-idle-clear-lbl">Leeren</span></button>
</div>
</div>
<!-- Temperatursteuerung + Verlauf -->
@@ -513,6 +425,169 @@
<div class="console" id="console-log" style="height:calc(100vh - 260px);min-height:160px" onscroll="onLogScroll()"></div>
</div>
</div>
<!-- ═══ EINSTELLUNGEN ═══ -->
<div class="panel" id="panel-settings">
<div class="settings-wrap">
<div class="settings-cats">
<button class="set-cat active" id="setcat-connection" onclick="showSettingsCat('connection')"><span>🔌</span> <span id="setcat-lbl-connection">Verbindung</span></button>
<button class="set-cat" id="setcat-printer" onclick="showSettingsCat('printer')"><span>🖨</span> <span id="setcat-lbl-printer">Drucker</span></button>
<button class="set-cat" id="setcat-display" onclick="showSettingsCat('display')"><span>🎨</span> <span id="setcat-lbl-display">Darstellung</span></button>
<button class="set-cat" id="setcat-filament" onclick="showSettingsCat('filament')"><span>🧵</span> <span id="setcat-lbl-filament">Filament</span></button>
<button class="set-cat" id="setcat-system" onclick="showSettingsCat('system')"><span></span> <span id="setcat-lbl-system">System</span></button>
</div>
<div class="settings-content">
<!-- Verbindung -->
<div class="set-group active" id="setgrp-connection">
<div class="card">
<div class="card-title"><span>🔌</span> <span id="modal-sec-connection">Verbindung</span></div>
<div class="modal-field" style="margin-bottom:12px">
<label id="lbl-printer-name" style="font-weight:600">Drucker-Name</label>
<input type="text" id="s-printer-name" placeholder="z.B. Kobra X Links">
</div>
<div class="modal-field">
<label id="lbl-printer-ip">Drucker-IP</label>
<input type="text" id="s-printer-ip" placeholder="192.168.x.x">
<small id="lbl-ip-hint" style="color:#f80;display:none"></small>
</div>
<div class="modal-field">
<label id="lbl-mqtt-port">MQTT-Port</label>
<input type="number" id="s-mqtt-port" placeholder="9883">
</div>
<div class="modal-field">
<label id="lbl-username">MQTT-Benutzername</label>
<input type="text" id="s-username" placeholder="userXXXXXXXX" autocomplete="new-password">
</div>
<div class="modal-field">
<label id="lbl-password">MQTT-Passwort</label>
<input type="password" id="s-password" autocomplete="new-password">
</div>
<div class="modal-field">
<label id="lbl-device-id">Device-ID</label>
<input type="text" id="s-device-id" placeholder="32 Hex-Zeichen">
</div>
<div class="modal-field">
<label id="lbl-mode-id">Mode-ID</label>
<input type="text" id="s-mode-id" placeholder="20030">
</div>
</div>
</div>
<!-- Drucker -->
<div class="set-group" id="setgrp-printer">
<div class="card">
<div class="card-title"><span>🖨</span> <span id="modal-sec-print">Druckeinstellungen</span></div>
<div class="modal-field">
<label id="lbl-default-slot">Standard-Slot (Einfarbdruck)</label>
<select id="s-default-slot">
<option value="auto" id="opt-slot-auto">Auto (alle belegten Slots)</option>
<option value="0" id="opt-slot-0">Slot 1</option>
<option value="1" id="opt-slot-1">Slot 2</option>
<option value="2" id="opt-slot-2">Slot 3</option>
<option value="3" id="opt-slot-3">Slot 4</option>
</select>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0">
<label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label>
</div>
<div class="modal-field">
<label id="lbl-file-ready-mode">Nach Upload: Druckstart-Verhalten</label>
<select id="s-file-ready-mode">
<option value="1" id="opt-file-ready-dialog">Print-Dialog</option>
<option value="0" id="opt-file-ready-banner">Print-Leiste</option>
</select>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-camera-on-print" style="width:auto;margin:0">
<label id="lbl-camera-on-print" style="margin:0;cursor:pointer" for="s-camera-on-print">Kamera bei Druckstart einschalten</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-web-upload-warning" style="width:auto;margin:0">
<label id="lbl-web-upload-warning" style="margin:0;cursor:pointer" for="s-web-upload-warning">Warnung bei Web-Upload-Druck anzeigen</label>
</div>
</div>
</div>
<!-- Darstellung -->
<div class="set-group" id="setgrp-display">
<div class="card">
<div class="card-title"><span>🎨</span> <span id="setcat-lbl-display2">Darstellung</span></div>
<div class="modal-field">
<label id="lbl-set-lang">Sprache</label>
<select id="s-lang-select" onchange="setLanguage(this.value)">
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="es">Espanol</option>
<option value="fr">Français</option>
<option value="zh-cn">中文(简体)</option>
</select>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="toggleTheme()"><span id="lbl-set-theme">Hell / Dunkel umschalten</span></button>
</div>
<div class="modal-field">
<label id="lbl-poll-interval">Poll-Intervall (Sekunden)</label>
<input type="number" id="s-poll-interval" min="1" max="60" step="1" placeholder="3" oninput="onPollIntervalInput()">
<small style="color:var(--txt2)" id="lbl-poll-hint">Wie oft die Bridge den Drucker-Status abfragt</small>
</div>
</div>
</div>
<!-- Filament -->
<div class="set-group" id="setgrp-filament">
<div class="card">
<div class="card-title"><span>🧵</span> <span id="modal-sec-orca-profiles">OrcaSlicer-Profile</span></div>
<div style="font-size:11px;color:var(--txt2);margin-bottom:8px" id="orca-profiles-hint">
Eigene Profile aus OrcaSlicer importieren (User-Dir öffnen via Help → Show Configuration Folder)
</div>
<div id="orca-profiles-list" style="margin-bottom:8px;font-size:12px;color:var(--txt2)"></div>
<button class="btn btn-sm" id="btn-orca-profiles-import" onclick="openProfileImport()"
style="background:var(--raised);color:var(--txt)">
<span id="lbl-orca-profiles-import">Profile importieren</span>
</button>
</div>
<div class="card">
<div class="card-title"><span>🎯</span> <span id="lbl-filament-mapping">Filament-Profil-Mapping (pro Slot)</span></div>
<div style="font-size:11px;color:var(--txt2);margin-bottom:8px" id="filament-mapping-hint">
Festes Orca-Profil pro AMS-Slot. Beim Slicer-Sync sendet die Bridge dieses Profil statt „Generic".
</div>
<div id="filament-mapping-list"></div>
<button class="btn btn-sm" style="background:var(--accent);color:#fff;margin-top:8px" onclick="saveFilamentMapping()"><span id="lbl-filament-mapping-save">Mapping speichern</span></button>
</div>
<div class="card">
<div class="card-title"><span>👁</span> <span id="lbl-visible-vendors">Sichtbare Hersteller (Profil-Dropdown)</span></div>
<div style="font-size:11px;color:var(--txt2);margin-bottom:8px" id="visible-vendors-hint">
Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic" und eigene Profile sind immer sichtbar.
</div>
<input type="text" id="vendor-filter-search" placeholder="Hersteller suchen…" oninput="renderVendorChecklist()"
style="width:100%;padding:6px 10px;margin-bottom:8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:12px">
<div id="visible-vendors-list" style="max-height:260px;overflow-y:auto;border:1px solid var(--border);border-radius:6px;padding:8px"></div>
<button class="btn btn-sm" style="background:var(--accent);color:#fff;margin-top:8px" onclick="saveVisibleVendors()"><span id="lbl-visible-vendors-save">Auswahl speichern</span></button>
</div>
</div>
<!-- System -->
<div class="set-group" id="setgrp-system">
<div class="card">
<div class="card-title"><span></span> <span id="modal-sec-version">Version</span></div>
<div class="update-row">
<span id="s-version-label" style="font-size:13px;color:var(--txt)"></span>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="checkUpdate()" id="btn-update-check">🔄 <span id="lbl-update-check">Auf Updates prüfen</span></button>
</div>
<div class="update-status" id="update-status" style="margin-top:6px"></div>
<button class="btn btn-sm btn-accent" id="btn-update-apply" style="display:none;margin-top:8px" onclick="applyUpdate()">
<span id="lbl-update-apply">Jetzt installieren</span>
</button>
<div id="update-changelog" style="display:none;margin-top:10px;background:var(--raised);border-radius:6px;padding:10px;font-size:11px;font-family:var(--mono);color:var(--txt2);white-space:pre-wrap;max-height:180px;overflow-y:auto;line-height:1.6"></div>
</div>
</div>
<button class="modal-save" onclick="saveSettings()" id="btn-save-settings" style="margin-top:14px">Speichern &amp; Neustart</button>
</div>
</div>
</div>
</main>
</div>
@@ -521,6 +596,7 @@
<button class="bnav-btn" onclick="showPanel('printers');loadPrinterTab()" id="bnb-printers"><span class="bnav-icon">🖨</span>Drucker</button>
<button class="bnav-btn" onclick="showPanel('store');loadStore()" id="bnb-store"><span class="bnav-icon">🗂</span>Browser</button>
<button class="bnav-btn" onclick="showPanel('console');clearLogBadge()" id="bnb-console"><span class="bnav-icon"></span>Log<span id="log-badge-bot" style="display:none;margin-left:3px;background:var(--err);color:#fff;border-radius:10px;font-size:10px;padding:1px 4px;font-weight:700"></span></button>
<button class="bnav-btn" onclick="showPanel('settings')" id="bnb-settings"><span class="bnav-icon"></span>Setup</button>
</nav>
@@ -550,9 +626,23 @@
<p id="fd-slots-hint" style="font-size:12px;color:var(--txt2);margin-bottom:10px">GCode-Kanal → AMS-Slot zuweisen:</p>
<div id="fd-slots" style="display:flex;flex-direction:column;gap:8px;margin-bottom:16px"></div>
<div id="fd-objects-section" style="display:none;margin-bottom:16px">
<p id="fd-objects-hint" style="font-size:12px;color:var(--txt2);margin-bottom:8px">Objekte überspringen (optional):</p>
<div id="fd-objects-svg" style="display:none;background:var(--raised);border:1px solid var(--border);border-radius:8px;padding:6px;margin-bottom:8px;text-align:center"></div>
<div id="fd-objects" style="display:flex;flex-direction:column;gap:6px;max-height:140px;overflow-y:auto"></div>
<button type="button" id="fd-objects-toggle" onclick="toggleFdObjects()"
style="display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer;font-size:12px;text-align:left">
<span id="fd-objects-arrow" style="font-size:10px;transition:transform .15s"></span>
<span><span id="fd-objects-toggle-lbl">Objekte überspringen</span></span>
<span id="fd-objects-count" style="margin-left:auto;color:var(--txt2);font-weight:600"></span>
</button>
<div id="fd-objects-body" style="display:none;margin-top:8px">
<div id="fd-objects-svg" style="display:none;background:var(--raised);border:1px solid var(--border);border-radius:8px;padding:6px;margin-bottom:8px;text-align:center"></div>
<div id="fd-objects" style="display:flex;flex-direction:column;gap:6px;max-height:140px;overflow-y:auto"></div>
</div>
</div>
<div style="margin-bottom:14px;padding:10px 12px;background:var(--raised);border-radius:8px;border:1px solid var(--border)">
<div style="font-size:11px;font-weight:600;color:var(--txt2);margin-bottom:8px;text-transform:uppercase;letter-spacing:.05em" id="fd-options-title">Druckoptionen</div>
<div style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="fd-auto-leveling" style="width:auto;margin:0">
<label for="fd-auto-leveling" style="margin:0;cursor:pointer;font-size:13px" id="fd-lbl-auto-leveling">Auto-Leveling</label>
</div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button id="fd-cancel" onclick="closeFilamentDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>

View File

@@ -212,6 +212,24 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:
.panel{display:none}
.panel.active{display:block}
/* ── SETTINGS (Master-Detail) ── */
.settings-wrap{display:grid;grid-template-columns:200px 1fr;gap:16px;align-items:start}
.settings-cats{display:flex;flex-direction:column;gap:4px;position:sticky;top:12px}
.set-cat{display:flex;align-items:center;gap:8px;padding:10px 12px;border-radius:8px;
border:1px solid transparent;background:var(--raised);color:var(--txt2);cursor:pointer;
font-size:13px;text-align:left;transition:background .15s,color .15s}
.set-cat:hover{color:var(--txt)}
.set-cat.active{background:var(--accent);color:#fff;border-color:var(--accent)}
.set-group{display:none}
.set-group.active{display:block}
.set-group .card{margin-bottom:14px}
@media(max-width:768px){
.settings-wrap{grid-template-columns:1fr}
.settings-cats{flex-direction:row;flex-wrap:wrap;position:static;overflow-x:auto}
.set-cat{flex:1;min-width:auto;justify-content:center;padding:8px 6px;font-size:12px}
.set-cat .nav-text{display:inline}
}
/* ── FILE BROWSER UPLOAD ZONE ── */
#store-upload-zone{
display:flex;flex-direction:column;align-items:center;justify-content:center;

View File

@@ -37,6 +37,7 @@
"lbl_remaining": "Restzeit:",
"lbl_slicer_time": "Slicer-Schätzung:",
"lbl_layers": "Layer",
"lbl_zpos": "Z (mm)",
"speed_silent": "🐢 Leise",
"speed_normal": "⚡ Normal",
"speed_sport": "🚀 Sport",
@@ -126,8 +127,21 @@
"settings_title": "Einstellungen",
"settings_connection": "Verbindung",
"settings_print": "Druckeinstellungen",
"settings_poll": "Poll-Intervall",
"settings_poll": "Poll-Intervall (Sekunden)",
"settings_version": "Version",
"nav_settings": "Einstellungen",
"settings_cat_display": "Darstellung",
"settings_cat_filament": "Filament",
"settings_cat_language": "Sprache",
"settings_cat_theme": "Hell / Dunkel umschalten",
"settings_filament_mapping": "Filament-Profil-Mapping (pro Slot)",
"settings_filament_mapping_save": "Mapping speichern",
"settings_visible_vendors": "Sichtbare Hersteller (Profil-Dropdown)",
"settings_visible_vendors_hint": "Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic\" und eigene Profile sind immer sichtbar.",
"settings_visible_vendors_save": "Auswahl speichern",
"progress_action_print": "Drucken",
"progress_action_slots": "Slots zuordnen",
"progress_action_clear": "Leeren",
"settings_save": "Speichern & Neustart",
"settings_printer_name": "Drucker-Name",
"settings_printer_ip": "Drucker-IP",
@@ -192,6 +206,7 @@
"skip_sending": "Sende …",
"skip_success": "Objekte werden übersprungen.",
"fd_objects_hint": "Objekte überspringen (optional):",
"fd_objects_toggle": "Objekte überspringen",
"fd_slots_hint": "GCode-Kanal → AMS-Slot zuweisen:",
"fd_cancel": "Abbrechen",
"fd_print": "▶ Drucken",
@@ -237,11 +252,40 @@
"store_upload_busy": "⏳ Hochladen…",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"store_upload_only_gcode": "✗ Nur GCode-Dateien erlaubt (.gcode, .3mf, .bgcode)",
"sf_all": "Alle",
"sf_ok": "✓ Erfolgreich",
"sf_err": "✗ Fehler",
"sf_new": "Neu",
"ss_date": "↓ Datum",
"ss_name": "AZ Name",
"ss_dur": "⏱ Druckzeit"
"ss_dur": "⏱ Druckzeit",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_tpu": "TPU",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_custom": "Custom",
"fd_options_title": "Optionen",
"print_auto_leveling": "Auto-Leveling für diesen Druck",
"settings_file_ready_mode": "Druckdialog starten",
"settings_file_ready_banner": "Druckleiste",
"settings_file_ready_dialog": "Druckdialog",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_dir_label": "Richtung:",
"log_lvl_err": "⛔ Fehler",
"log_lvl_warn": "⚠ Warnung",
"log_topic_label": "Thema:",
"log_topic_ams": "AMS",
"log_topic_print": "Druck",
"log_topic_info": "Info",
"log_topic_status": "Status",
"log_download": "⬇ Download",
"log_auto": "⬇ Auto",
"log_clear": "✕ Leeren",
"log_filter_placeholder": "Filtern…",
"skip_cancel": "Abbrechen",
"skip_confirm": "Überspringen"
}

View File

@@ -37,6 +37,7 @@
"lbl_remaining": "Remaining:",
"lbl_slicer_time": "Slicer estimate:",
"lbl_layers": "Layer",
"lbl_zpos": "Z (mm)",
"speed_silent": "🐢 Silent",
"speed_normal": "⚡ Normal",
"speed_sport": "🚀 Sport",
@@ -68,6 +69,13 @@
"ace_dry_dialog_save_restart": "Save & Restart",
"ace_dry_dialog_custom_name": "Custom Name",
"ace_dry_dialog_reset_default": "Reset to Default",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_tpu": "TPU",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_custom": "Custom",
"cam_placeholder": "📷 Camera not started",
"cam_stream_unavailable": "Stream unavailable",
"btn_cam_start": "▶ Camera",
@@ -126,7 +134,20 @@
"settings_title": "Settings",
"settings_connection": "Connection",
"settings_print": "Print Settings",
"settings_poll": "Poll Interval",
"settings_poll": "Poll Interval (seconds)",
"nav_settings": "Settings",
"settings_cat_display": "Appearance",
"settings_cat_filament": "Filament",
"settings_cat_language": "Language",
"settings_cat_theme": "Toggle light / dark",
"settings_filament_mapping": "Filament profile mapping (per slot)",
"settings_filament_mapping_save": "Save mapping",
"settings_visible_vendors": "Visible vendors (profile dropdown)",
"settings_visible_vendors_hint": "Only these vendors appear in the slot profile dropdown. Nothing selected = show all. \"Generic\" and your own profiles are always visible.",
"settings_visible_vendors_save": "Save selection",
"progress_action_print": "Print",
"progress_action_slots": "Map slots",
"progress_action_clear": "Clear",
"settings_version": "Version",
"settings_save": "Save & Restart",
"settings_printer_name": "Printer Name",
@@ -139,7 +160,12 @@
"hint_ip_no_port": "IP address only, no port (e.g. 192.168.1.102)",
"settings_default_slot": "Default Slot (single color)",
"settings_slot_auto": "Auto (all loaded slots)",
"settings_auto_leveling": "Auto-Leveling before print",
"settings_auto_leveling": "Auto-Leveling Default",
"fd_options_title": "Print Options",
"print_auto_leveling": "Auto-Leveling",
"settings_file_ready_mode": "Start Print Behavior",
"settings_file_ready_banner": "Print Bar",
"settings_file_ready_dialog": "Print Dialog",
"settings_camera_on_print": "Turn camera on at print start",
"settings_web_upload_warning": "Show warning when printing web uploads",
"update_check": "Check for Updates",
@@ -178,7 +204,21 @@
"orca_profile_done": "Imported",
"orca_profile_skipped": "skipped",
"log_dir_all": "All",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_dir_label": "Dir:",
"log_lvl_label": "Level:",
"log_lvl_err": "⛔ Errors",
"log_lvl_warn": "⚠ Warn",
"log_topic_label": "Topic:",
"log_topic_ams": "AMS",
"log_topic_print": "Print",
"log_topic_info": "Info",
"log_topic_status": "Status",
"log_download": "⬇ Download",
"log_auto": "⬇ Auto",
"log_clear": "✕ Clear",
"log_filter_placeholder": "Filter…",
"file_ready_btn": "▶ Start Print",
"file_slots_btn": "🎨 Select Slots",
"file_cancel_btn": "✕ Cancel",
@@ -188,10 +228,13 @@
"skip_btn_label": "Objects",
"skip_no_objects": "No objects in this print.",
"skip_already": "skipped",
"skip_cancel": "Cancel",
"skip_confirm": "Skip",
"skip_select_at_least_one": "Please pick at least one object.",
"skip_sending": "Sending …",
"skip_success": "Objects will be skipped.",
"fd_objects_hint": "Skip objects (optional):",
"fd_objects_toggle": "Skip objects",
"fd_slots_hint": "Assign GCode channel to AMS slot:",
"fd_cancel": "Cancel",
"fd_print": "▶ Print",
@@ -237,6 +280,7 @@
"store_upload_busy": "⏳ Uploading…",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"store_upload_only_gcode": "✗ Only GCode files allowed (.gcode, .3mf, .bgcode)",
"sf_all": "All",
"sf_ok": "✓ Completed",
"sf_err": "✗ Failed",

View File

@@ -37,6 +37,7 @@
"lbl_remaining": "Restante:",
"lbl_slicer_time": "Estimación del slicer:",
"lbl_layers": "Capa",
"lbl_zpos": "Z (mm)",
"speed_silent": "🐢 Silencioso",
"speed_normal": "⚡ Normal",
"speed_sport": "🚀 Sport",
@@ -126,7 +127,20 @@
"settings_title": "Configuración",
"settings_connection": "Conexión",
"settings_print": "Ajustes de impresión",
"settings_poll": "Intervalo de sondeo",
"settings_poll": "Intervalo de sondeo (segundos)",
"nav_settings": "Ajustes",
"settings_cat_display": "Apariencia",
"settings_cat_filament": "Filamento",
"settings_cat_language": "Idioma",
"settings_cat_theme": "Alternar claro / oscuro",
"settings_filament_mapping": "Asignación de perfil de filamento (por ranura)",
"settings_filament_mapping_save": "Guardar asignación",
"settings_visible_vendors": "Fabricantes visibles (lista de perfiles)",
"settings_visible_vendors_hint": "Solo estos fabricantes aparecen en la lista de perfiles de ranura. Nada seleccionado = mostrar todos. «Generic» y tus propios perfiles siempre son visibles.",
"settings_visible_vendors_save": "Guardar selección",
"progress_action_print": "Imprimir",
"progress_action_slots": "Asignar ranuras",
"progress_action_clear": "Vaciar",
"settings_version": "Versión",
"settings_save": "Guardar y reiniciar",
"settings_printer_name": "Nombre de impresora",
@@ -192,6 +206,7 @@
"skip_sending": "Enviando …",
"skip_success": "Se omitirán los objetos.",
"fd_objects_hint": "Omitir objetos (opcional):",
"fd_objects_toggle": "Omitir objetos",
"fd_slots_hint": "Asignar canal GCode a la ranura AMS:",
"fd_cancel": "Cancelar",
"fd_print": "▶ Imprimir",
@@ -237,11 +252,40 @@
"store_upload_busy": "⏳ Subiendo…",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"store_upload_only_gcode": "✗ Solo se permiten archivos GCode (.gcode, .3mf, .bgcode)",
"sf_all": "Todos",
"sf_ok": "✓ Completado",
"sf_err": "✗ Fallido",
"sf_new": "Nuevo",
"ss_date": "↓ Fecha",
"ss_name": "AZ Nombre",
"ss_dur": "⏱ Tiempo de impresión"
"ss_dur": "⏱ Tiempo de impresión",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_tpu": "TPU",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_custom": "Personalizado",
"fd_options_title": "Opciones",
"print_auto_leveling": "Autonivelado para esta impresión",
"settings_file_ready_mode": "Iniciar diálogo de impresión",
"settings_file_ready_banner": "Barra de impresión",
"settings_file_ready_dialog": "Diálogo de impresión",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_dir_label": "Dirección:",
"log_lvl_err": "⛔ Errores",
"log_lvl_warn": "⚠ Avisos",
"log_topic_label": "Tema:",
"log_topic_ams": "AMS",
"log_topic_print": "Impresión",
"log_topic_info": "Info",
"log_topic_status": "Estado",
"log_download": "⬇ Descargar",
"log_auto": "⬇ Auto",
"log_clear": "✕ Limpiar",
"log_filter_placeholder": "Filtrar…",
"skip_cancel": "Cancelar",
"skip_confirm": "Omitir"
}

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

@@ -0,0 +1,291 @@
{
"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 (secondes)",
"nav_settings": "Paramètres",
"settings_cat_display": "Apparence",
"settings_cat_filament": "Filament",
"settings_cat_language": "Langue",
"settings_cat_theme": "Basculer clair / sombre",
"settings_filament_mapping": "Mappage du profil de filament (par emplacement)",
"settings_filament_mapping_save": "Enregistrer le mappage",
"settings_visible_vendors": "Fabricants visibles (liste des profils)",
"settings_visible_vendors_hint": "Seuls ces fabricants apparaissent dans la liste des profils d'emplacement. Rien de sélectionné = tout afficher. « Generic » et vos propres profils sont toujours visibles.",
"settings_visible_vendors_save": "Enregistrer la sélection",
"progress_action_print": "Imprimer",
"progress_action_slots": "Affecter les emplacements",
"progress_action_clear": "Vider",
"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_objects_toggle": "Ignorer des objets",
"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}",
"store_upload_only_gcode": "✗ Seuls les fichiers GCode sont autorisés (.gcode, .3mf, .bgcode)",
"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",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_tpu": "TPU",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_custom": "Personnalisé",
"fd_options_title": "Options",
"print_auto_leveling": "Mise à niveau auto pour cette impression",
"settings_file_ready_mode": "Démarrer le dialogue d'impression",
"settings_file_ready_banner": "Barre d'impression",
"settings_file_ready_dialog": "Dialogue d'impression",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_dir_label": "Sens :",
"log_lvl_err": "⛔ Erreurs",
"log_lvl_warn": "⚠ Avert.",
"log_topic_label": "Sujet :",
"log_topic_ams": "AMS",
"log_topic_print": "Impression",
"log_topic_info": "Info",
"log_topic_status": "Statut",
"log_download": "⬇ Télécharger",
"log_auto": "⬇ Auto",
"log_clear": "✕ Effacer",
"log_filter_placeholder": "Filtrer…",
"skip_cancel": "Annuler",
"skip_confirm": "Ignorer"
}

View File

@@ -37,6 +37,7 @@
"lbl_remaining": "剩余时间:",
"lbl_slicer_time": "切片预估:",
"lbl_layers": "层",
"lbl_zpos": "Z (mm)",
"speed_silent": "🐢 静音",
"speed_normal": "⚡ 标准",
"speed_sport": "🚀 运动",
@@ -126,7 +127,20 @@
"settings_title": "设置",
"settings_connection": "连接",
"settings_print": "打印设置",
"settings_poll": "轮询间隔",
"settings_poll": "轮询间隔(秒)",
"nav_settings": "设置",
"settings_cat_display": "外观",
"settings_cat_filament": "耗材",
"settings_cat_language": "语言",
"settings_cat_theme": "切换浅色 / 深色",
"settings_filament_mapping": "耗材配置映射(每槽位)",
"settings_filament_mapping_save": "保存映射",
"settings_visible_vendors": "可见厂商(配置下拉框)",
"settings_visible_vendors_hint": "仅这些厂商会出现在槽位配置下拉框中。未选择 = 显示全部。“Generic”和您自己的配置始终可见。",
"settings_visible_vendors_save": "保存选择",
"progress_action_print": "打印",
"progress_action_slots": "分配槽位",
"progress_action_clear": "清除",
"settings_version": "版本",
"settings_save": "保存并重启",
"settings_printer_name": "打印机名称",
@@ -192,6 +206,7 @@
"skip_sending": "发送中 …",
"skip_success": "对象将被跳过。",
"fd_objects_hint": "跳过对象 (可选):",
"fd_objects_toggle": "跳过对象",
"fd_slots_hint": "将 GCode 通道分配到 AMS 槽位:",
"fd_cancel": "取消",
"fd_print": "▶ 打印",
@@ -237,11 +252,40 @@
"store_upload_busy": "⏳ 上传中…",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"store_upload_only_gcode": "✗ 仅允许 GCode 文件 (.gcode, .3mf, .bgcode)",
"sf_all": "全部",
"sf_ok": "✓ 已完成",
"sf_err": "✗ 失败",
"sf_new": "新",
"ss_date": "↓ 日期",
"ss_name": "AZ 名称",
"ss_dur": "⏱ 打印时间"
"ss_dur": "⏱ 打印时间",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_tpu": "TPU",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_custom": "自定义",
"fd_options_title": "选项",
"print_auto_leveling": "本次打印自动调平",
"settings_file_ready_mode": "开始打印对话框",
"settings_file_ready_banner": "打印栏",
"settings_file_ready_dialog": "打印对话框",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_dir_label": "方向:",
"log_lvl_err": "⛔ 错误",
"log_lvl_warn": "⚠ 警告",
"log_topic_label": "主题:",
"log_topic_ams": "AMS",
"log_topic_print": "打印",
"log_topic_info": "信息",
"log_topic_status": "状态",
"log_download": "⬇ 下载",
"log_auto": "⬇ 自动",
"log_clear": "✕ 清空",
"log_filter_placeholder": "筛选…",
"skip_cancel": "取消",
"skip_confirm": "跳过"
}