Compare commits

9 Commits

Author SHA1 Message Date
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
76738e5961 release: v0.9.19 2026-06-02 13:59:53 +02:00
9c82073540 build: sources for v0.9.19 2026-06-02 13:31:47 +02:00
031e34d8ea merge: PR #42 — Dryer toggle false error (@gangoke)
ACE-Dryer setDry geht jetzt fire-and-forget (timeout=0, kein Response-
Check). Drucker führt den Befehl korrekt aus, aber liefert code:0
statt code:200 — was eine 502-Fehlermeldung in der Bridge-UI auslöste
obwohl der Trockner-Toggle eigentlich funktioniert hat.

Pattern identisch zu setAutoFeed (Z.3161).
2026-06-01 14:28:42 +02:00
Gangoke
fc89dfffa5 fire and forget setDry 2026-05-31 17:27:01 -10:00
18 changed files with 987 additions and 5714 deletions

View File

@@ -1,5 +1,84 @@
# Changelog
## [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
- **🎯 Filament-Sync mit OrcaSlicer matched jetzt das richtige Preset**
statt immer auf „Generic PLA" zu landen. Voraussetzung: ein
OrcaSlicer-Build mit dem
[PR #13719](https://github.com/SoftFever/OrcaSlicer/pull/13719)
Empfangs-Patch (im OrcaSlicer-KX-Build dabei). Die Bridge sendet pro
AMS-Slot jetzt `name` + `vendor_name` im Lane-Pfad UND
`gate_filament_name` im Happy-Hare-MMU-Pfad (OrcaSlicer wechselt bei
AMS-Setups automatisch auf den HH-Pfad).
- **Eigene OrcaSlicer-Profile in die Bridge importieren (Issue #41).**
Settings-Tab → „OrcaSlicer-Profile" oder direkt im Slot-Edit-Dialog
(„★ Eigene Profile importieren…") lädst du deine `.json`-Files aus
`~/.config/OrcaSlicer/user/<id>/filament/` hoch — einzeln oder als
ZIP. Erscheint dann im Slot-Dropdown unter „★ Eigene Profile" und
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 werden korrekt
erkannt — die Bridge resolved die vererbten Felder aus dem
System-Parent.
### Fixes
- **AMS-Sync landete hartnäckig auf „Generic PLA":** das Orca-
Datenmodell hat 68 duplikate `filament_id`-Werte (`OGFL99` allein
136 mal), und die Bridge wählte oft eine ID die für den Kobra X
nicht `is_compatible` war (z.B. `GFL92` aus dem Kobra-2-Profil →
Orca verwarf es). Generator priorisiert jetzt Kobra-X-Varianten und
filtert Phantom-Profile (Cross-Vendor-Overrides) raus —
`orca_filaments.json` von 1035 → 400 saubere Profile.
- **Slot ohne expliziten Override sendet jetzt `Generic <Typ>`** statt
einer impliziten Vendor-Annahme. Library-Generic-Profile haben
`compatible_printers: []` (= alle Drucker), sind also immer sichtbar
und matchen verlässlich.
- **Slot-Karte zeigt den Hersteller direkt nach dem Speichern** —
ohne Browser-Reload. `poll()` war async, das Re-Render kam erst
beim nächsten Tick.
- **ACE-Trockner-Toggle warf 502-Fehler obwohl der Trockner ein-/aus-
ging (PR #42 von @gangoke):** `setDry` jetzt fire-and-forget wie
`setAutoFeed`. Der Drucker antwortet auf diesem Push-Topic mit
`code: 0` statt `code: 200`, das hat die Bridge fälschlich als
Fehler interpretiert.
### Datenmodell / API
- `orca_filaments.json` regeneriert: nur echte Vendor-Profile und
OrcaFilamentLibrary-Profile bleiben drin. Bambu/Polymaker/SUNLU-
Library-Profile dabei, Qidi-Cross-Bundles raus.
- Neue Endpoints: `POST /kx/filament/profiles/user` (ZIP/JSON-Upload),
`GET /kx/filament/profiles/user` (Liste der User-Imports),
`DELETE /kx/filament/profiles/user[?vendor=…&name=…]`. Persistenz
in `<KX_DATA_DIR>/orca_filaments.user.json` (Volume — überlebt
Image-Updates).
### Build
- Neues Modul `bridge/orca_filaments.py` (gemeinsame Parser-Helfer
für Generator und Import-Endpoint).
- Dockerfile + release.sh um `orca_filaments.py` erweitert.
## [0.9.18] 2026-05-31
### Neu
@@ -11,8 +90,8 @@
(`slicer/printer/…`) gesendet, der Drucker hat ihn stillschweigend
ignoriert. Jetzt geht er über `web/printer/…` wie der Anycubic
Slicer Next es macht (verifiziert via Live-MQTT-Sniff +
Workbench-Vue-Source). **Achtung:** der aktiv geladene Slot kann
während des Drucks nicht umgeschrieben werden — vorher ausziehen.
Workbench-Vue-Source). **Achtung:** der Drucker muss im Idle-Zustand
sein, und leere Slots lassen sich nicht beschriften.
- **Mehrsprachiges UI spanische Übersetzung von Muttersprachler
überarbeitet (PR #40 von @pezfisk):** fehlende Akzente
(impresión, cámara, después, animación, …), Begriffe vereinheitlicht

View File

@@ -1,5 +1,84 @@
# Changelog
## [0.9.19.1] 2026-06-04
### Fixed
- 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.
### Changed
- 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
### New
- **🎯 Filament sync with OrcaSlicer now picks the right preset**
instead of always falling back to "Generic PLA". Requires an
OrcaSlicer build with the
[PR #13719](https://github.com/SoftFever/OrcaSlicer/pull/13719)
receive-side patch (included in the OrcaSlicer-KX build). The bridge
sends `name` + `vendor_name` per AMS slot on the lane path AND
`gate_filament_name` per gate on the Happy-Hare MMU path (OrcaSlicer
switches to the HH path automatically for AMS setups).
- **Import your own OrcaSlicer profiles into the bridge (issue #41).**
Settings → "OrcaSlicer Profiles" or directly in the slot-edit dialog
("★ Import own profiles…") lets you upload `.json` files from
`~/.config/OrcaSlicer/user/<id>/filament/` — single files or as a
ZIP. They show up in the slot dropdown under "★ Own profiles" and
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 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
duplicate `filament_id` values (`OGFL99` alone shared by 136
profiles), and the bridge often picked an ID that was not
`is_compatible` with the Kobra X (e.g. `GFL92` from the Kobra-2
profile → Orca rejected it). The generator now prioritises Kobra-X
variants and filters out phantom profiles (cross-vendor overrides) —
`orca_filaments.json` dropped from 1035 to 400 clean profiles.
- **Slot without an explicit override now sends `Generic <type>`**
instead of an implicit vendor guess. Library generic profiles have
`compatible_printers: []` (= all printers), so they are always
visible and match reliably.
- **Slot card shows the vendor right after save** without a browser
reload. `poll()` was async, the re-render only happened on the next
tick.
- **ACE dryer toggle threw a 502 even though the dryer worked
(PR #42 by @gangoke):** `setDry` is now fire-and-forget like
`setAutoFeed`. The printer answers on that push topic with
`code: 0` instead of `code: 200`, which the bridge wrongly treated
as an error.
### Data model / API
- `orca_filaments.json` regenerated: only real vendor profiles and
OrcaFilamentLibrary profiles. Bambu/Polymaker/SUNLU library profiles
in, Qidi cross-bundles out.
- New endpoints: `POST /kx/filament/profiles/user` (ZIP/JSON upload),
`GET /kx/filament/profiles/user` (list user imports),
`DELETE /kx/filament/profiles/user[?vendor=…&name=…]`. Persisted in
`<KX_DATA_DIR>/orca_filaments.user.json` (volume — survives image
updates).
### Build
- New module `bridge/orca_filaments.py` (shared parser helpers used by
the generator and the import endpoint).
- Dockerfile + release.sh updated to include `orca_filaments.py`.
## [0.9.18] 2026-05-31
### New
@@ -10,8 +89,8 @@
sent over the wrong MQTT topic (`slicer/printer/…`) and the printer
silently dropped it. It now goes via `web/printer/…` like the
Anycubic Slicer Next does (verified by live MQTT sniff +
Workbench-Vue source). **Note:** the currently loaded slot can not
be overwritten during a print — unload it first.
Workbench-Vue source). **Note:** the printer must be idle, and
empty slots can not be labelled.
- **Spanish translation reviewed by a native speaker (PR #40 by
@pezfisk):** missing accents (impresión, cámara, después,
animación, …) and term consistency (Pause → Pausa, Start →

View File

@@ -13,6 +13,7 @@ COPY data/ ./static/
COPY config_loader.py .
COPY env_loader.py .
COPY kobrax_client.py .
COPY orca_filaments.py .
COPY VERSION .
COPY anycubic_slicer.crt .
COPY anycubic_slicer.key .

View File

@@ -8,6 +8,11 @@
Eine Moonraker-kompatible Bridge, die direkt mit dem Drucker spricht.
<sub>🧪 Ein Community-Bericht auf Reddit deutet darauf hin, dass die Bridge auch
mit dem **Kobra S1** und **Kobra S1 Max** funktioniert — die Protokolle wirken
kompatibel, beides ist aber weder offiziell getestet noch unterstützt.
Feedback willkommen.</sub>
<sub>🇬🇧 <a href="README.md">English version</a> · 🇪🇸 <a href="README.es.md">Versión española</a></sub>
<br>
@@ -32,12 +37,17 @@ Eine Moonraker-kompatible Bridge, die direkt mit dem Drucker spricht.
|---|---|
| 🖨️ | **Druckersteuerung** — Start, Pause, Resume, Abbruch, Temperaturen, Druckgeschwindigkeit |
| 📊 | **Live-Status** — Temperatur, Fortschritt, Layer, Restzeit, Kamera-Stream |
| 🎨 | **AMS / Multicolor**Filament-Slots, Per-Kanal-Remapping, MMU-Emulation für OrcaSlicer Filament-Sync |
| 🎨 | **AMS / Multicolor**Slots mit **Profil-Picker pro Slot** (eigene Marke aus OrcaSlicer-Profilen pro Slot zuweisen); Bridge schreibt Material und Farbe ans Drucker-Display zurück |
| 📦 | **Eigene OrcaSlicer-Profile importieren** — ZIP aus `~/.config/OrcaSlicer/user/<id>/filament/` in die Bridge ziehen; tauchen im Slot-Dropdown unter ★ Eigene Profile auf |
| 📷 | **Obico-Integration (experimentell)** — Time-Lapse und WebRTC-Livestream gegen einen selbst gehosteten [Obico-Server](https://github.com/TheSpaghettiDetective/obico-server) via moonraker-obico |
| 📐 | **H.264-Direkt-Stream + Z-Höhe** — sparsamer Kamera-Pfad für Obico, aktuelle Z aus der Layer-Höhe abgeleitet (Mm-Progress-Widget) |
| 🗂️ | **GCode-Browser** — hochgeladene Dateien mit Thumbnail, Druckhistorie, Suche & Filter |
| 🧩 | **Multi-Printer** — mehrere Drucker in **einer** Bridge-Instanz, Umschalten per Dropdown |
| | **Drucker hinzufügen per Klick** — nur die IP eingeben, Zugangsdaten werden automatisch importiert |
| 🔁 | **Robuster MQTT-Reconnect** — Bridge überlebt nächtlichen Drucker-Reboot ohne manuellen Neustart |
| 🌐 | **Mehrsprachiges UI** — DE / EN / ES / 中文, Browser-Sprache automatisch erkannt |
| 🔄 | **Self-Update** — neue Versionen direkt im Browser installieren |
| 🌐 | **OrcaSlicer** — volles Moonraker-Protokoll (HTTP + WebSocket), DE/EN UI |
| 🧠 | **OrcaSlicer** — volles Moonraker-Protokoll (HTTP + WebSocket); für korrekten Vendor-Match pro Slot den [OrcaSlicer-KX-Build](#-empfohlener-slicer) nutzen |
---
@@ -103,18 +113,28 @@ Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125`
## 🎨 Empfohlener Slicer
Für die beste Erfahrung mit der KX-Bridge bieten wir einen **gepatchten
OrcaSlicer-Build**, der drei offene SoftFever/OrcaSlicer-PRs bündelt: das
Anycubic-Kobra-X-Druckerprofil, einen Multicolor-G-Code-Fix und — am wichtigsten
— einen Moonraker/Happy-Hare-Filament-Sync-Fix, der die AMS-Slot-Positionen auch
bei einem leeren Slot in der Mitte korrekt beibehält.
Für sauberen AMS-Filament-Sync gibt es einen **gepatchten OrcaSlicer-Build**:
**[OrcaSlicer-KX Releases](https://gitea.it-drui.de/viewit/OrcaSlicer-KX/releases/latest)** (Linux AppImage + Windows ZIP)
Standard-OrcaSlicer funktioniert auch; der gepatchte Build verbessert
hauptsächlich das AMS-Handling. Es ist ein Build von
[OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0); der Quellcode
ist über die verlinkten PRs verfügbar.
**Upstream-PRs im KX-Build:**
- **[PR #13372](https://github.com/SoftFever/OrcaSlicer/pull/13372)** — Moonraker / Happy-Hare AMS-Sync-Fix (Slot-Positionen bleiben auch bei leerem Slot in der Mitte korrekt)
- **[PR #13719](https://github.com/SoftFever/OrcaSlicer/pull/13719)** — Vendor- + Name-Matching für Moonraker (liest `name` + `vendor_name` pro Slot und matched gegen deine Filament-Presets), von [@LordGuenni](https://github.com/LordGuenni)
- **[PR #13315](https://github.com/SoftFever/OrcaSlicer/pull/13315)** — Eindeutige `filament_id` für User-Presets (neu erstellte eigene Profile bekommen eine frische ID statt das `OGFL99` vom Generic-Parent zu erben), von [@mrnoisytiger](https://github.com/mrnoisytiger)
**Plus vier KX-eigene Verbesserungen on top:**
- Bridge-Filament-Hint (`tray_info_idx` + Vendor) respektieren
- Vendor-Match auch wenn das gewählte Base-Preset **nicht is_compatible** mit dem aktiven Drucker ist (so matchen Profile aus anderen Drucker-Setups trotzdem über die Marke)
- Vendor-Match wenn `tray_info_idx` gesetzt ist, das Preset aber inkompatibel
- Zwei-Pass-Suche: erst kompatible Presets, dann alle sichtbaren
**Warum das zusammen wichtig ist:** ohne #13719 landen die AMS-Slots in OrcaSlicer alle auf `Generic PLA` / `Generic PETG`, obwohl die Bridge die konkrete Marke schon mitsendet (`name + vendor_name + gate_filament_name`). Mit dem KX-Build matched OrcaSlicer deine echten User-Presets — auch die, die du via [Eigene OrcaSlicer-Profile importieren](#-features) in die Bridge gezogen hast.
Stock-Upstream-OrcaSlicer funktioniert für Slicing und Drucken weiterhin — nur das Per-Slot-Vendor-Matching beim AMS-Sync fällt dann weg. Material und Farbe pro Slot kannst du auch ohne den KX-Build über die Bridge ans Drucker-Display schreiben (das läuft über MQTT, nicht über den Slicer).
OrcaSlicer-KX ist ein Build von [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0); der Quellcode der Upstream-PRs ist auf GitHub, die KX-spezifischen Patches im OrcaSlicer-KX-Repo.
---
@@ -123,6 +143,13 @@ ist über die verlinkten PRs verfügbar.
- **[Home-Assistant-Integration](https://github.com/gangoke/kobrax-lan-hass-component)**
von [@gangoke](https://github.com/gangoke) — bindet Sensoren, Drucksteuerung,
Licht, Kamera und das GCode-Vorschaubild als native Home-Assistant-Entitäten ein.
- **[Obico (selbst gehostet)](https://github.com/TheSpaghettiDetective/obico-server)** —
die Bridge bietet eine Moonraker-kompatible API, die
[moonraker-obico](https://github.com/TheSpaghettiDetective/moonraker-obico)
akzeptiert; damit hast du Time-Lapse und WebRTC-Live-Stream gegen deinen
eigenen Obico-Server. Die KI-Spaghetti-Erkennung ist beim Kobra X
experimentell — der Top-Down-Kamerawinkel weicht von dem ab, auf den
das Modell trainiert wurde.
> Dies sind **Community-Projekte**, die nicht von KX-Bridge betreut oder
> supportet werden. Bei Fragen oder Problemen bitte das verlinkte Repository nutzen.

View File

@@ -8,6 +8,10 @@
Un puente compatible con Moonraker que se comunica directamente con la impresora.
<sub>🧪 Un usuario en Reddit ha reportado que el puente también funciona con la
**Kobra S1** y la **Kobra S1 Max** — los protocolos parecen compatibles, pero
ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
<sub>🇬🇧 <a href="README.md">English version</a> · 🇩🇪 <a href="README.de.md">Deutsche Version</a></sub>
<br>
@@ -32,12 +36,17 @@ Un puente compatible con Moonraker que se comunica directamente con la impresora
|---|---|
| 🖨️ | **Control de impresora** — iniciar, pausar, reanudar, cancelar, temperaturas, velocidad de impresión |
| 📊 | **Estado en tiempo real** — temperatura, progreso, capas, tiempo restante, transmisión de cámara |
| 🎨 | **AMS / multicolor** — ranuras de filamento, reasignación por canal, emulación MMU para sincronización de filamento en OrcaSlicer |
| 🎨 | **AMS / multicolor** — ranuras con **selector de perfil por ranura** (asigna tu propia marca de los perfiles de OrcaSlicer a cada ranura); el puente escribe material y color al display de la impresora |
| 📦 | **Importa tus propios perfiles de OrcaSlicer** — arrastra un ZIP de `~/.config/OrcaSlicer/user/<id>/filament/` al puente; aparecen en el desplegable de la ranura bajo ★ Perfiles propios |
| 📷 | **Integración con Obico (experimental)** — Time-Lapse y stream en vivo WebRTC contra un [servidor Obico](https://github.com/TheSpaghettiDetective/obico-server) autoalojado vía moonraker-obico |
| 📐 | **Stream H.264 directo + altura Z** — ruta de cámara de bajo consumo de CPU para Obico, Z actual derivada de la altura de capa (widget de progreso) |
| 🗂️ | **Explorador de GCode** — archivos subidos con vistas previas, historial de impresión, búsqueda y filtros |
| 🧩 | **Multi-impresora** — múltiples impresoras en **una** instancia del puente, cambia mediante un menú desplegable |
| | **Añade una impresora con un clic** — solo introduce la IP, las credenciales se importan automáticamente |
| 🔁 | **Reconexión MQTT robusta** — el puente sobrevive a reinicios nocturnos de la impresora sin reinicio manual |
| 🌐 | **Interfaz multilingüe** — DE / EN / ES / 中文, detecta automáticamente el idioma del navegador |
| 🔄 | **Actualización automática** — instala nuevas versiones directamente desde el navegador |
| 🌐 | **OrcaSlicer** — protocolo Moonraker completo (HTTP + WebSocket), interfaz EN/ES |
| 🧠 | **OrcaSlicer** — protocolo Moonraker completo (HTTP + WebSocket); usa el [build OrcaSlicer-KX](#-slicer-recomendado) para emparejamiento correcto de vendor por ranura |
---
@@ -103,16 +112,28 @@ Impresora → Tipo de conexión **Moonraker** → Host: `http://IP-DEL-PUENTE:71
## 🎨 Slicer recomendado
Para la mejor experiencia con KX-Bridge ofrecemos una **versión modificada de OrcaSlicer** que
incluye tres PRs abiertos de SoftFever/OrcaSlicer: el perfil de la impresora Anycubic Kobra
X, una corrección de GCode multicolor y — lo más importante — una corrección de sincronización de
filamento Moonraker/Happy-Hare que mantiene las posiciones de las ranuras AMS intactas incluso con una ranura vacía.
Para una sincronización de filamento AMS correcta ofrecemos una **versión modificada de OrcaSlicer**:
**[Lanzamientos de OrcaSlicer-KX](https://gitea.it-drui.de/viewit/OrcaSlicer-KX/releases/latest)** (Linux AppImage + Windows ZIP)
OrcaSlicer estándar también funciona; la versión modificada mejora principalmente el manejo de AMS.
Es una versión basada en [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0);
el código fuente está disponible a través de los PRs enlazados.
**PRs upstream incluidos en el build KX:**
- **[PR #13372](https://github.com/SoftFever/OrcaSlicer/pull/13372)** — Corrección de sincronización Moonraker / Happy-Hare AMS (las posiciones de las ranuras se mantienen correctas incluso con ranuras vacías)
- **[PR #13719](https://github.com/SoftFever/OrcaSlicer/pull/13719)** — Coincidencia de Vendor + Nombre para Moonraker (lee `name` + `vendor_name` por ranura y los empareja con los presets de filamento del usuario), por [@LordGuenni](https://github.com/LordGuenni)
- **[PR #13315](https://github.com/SoftFever/OrcaSlicer/pull/13315)** — `filament_id` único para presets de usuario (los perfiles nuevos reciben un ID nuevo en vez de heredar `OGFL99` del padre genérico), por [@mrnoisytiger](https://github.com/mrnoisytiger)
**Más cuatro mejoras específicas de KX encima:**
- Respetar el hint de filamento del puente (`tray_info_idx` + vendor)
- Coincidencia por vendor incluso cuando el preset base no es **is_compatible** con la impresora activa (así un perfil copiado de otra máquina sigue coincidiendo por vendor)
- Coincidencia por vendor cuando `tray_info_idx` está definido pero su preset es incompatible
- Búsqueda de dos pasadas: primero presets compatibles, luego todos los visibles
**Por qué importa:** sin #13719 todas las ranuras AMS caen en `Generic PLA` / `Generic PETG` aunque el puente ya envíe la marca concreta (`name + vendor_name + gate_filament_name`). Con el build KX, OrcaSlicer coincide con tus presets de usuario reales — incluyendo los perfiles que importaste al puente vía [Importa tus propios perfiles de OrcaSlicer](#-características).
OrcaSlicer upstream también funciona para rebanar e imprimir — solo pierdes la coincidencia de vendor por ranura en la sincronización AMS. El material y color por ranura se pueden empujar puente → impresora con cualquier slicer (eso va por MQTT, no por el slicer).
OrcaSlicer-KX es un build de [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0); el código fuente de cada PR upstream está en GitHub, los parches específicos de KX en el repo OrcaSlicer-KX.
---
@@ -121,6 +142,12 @@ el código fuente está disponible a través de los PRs enlazados.
- **[Integración con Home Assistant](https://github.com/gangoke/kobrax-lan-hass-component)**
por [@gangoke](https://github.com/gangoke) — expone sensores, controles de impresión,
luz, cámara y la vista previa del GCode como entidades nativas de Home Assistant.
- **[Obico (autoalojado)](https://github.com/TheSpaghettiDetective/obico-server)** —
el puente expone una API compatible con Moonraker que
[moonraker-obico](https://github.com/TheSpaghettiDetective/moonraker-obico)
acepta, así obtienes Time-Lapse y streaming en vivo WebRTC contra tu propio
servidor Obico. La detección de fallos por IA es experimental en la Kobra X
(el ángulo cenital de la cámara difiere del que el modelo fue entrenado).
> Estos son **proyectos de la comunidad**, no mantenidos ni soportados por KX-Bridge.
> Para preguntas o problemas, utiliza el repositorio enlazado.

View File

@@ -8,6 +8,10 @@
A Moonraker-compatible bridge that talks directly to the printer.
<sub>🧪 A community report on Reddit suggests the bridge also works with the
**Kobra S1** and **Kobra S1 Max** — protocols look compatible, but neither is
officially tested or supported. Feedback welcome.</sub>
<sub>🇩🇪 <a href="README.de.md">Deutsche Version</a> · 🇪🇸 <a href="README.es.md">Versión española</a></sub>
<br>
@@ -32,12 +36,17 @@ A Moonraker-compatible bridge that talks directly to the printer.
|---|---|
| 🖨️ | **Printer control** — start, pause, resume, cancel, temperatures, print speed |
| 📊 | **Live status** — temperature, progress, layers, remaining time, camera stream |
| 🎨 | **AMS / multicolor**filament slots, per-channel remapping, MMU emulation for OrcaSlicer filament sync |
| 🎨 | **AMS / multicolor**slots with per-slot **profile picker** (assign your own brand from OrcaSlicer profiles per slot); bridge writes material & colour back to the printer display |
| 📦 | **Import your own OrcaSlicer profiles** — drag a ZIP from `~/.config/OrcaSlicer/user/<id>/filament/` into the bridge; they show up in the slot dropdown under ★ Own profiles |
| 📷 | **Obico integration (experimental)** — Time-Lapse and WebRTC live stream against a self-hosted [Obico server](https://github.com/TheSpaghettiDetective/obico-server) via moonraker-obico |
| 📐 | **Direct H.264 stream + Z-height** — low-CPU camera path for Obico, current Z derived from layer-height for the print-progress widget |
| 🗂️ | **GCode browser** — uploaded files with thumbnails, print history, search & filter |
| 🧩 | **Multi-printer** — multiple printers in **one** bridge instance, switch via dropdown |
| | **Add a printer with one click** — just enter the IP, credentials are imported automatically |
| 🔁 | **Robust MQTT reconnect** — bridge survives overnight printer reboots without manual restart |
| 🌐 | **Multi-language UI** — DE / EN / ES / 中文, auto-detect browser locale |
| 🔄 | **Self-update** — install new versions directly in the browser |
| 🌐 | **OrcaSlicer** — full Moonraker protocol (HTTP + WebSocket), EN/DE UI |
| 🧠 | **OrcaSlicer** — full Moonraker protocol (HTTP + WebSocket); pair with the [OrcaSlicer-KX build](#-recommended-slicer) for proper per-slot vendor matching |
---
@@ -103,16 +112,28 @@ Printer → Connection type **Moonraker** → Host: `http://BRIDGE-IP:7125`
## 🎨 Recommended Slicer
For the best KX-Bridge experience we offer a **patched OrcaSlicer build** that
bundles three open SoftFever/OrcaSlicer PRs: the Anycubic Kobra X printer
profile, a multicolor G-code fix and — most importantly — a Moonraker/Happy-Hare
filament-sync fix that keeps AMS slot positions intact even with an empty slot.
For proper AMS filament-sync we ship a **patched OrcaSlicer build**:
**[OrcaSlicer-KX releases](https://gitea.it-drui.de/viewit/OrcaSlicer-KX/releases/latest)** (Linux AppImage + Windows ZIP)
Standard OrcaSlicer also works; the patched build mainly improves AMS handling.
It's a build of [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0);
source is available via the linked PRs.
**Upstream PRs bundled in the KX build:**
- **[PR #13372](https://github.com/SoftFever/OrcaSlicer/pull/13372)** — Moonraker / Happy-Hare AMS sync fix (slot positions stay correct even with empty slots)
- **[PR #13719](https://github.com/SoftFever/OrcaSlicer/pull/13719)** — Vendor + Name matching for Moonraker (reads `name` + `vendor_name` per slot and matches against the user's filament presets), by [@LordGuenni](https://github.com/LordGuenni)
- **[PR #13315](https://github.com/SoftFever/OrcaSlicer/pull/13315)** — Unique `filament_id` for user presets (so newly created custom profiles get a fresh ID instead of inheriting `OGFL99` from the generic parent), by [@mrnoisytiger](https://github.com/mrnoisytiger)
**Plus four KX-specific matching improvements on top:**
- Respect the bridge filament hint (`tray_info_idx` + vendor)
- Vendor match also when the chosen base preset is **not is_compatible** with the active printer (so a profile copied from a different machine still matches by vendor)
- Vendor match when `tray_info_idx` is set but its preset is incompatible
- Two-pass lookup: first compatible presets, then all visible ones
**Why this matters:** without #13719 the AMS slots in OrcaSlicer all fall back to `Generic PLA` / `Generic PETG` even though the bridge already sends the concrete brand (`name + vendor_name + gate_filament_name`). With the KX build OrcaSlicer matches your actual user presets — including profiles you imported into the bridge via the [Import your own OrcaSlicer profiles](#-features) flow.
Stock upstream OrcaSlicer still works for slicing and printing — you just lose the per-slot brand matching on AMS sync. Slot material + colour can still be pushed bridge → printer either way (that goes over MQTT, not via the slicer).
OrcaSlicer-KX is a build of [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0); source for each upstream PR is on GitHub, KX-specific patches live in the OrcaSlicer-KX repo.
---
@@ -121,6 +142,12 @@ source is available via the linked PRs.
- **[Home Assistant integration](https://github.com/gangoke/kobrax-lan-hass-component)**
by [@gangoke](https://github.com/gangoke) — exposes sensors, print controls,
light, camera and the GCode thumbnail as native Home Assistant entities.
- **[Obico (self-hosted)](https://github.com/TheSpaghettiDetective/obico-server)** —
the bridge exposes a Moonraker-compatible surface that
[moonraker-obico](https://github.com/TheSpaghettiDetective/moonraker-obico)
accepts, so you get Time-Lapse and WebRTC live streaming against your own
Obico server. The AI failure-detection side is experimental on the Kobra X
(top-down camera angle differs from what the model was trained on).
> These are **community projects**, not maintained or supported by KX-Bridge.
> For questions or issues, please use the linked repository.

View File

@@ -1 +1 @@
0.9.18
0.9.19.1

File diff suppressed because it is too large Load Diff

View File

@@ -154,6 +154,13 @@ 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

View File

@@ -1494,12 +1494,30 @@ class KobraXBridge:
self._push_status_update()
# OrcaSlicer filament preset IDs (MoonrakerPrinterAgent.cpp mapping)
# Default-Mapping pro Material-Typ wenn der User keinen Slot-Profil-
# Override gesetzt hat. Für den Kobra X bevorzugen wir Anycubic-eigene
# Filament-IDs aus den `@Anycubic Kobra X 0.4 nozzle`-Profilen — die
# sind druckerspezifisch is_compatible und werden von OrcaSlicer direkt
# gematched. Library-Fallbacks (OGF*) nur für Material-Typen ohne
# Kobra-X-spezifisches Anycubic-Profil — deren @System-Profile haben
# `compatible_printers: []` (= mit allen Druckern kompatibel).
_TRAY_INFO_IDX = {
"PLA": "OGFL99", "PLA-CF": "OGFL98", "PLA SILK": "OGFL96",
"PETG": "OGFG99", "PETG-CF": "OGFG98",
"ABS": "OGFB99", "ASA": "OGFB98",
"TPU": "OGFT99", "PA": "OGFP99", "PA-CF": "OGFP98",
"PC": "OGFC99", "HIPS": "OGFH99", "PVA": "OGFV99",
# Anycubic-eigene Kobra-X-Profile
"PLA": "GFPLA",
"PLA+": "GFPLA+",
"PLA SILK": "GFPLA Silk",
"PETG": "GFPETG",
"ABS": "GFABS",
"ASA": "GFASA",
"TPU": "GFTPU 95A",
"PVA": "GFPVA",
# Kein Anycubic-Kobra-X-Profil → Library-Fallback
"PLA-CF": "OGFL98",
"PETG-CF": "OGFG98",
"PA": "OGFN99",
"PA-CF": "OGFN98",
"PC": "OGFC99",
"HIPS": "OGFS98",
}
def _build_lane_data(self) -> dict:
@@ -1556,9 +1574,13 @@ class KobraXBridge:
fila_name = user_profile.get("name", "")
tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99")
else:
vendor = ""
fila_name = ""
tray_info_idx = self._TRAY_INFO_IDX.get(material, "OGFL99")
# Default: Library-Generic-Profil (siehe _default_filament_name) —
# ist mit allen Druckern kompatibel und garantiert sichtbar.
# Der User wählt pro Slot bewusst eine konkrete Marke wenn er
# eine will; Default bleibt neutral.
fila_name = self._default_filament_name(material)
vendor = "Generic" if fila_name.startswith("Generic ") else ""
tray_info_idx = self._lookup_filament_id(vendor, fila_name) or self._TRAY_INFO_IDX.get(material, "OGFL99")
tray_array.append({
"id": str(slot_id),
"tag_uid": "0000000000000000",
@@ -1566,17 +1588,20 @@ class KobraXBridge:
"tray_type": material,
"tray_color": color_hex,
"tray_sub_brands": vendor,
"filament_vendor": vendor,
# Für den OrcaSlicer-Empfangs-Patch (Variante 2,
# MoonrakerPrinterAgent.cpp): `filament_id` direkt
# übernehmen (exakt), sonst `preset`-Name per
# find_preset() auflösen. tray_info_idx ist im Orca-
# Datenmodell nicht eindeutig (z.B. OGFL99 für 136
# Profile), aber der Bare-Name aus orca_filaments.json
# ist eindeutig — find_preset() parsed @-Suffixe weg.
# OrcaSlicer-Empfangs-Patch PR #13719 erwartet `name` +
# `vendor_name` pro Lane (Stufen-Matching: Vendor+Name → Name →
# filament_id_by_type). Wir senden beide Schreibweisen mit
# damit ältere Patch-Varianten + zukünftige Upstream-PRs beide
# bedient sind.
"name": fila_name,
"vendor_name": vendor,
# Aliase für ältere Patch-Varianten (Variante 2,
# MoonrakerPrinterAgent.cpp): filament_id direkt (exakt),
# sonst preset-Name per find_preset() auflösen.
"filament_id": tray_info_idx,
"filament_vendor": vendor,
"filament_name": fila_name,
"preset": fila_name,
"filament_name": fila_name, # ältere Aliase
})
else:
tray_array.append({
@@ -1695,6 +1720,7 @@ class KobraXBridge:
"TPU": 220, "PA": 260, "PC": 270, "HIPS": 220}
num_gates = len(slots)
gate_status, gate_material, gate_color, gate_temperature, gate_color_rgb = [], [], [], [], []
gate_filament_name = []
for _global_index, slot in slots:
occupied = slot.get("status") == 5
gate_status.append(1 if occupied else 0)
@@ -1706,6 +1732,17 @@ class KobraXBridge:
gate_color.append("{:02X}{:02X}{:02X}".format(*c[:3]) if occupied else "")
gate_color_rgb.append([round(c[0]/255, 3), round(c[1]/255, 3), round(c[2]/255, 3)] if occupied else [0.0, 0.0, 0.0])
gate_temperature.append(_TEMP.get(material, 210) if occupied else 0)
# gate_filament_name aus User-Override oder Material-Default für den
# HH-Pfad in OrcaSlicer (fetch_hh_filament_info). Wenn Orca den
# HH-Pfad wählt (MMU-Erkennung), wertet PR #13719 dieses Feld als
# Preset-Namen aus → 'Anycubic PLA' matched das druckerspezifische
# Preset, leerer String führte vorher auf Generic PLA.
if occupied:
user_profile = self._filament_profiles.get(_global_index) or {}
fila_name = user_profile.get("name") or self._default_filament_name(material)
gate_filament_name.append(fila_name)
else:
gate_filament_name.append("")
loaded_index_map = {global_index: idx for idx, (global_index, _) in enumerate(slots)}
active_gate = loaded_index_map.get(int(self._ams_loaded_slot), -1)
@@ -1717,13 +1754,36 @@ class KobraXBridge:
"gate_color": gate_color,
"gate_temperature": gate_temperature,
"gate_color_rgb": gate_color_rgb,
"gate_filament_name": [""] * num_gates,
"gate_filament_name": gate_filament_name,
"gate_spool_id": [-1] * num_gates,
"ttg_map": list(range(num_gates)),
"tool": active_gate,
"gate": active_gate,
}
def _default_filament_name(self, material: str) -> str:
"""Default-Name für `gate_filament_name`/`name` in lane_data wenn kein
User-Override gesetzt ist. Bewusste Designentscheidung: **immer
Generic <Typ>** als Default — das Library-Profil ist `compatible_printers:[]`
(= mit jedem Drucker kompatibel) und damit garantiert sichtbar.
OrcaSlicer matcht dann das neutrale Generic-Preset und der User
kann pro Slot eine konkrete Marke setzen wenn er das will."""
if not material:
return ""
mat = material.upper().strip()
profs = self._load_orca_filaments()
def _match_type(p: dict) -> bool:
pt = (p.get("type") or "").upper()
return pt == mat or pt.startswith(mat + "-") or pt.startswith(mat + " ")
# Library-Generic-Profil (immer is_visible+is_compatible)
for p in profs:
if p.get("vendor") == "Generic" and p.get("name", "").startswith("Generic ") and _match_type(p):
return p.get("name", "")
# Falls die Library-Generic für diesen exotischen Material-Typ fehlt,
# liefern wir nichts — OrcaSlicer fällt auf filament_id_by_type zurück.
return ""
def _build_printer_objects(self) -> dict:
s = self._state
return {
@@ -1946,6 +2006,128 @@ class KobraXBridge:
profiles = [p for p in profiles if p.get("vendor", "") == vendor_filter]
return self._json_cors({"result": profiles})
async def handle_kx_filament_profiles_user_list(self, request):
"""GET /kx/filament/profiles/user — nur die User-importierten Profile,
für den Settings-Tab (Verwaltung mit Lösch-Buttons)."""
path = self._orca_filaments_user_path()
if not os.path.isfile(path):
return self._json_cors({"result": []})
try:
with open(path, encoding="utf-8") as f:
user_profiles = json.load(f) or []
except Exception:
user_profiles = []
return self._json_cors({"result": user_profiles})
async def handle_kx_filament_profiles_import(self, request):
"""POST /kx/filament/profiles/user — multipart-Upload mit einer
ZIP-Datei oder mehreren `.json`-Files aus
~/.config/OrcaSlicer/user/<id>/filament/.
Bestehende User-Profile mit gleichem (vendor, name)-Key werden
überschrieben. Geparste Profile haben dasselbe Schema wie
orca_filaments.json (id, name, vendor, type, color)."""
import io, zipfile
from orca_filaments import parse_profile_bytes
added: list[dict] = []
skipped: int = 0
# System-Index für Inherits-Resolve: User-Profile referenzieren
# System-Parents via "inherits" (z.B. "Generic PLA @System"). Damit
# können wir filament_id/vendor/type/color aus dem System-Parent
# ziehen wenn das User-Profil sie selbst nicht setzt.
sys_idx = [p for p in self._load_orca_filaments() if not p.get("is_user")]
try:
reader = await request.multipart()
except Exception:
return self._json_cors({"error": "expected multipart"}, status=400)
async for part in reader:
if part.name not in ("file", "files", "upload"):
continue
blob = await part.read()
fn = (part.filename or "").lower()
if fn.endswith(".zip"):
try:
with zipfile.ZipFile(io.BytesIO(blob)) as zf:
for inner in zf.namelist():
if not inner.lower().endswith(".json"):
continue
try:
with zf.open(inner) as zf_in:
p = parse_profile_bytes(zf_in.read(), source_name=inner, system_index=sys_idx)
except Exception:
skipped += 1
continue
if p:
added.append(p)
else:
skipped += 1
except zipfile.BadZipFile:
return self._json_cors({"error": "bad zip"}, status=400)
elif fn.endswith(".json"):
p = parse_profile_bytes(blob, source_name=fn, system_index=sys_idx)
if p:
added.append(p)
else:
skipped += 1
if not added:
return self._json_cors({"result": "ok", "added": 0, "skipped": skipped})
# Merge mit existierender User-JSON (gleicher (vendor,name) → ersetzen)
path = self._orca_filaments_user_path()
existing: list[dict] = []
if os.path.isfile(path):
try:
with open(path, encoding="utf-8") as f:
existing = json.load(f) or []
except Exception:
existing = []
by_key = {(p.get("vendor"), p.get("name")): p for p in existing}
for p in added:
by_key[(p.get("vendor"), p.get("name"))] = p
merged = sorted(by_key.values(), key=lambda x: (x.get("vendor",""), x.get("name","")))
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(merged, f, indent=2, ensure_ascii=False)
f.write("\n")
except Exception as e:
return self._json_cors({"error": f"write failed: {e}"}, status=500)
self._invalidate_filaments_cache()
return self._json_cors({"result": "ok",
"added": len(added),
"skipped": skipped,
"total_user": len(merged)})
async def handle_kx_filament_profiles_user_delete(self, request):
"""DELETE /kx/filament/profiles/user — löscht entweder einen einzelnen
Eintrag (?vendor=…&name=…) oder alle wenn keine Query angegeben."""
vendor = request.rel_url.query.get("vendor", "").strip()
name = request.rel_url.query.get("name", "").strip()
path = self._orca_filaments_user_path()
if not os.path.isfile(path):
return self._json_cors({"result": "ok", "removed": 0})
try:
with open(path, encoding="utf-8") as f:
existing = json.load(f) or []
except Exception:
existing = []
before = len(existing)
if vendor and name:
existing = [p for p in existing
if not (p.get("vendor") == vendor and p.get("name") == name)]
else:
existing = []
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(existing, f, indent=2, ensure_ascii=False)
f.write("\n")
except Exception as e:
return self._json_cors({"error": str(e)}, status=500)
self._invalidate_filaments_cache()
return self._json_cors({"result": "ok",
"removed": before - len(existing),
"total_user": len(existing)})
def _find_orca_filaments_json(self) -> str | None:
"""Findet die statische JSON-Datei. Liegt analog zu web/ unter _WEB_BASE/data/
— in allen 3 Deployment-Modi:
@@ -2022,21 +2204,45 @@ class KobraXBridge:
"id": entry.get("id", "")})
def _load_orca_filaments(self) -> list[dict]:
"""Lädt orca_filaments.json einmalig in den Cache. Bei wiederholten
Aufrufen wird die Liste aus dem RAM geliefert."""
"""Lädt System- + User-Profile aus dem Cache. System-Profile kommen
aus bridge/data/orca_filaments.json (Image-embedded), User-Profile
aus <KX_DATA_DIR>/orca_filaments.user.json (Volume-persistent —
überlebt Image-Updates). User-Profile bekommen ein `is_user: True`-
Flag damit das Frontend sie markieren kann."""
if getattr(self, "_orca_filaments_cache", None) is not None:
return self._orca_filaments_cache
self._orca_filaments_cache = []
data_path = self._find_orca_filaments_json()
if not data_path or not os.path.isfile(data_path):
return self._orca_filaments_cache
merged: list[dict] = []
# System
sys_path = self._find_orca_filaments_json()
if sys_path and os.path.isfile(sys_path):
try:
with open(data_path, encoding="utf-8") as f:
self._orca_filaments_cache = json.load(f) or []
with open(sys_path, encoding="utf-8") as f:
merged.extend(json.load(f) or [])
except Exception as e:
log.warning(f"orca_filaments.json read error: {e}")
# User
usr_path = self._orca_filaments_user_path()
if usr_path and os.path.isfile(usr_path):
try:
with open(usr_path, encoding="utf-8") as f:
for p in (json.load(f) or []):
p["is_user"] = True
merged.append(p)
except Exception as e:
log.warning(f"orca_filaments.user.json read error: {e}")
self._orca_filaments_cache = merged
return self._orca_filaments_cache
def _orca_filaments_user_path(self) -> str:
"""Pfad zur User-Profile-JSON. Liegt im Volume-Mount (KX_DATA_DIR),
damit Image-Updates die Daten nicht zerstören."""
data_dir = os.environ.get("KX_DATA_DIR") or os.path.join(_WEB_BASE, "data")
os.makedirs(data_dir, exist_ok=True)
return os.path.join(data_dir, "orca_filaments.user.json")
def _invalidate_filaments_cache(self):
self._orca_filaments_cache = None
def _lookup_filament_id(self, vendor: str, name: str) -> str:
"""Sucht in orca_filaments.json die filament_id zu einem (vendor,name)-
Tupel. Liefert '' wenn nicht gefunden."""
@@ -3085,13 +3291,10 @@ class KobraXBridge:
loop = asyncio.get_event_loop()
def _send():
return self.client.publish("multiColorBox", "setDry", payload, timeout=5)
resp = await loop.run_in_executor(None, _send)
if resp is None:
return web.json_response({"error": "No response from printer"}, status=504)
if int(resp.get("code", 200)) != 200:
return web.json_response({"error": f"Printer rejected command: {resp}"}, status=502)
return self.client.publish("multiColorBox", "setDry", payload, timeout=0)
# Fire-and-forget: setDry ACK arrives via multiColorBox/report callback.
# Waiting for a response on that busy push topic causes false "code:0" rejections.
await loop.run_in_executor(None, _send)
self._state["ace_drying"] = ui_state
self._state_dirty = True
@@ -3770,7 +3973,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()
@@ -4442,6 +4647,12 @@ 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)
# 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.
r.add_get("/kx/filament/profiles/user", bridge.handle_kx_filament_profiles_user_list)
r.add_post("/kx/filament/profiles/user", bridge.handle_kx_filament_profiles_import)
r.add_delete("/kx/filament/profiles/user", bridge.handle_kx_filament_profiles_user_delete)
r.add_get("/kx/history", bridge.handle_kx_history)
r.add_get("/kx/ui/{name:.*}", bridge.handle_kx_ui_asset)
r.add_get("/kx/files/{id}/objects", bridge.handle_kx_file_objects)

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 = []

147
orca_filaments.py Normal file
View File

@@ -0,0 +1,147 @@
"""OrcaSlicer Filament-Profil Parser.
Geteilt zwischen dem Generator (tools/gen_orca_filament_list.py) und dem
Custom-Profile-Import-Endpoint (bridge/kobrax_moonraker_bridge.py).
Liest Orca-Filament-JSON-Dateien (System- oder User-Profile) und gibt
sie als normalisierte Liste mit (id, name, vendor, type, color) zurück.
"""
from __future__ import annotations
import json
import re
def first_str(value, default: str = "") -> str:
"""Orca-Profile speichern manche Felder als ['wert']. Liefert erstes
Element als String."""
if isinstance(value, list):
return str(value[0]) if value else default
if isinstance(value, str):
return value
return default
def clean_name(raw: str) -> str:
"""Strippt printer-/varianten-spezifische Suffixe:
'PolyTerra PLA @base''PolyTerra PLA'
'Anycubic PLA @Anycubic Kobra X 0.4 nozzle''Anycubic PLA'
'Anker Generic PLA 0.4 nozzle''Anker Generic PLA'
"""
name = re.sub(r"\s*@.*$", "", raw).strip()
name = re.sub(r"\s+\d+(\.\d+)?\s*nozzle\s*$", "", name, flags=re.IGNORECASE).strip()
return name or raw
def parse_profile(data: dict, by_name: dict | None = None,
path_vendor: str | None = None,
source_path: str = "",
system_index: list | None = None) -> dict | None:
"""Parsed ein einzelnes Orca-Filament-Profil zum Bridge-Schema.
`by_name` ist optional ein {name: [profile, …]}-Index für Inherits-Resolve
aus dem rohen Source-Tree (Generator). Bei Single-File-Import (User-Datei
aus OrcaSlicer-User-Dir) reichen wir stattdessen `system_index` rein —
die fertige System-Profile-Liste aus orca_filaments.json. Damit können
wir filament_id/vendor/type/color über die `inherits`-Kette aus dem
System-Parent ableiten, auch wenn das User-Profil diese Felder nicht
selbst setzt (typisch: User-Override-Profile haben nur Tweaks).
Liefert {id, name, vendor, type, color} oder None wenn das Profil
keine filament_id hat (z.B. abstrakte @base-Templates).
"""
if not isinstance(data, dict):
return None
# User-Profile aus dem OrcaSlicer-User-Dir setzen oft KEIN "type"-Feld —
# das kommt vom System-Parent. Wir akzeptieren das wenn entweder "type"
# explizit "filament" ist ODER ein "inherits" auf ein anderes Profil zeigt.
if data.get("type") not in (None, "filament") and not data.get("inherits"):
return None
if data.get("type") == "filament" and data.get("inherits") is None and not data.get("filament_id"):
# type=filament aber kein parent + keine ID → wertloses Stub
return None
inst = data.get("instantiation", "true")
if isinstance(inst, str) and inst.lower() == "false":
return None
# Build system-name-Index für den fallback-Lookup wenn system_index gesetzt.
sys_by_name: dict[str, dict] = {}
if system_index:
for p in system_index:
if isinstance(p, dict) and p.get("name"):
sys_by_name[p["name"]] = p
def _resolve(key: str, depth: int = 5):
cur_list = [data]
for _ in range(depth):
for cur in cur_list:
v = cur.get(key)
if v not in ("", [], None, [""]) and v is not None:
return v
# Erst raw-Inherits via by_name (Generator-Pfad)
if by_name:
next_list: list[dict] = []
for cur in cur_list:
parent_name = cur.get("inherits")
if parent_name and parent_name in by_name:
next_list.extend(by_name[parent_name])
if next_list:
cur_list = next_list
continue
break
return None
def _resolve_via_system_index(key: str):
"""Inherits-Kette über system_index (clean_name-Match)."""
parent_raw = data.get("inherits")
if not parent_raw or not sys_by_name:
return None
parent_clean = clean_name(parent_raw)
sys_p = sys_by_name.get(parent_clean)
if not sys_p:
return None
# System-JSON benutzt schon das normalisierte Schema
mapping = {
"filament_id": "id",
"filament_vendor": "vendor",
"filament_type": "type",
"default_filament_colour": "color",
}
return sys_p.get(mapping.get(key, key))
def _resolve_full(key: str):
v = _resolve(key)
if v not in ("", [], None, [""]) and v is not None:
return v
return _resolve_via_system_index(key)
fid = _resolve_full("filament_id")
if not fid or not isinstance(fid, str):
return None
name_raw = data.get("name", fid)
name = clean_name(name_raw)
vendor = first_str(_resolve_full("filament_vendor")) or (path_vendor or "Generic")
ftype = first_str(_resolve_full("filament_type"), "")
color = first_str(_resolve_full("default_filament_colour"), "")
return {
"id": fid,
"name": name,
"vendor": vendor,
"type": ftype,
"color": color,
}
def parse_profile_bytes(blob: bytes, source_name: str = "",
system_index: list | None = None) -> dict | None:
"""Liest ein einzelnes Profil aus JSON-Bytes. Für File-Upload-Pfad.
`system_index` ist optional die fertige Liste aus orca_filaments.json —
wird für die Inherits-Resolve von User-Profilen genutzt die das volle
Schema vom System-Parent erben."""
try:
data = json.loads(blob.decode("utf-8", errors="replace"))
except Exception:
return None
return parse_profile(data, source_path=source_name, system_index=system_index)

View File

@@ -312,6 +312,17 @@ function applyLang(){
setText('modal-sec-print',T.settings_print);
setText('modal-sec-poll',T.settings_poll);
setText('modal-sec-version',T.settings_version);
// Custom-Profile-Import (Issue #41)
setText('modal-sec-orca-profiles',T.orca_profile_section);
setText('orca-profiles-hint',T.orca_profile_hint);
setText('lbl-orca-profiles-import',T.orca_profile_import_btn);
setText('lbl-slot-profile-import',T.orca_profile_import_link);
setText('profile-import-title',T.orca_profile_import_title);
setText('profile-import-dropmsg',T.orca_profile_dropmsg);
setText('profile-import-list-label',T.orca_profile_list_label);
// Hilfe-Text mit Inline-HTML — innerHTML statt setText
var helpEl=document.getElementById('profile-import-help');
if(helpEl && T.orca_profile_help_html) helpEl.innerHTML=T.orca_profile_help_html;
setText('btn-save-settings',T.settings_save);
setText('lbl-printer-name',T.settings_printer_name);
setText('lbl-printer-ip',T.settings_printer_ip);
@@ -903,11 +914,110 @@ function openSettings(){
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)
refreshUserProfileList();
}
function closeSettings(){
document.getElementById('settings-modal').classList.remove('open');
}
// ── Custom Filament Profile Import (Issue #41) ──
function refreshUserProfileList(){
var listEl=document.getElementById('orca-profiles-list');
if(!listEl) return;
fetch(_apiUrl('/kx/filament/profiles/user')).then(function(r){return r.json();}).then(function(d){
var profs=(d && d.result)||[];
if(!profs.length){
listEl.innerHTML='<i style="color:var(--txt2)">'+(tr('orca_profile_user_empty')||' keine ')+'</i>';
return;
}
listEl.innerHTML=profs.map(function(p){
var label=p.vendor+' / '+p.name+' ('+p.type+')';
return '<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;border-bottom:1px solid var(--border)">'
+'<span>★ '+label+'</span>'
+'<button onclick="deleteUserProfile(\''+encodeURIComponent(p.vendor)+'\',\''+encodeURIComponent(p.name)+'\')" '
+'style="background:none;border:none;color:var(--err);cursor:pointer;font-size:14px" title="löschen">🗑</button>'
+'</div>';
}).join('');
}).catch(function(){});
}
function deleteUserProfile(vendor, name){
fetch(_apiUrl('/kx/filament/profiles/user?vendor='+vendor+'&name='+name), {method:'DELETE'})
.then(function(r){return r.json();})
.then(function(){
_orcaFilamentCache=null;
refreshUserProfileList();
// Falls Import-Dialog offen ist, dort auch refreshen
refreshImportDialogList();
});
}
function openProfileImport(){
document.getElementById('profile-import-status').textContent='';
refreshImportDialogList();
document.getElementById('profile-import-modal').classList.add('open');
}
function closeProfileImport(){
document.getElementById('profile-import-modal').classList.remove('open');
}
function refreshImportDialogList(){
var el=document.getElementById('profile-import-list');
if(!el) return;
fetch(_apiUrl('/kx/filament/profiles/user')).then(function(r){return r.json();}).then(function(d){
var profs=(d && d.result)||[];
if(!profs.length){
el.innerHTML='<i style="color:var(--txt2)">'+(tr('orca_profile_user_empty')||' keine ')+'</i>';
return;
}
el.innerHTML=profs.map(function(p){
var label=p.vendor+' / '+p.name+' ('+p.type+')';
return '<div style="display:flex;justify-content:space-between;align-items:center;padding:4px 6px;border-bottom:1px solid var(--border)">'
+'<span>★ '+label+'</span>'
+'<button onclick="deleteUserProfile(\''+encodeURIComponent(p.vendor)+'\',\''+encodeURIComponent(p.name)+'\')" '
+'style="background:none;border:none;color:var(--err);cursor:pointer;font-size:14px" title="löschen">🗑</button>'
+'</div>';
}).join('');
}).catch(function(){});
}
function doProfileImportUpload(files){
if(!files || !files.length) return;
var status=document.getElementById('profile-import-status');
status.textContent=(tr('orca_profile_uploading')||'Lade hoch…');
status.style.color='var(--txt2)';
var done=0, totalAdded=0, totalSkipped=0;
function _one(idx){
if(idx>=files.length){
status.textContent=(tr('orca_profile_done')||'Importiert')+': '+totalAdded
+(totalSkipped?' / '+totalSkipped+' '+(tr('orca_profile_skipped')||'übersprungen'):'');
status.style.color='var(--ok)';
_orcaFilamentCache=null;
refreshImportDialogList();
refreshUserProfileList();
// 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')){
_fillSlotProfileDropdown(mat.value, '', '');
}
return;
}
var fd=new FormData();
fd.append('file', files[idx]);
fetch(_apiUrl('/kx/filament/profiles/user'), {method:'POST', body:fd})
.then(function(r){return r.json();})
.then(function(d){
totalAdded += (d.added||0);
totalSkipped += (d.skipped||0);
done++;
_one(idx+1);
})
.catch(function(e){
status.textContent='Fehler: '+e;
status.style.color='var(--err)';
});
}
_one(0);
}
// ── AMS Slot Edit ──
var _slotEditIndex=-1;
var _slotEditLoaded=false;
@@ -949,21 +1059,31 @@ function _fillSlotProfileDropdown(material, currentVendor, currentName){
return matU==='' || pt===matU || pt.startsWith(matU+'-') || pt.startsWith(matU+' ');
});
sel.innerHTML='<option value="">'+tr('slot_edit_profile_default')+'</option>';
// Gruppieren nach Vendor
var byVendor={};
matched.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){
// User-Profile (is_user) zuerst — eigene Optgroup '★ Eigene' an erster Stelle.
var userProfs=matched.filter(function(p){return p.is_user;});
var systemProfs=matched.filter(function(p){return !p.is_user;});
function _appendOption(g, p){
var o=document.createElement('option');
o.value=_profileKey(p.vendor, p.name);
o.dataset.vendor=p.vendor;
o.dataset.name=p.name;
o.dataset.id=p.id || '';
o.textContent=p.name;
o.textContent=(p.is_user?'★ ':'')+p.name;
if(o.value===wantKey) o.selected=true;
g.appendChild(o);
});
}
if(userProfs.length){
var gUser=document.createElement('optgroup');
gUser.label='★ '+(tr('orca_profile_user_label')||'Eigene Profile');
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);
});
});
@@ -1112,6 +1232,10 @@ function saveSlotEdit(){
closeSlotEdit();
var profSuffix=newProfName?(' ['+newProfVendor+' '+newProfName+']'):'';
clog(tr('slot_edit_ok')+' '+(slotIdx+1)+': '+mat+' '+hex+profSuffix,'msg-ok');
// Sofortiges Re-Render mit aktuellem _slotProfileMap (poll() ist async
// und re-rendert beim nächsten Tick — wir wollen aber dass die Vendor-
// Badge JETZT direkt sichtbar wird).
if(typeof applyState==='function') applyState();
if(typeof poll==='function') poll();
})
.catch(function(e){clog('Fehler: '+e,'msg-err');});

View File

@@ -121,6 +121,19 @@
</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">
@@ -170,12 +183,42 @@
<option value="" id="slot-profile-default-opt"></option>
</select>
<div style="font-size:11px;color:var(--txt2);margin-top:4px" id="slot-profile-hint"></div>
<a href="#" onclick="event.preventDefault();openProfileImport()"
style="display:inline-block;margin-top:6px;font-size:11px;color:var(--accent);text-decoration:none"
id="lbl-slot-profile-import">★ Eigene Profile importieren…</a>
</div>
<button class="btn" id="btn-slot-edit-feed" style="width:100%;margin-bottom:8px" onclick="slotEditFeed()"></button>
<button class="modal-save" id="btn-slot-edit-save" onclick="saveSlotEdit()"></button>
</div>
</div>
<!-- ═══ ORCA-PROFILE-IMPORT-DIALOG (Issue #41) ═══ -->
<div class="modal-overlay" id="profile-import-modal" onclick="if(event.target===this)closeProfileImport()">
<div class="modal-box" style="max-width:480px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div style="font-size:16px;font-weight:600" id="profile-import-title">Eigene OrcaSlicer-Profile importieren</div>
<button onclick="closeProfileImport()" style="background:none;border:none;color:var(--txt2);font-size:20px;cursor:pointer">×</button>
</div>
<div style="font-size:12px;color:var(--txt2);margin-bottom:12px;line-height:1.5" id="profile-import-help">
Lade ein <b>ZIP</b> deines OrcaSlicer-Filament-Ordners oder einzelne <b>.json</b>-Files hoch.<br>
In OrcaSlicer: <i>Help → Show Configuration Folder → user/&lt;id&gt;/filament/</i>
</div>
<div id="profile-import-drop" style="border:2px dashed var(--border);border-radius:8px;padding:24px;text-align:center;cursor:pointer;margin-bottom:12px"
ondragover="event.preventDefault();this.style.borderColor='var(--accent)'"
ondragleave="this.style.borderColor='var(--border)'"
ondrop="event.preventDefault();this.style.borderColor='var(--border)';doProfileImportUpload(event.dataTransfer.files)"
onclick="document.getElementById('profile-import-file').click()">
<div style="font-size:32px;margin-bottom:8px"></div>
<div style="font-size:13px;color:var(--txt2)" id="profile-import-dropmsg">Hierher ziehen oder klicken</div>
<input type="file" id="profile-import-file" accept=".zip,.json" multiple
style="display:none" onchange="doProfileImportUpload(this.files);this.value=''">
</div>
<div id="profile-import-status" style="font-size:12px;margin-bottom:12px;min-height:18px"></div>
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="profile-import-list-label">Aktuell importiert</div>
<div id="profile-import-list" style="max-height:240px;overflow-y:auto;font-size:12px"></div>
</div>
</div>
<div class="layout">
<nav class="sidebar">
<button class="nav-btn active" onclick="showPanel('dashboard')" id="nb-dashboard">

View File

@@ -164,6 +164,19 @@
"slot_edit_profile": "OrcaSlicer-Profil",
"slot_edit_profile_hint": "Sendet beim OrcaSlicer-Sync die konkrete Marke statt nur „Generic\"",
"slot_edit_profile_default": "— Generic (Default) —",
"orca_profile_section": "OrcaSlicer-Profile",
"orca_profile_hint": "Eigene Profile aus OrcaSlicer importieren (User-Dir öffnen via Help → Show Configuration Folder)",
"orca_profile_import_btn": "Profile importieren",
"orca_profile_import_link": "★ Eigene Profile importieren…",
"orca_profile_import_title": "Eigene OrcaSlicer-Profile importieren",
"orca_profile_help_html": "Lade ein <b>ZIP</b> deines OrcaSlicer-Filament-Ordners oder einzelne <b>.json</b>-Files hoch.<br>In OrcaSlicer: <i>Help → Show Configuration Folder → user/&lt;id&gt;/filament/</i>",
"orca_profile_dropmsg": "Hierher ziehen oder klicken",
"orca_profile_list_label": "Aktuell importiert",
"orca_profile_user_label": "Eigene Profile",
"orca_profile_user_empty": " keine ",
"orca_profile_uploading": "Lade hoch…",
"orca_profile_done": "Importiert",
"orca_profile_skipped": "übersprungen",
"log_dir_all": "Alle",
"log_lvl_label": "Level:",
"file_ready_btn": "▶ Druck starten",

View File

@@ -164,6 +164,19 @@
"slot_edit_profile": "OrcaSlicer profile",
"slot_edit_profile_hint": "Sent on OrcaSlicer sync as the specific brand instead of just \"Generic\"",
"slot_edit_profile_default": "— Generic (default) —",
"orca_profile_section": "OrcaSlicer Profiles",
"orca_profile_hint": "Import your own OrcaSlicer filament profiles (open the user dir via Help → Show Configuration Folder)",
"orca_profile_import_btn": "Import profiles",
"orca_profile_import_link": "★ Import own profiles…",
"orca_profile_import_title": "Import your OrcaSlicer profiles",
"orca_profile_help_html": "Upload a <b>ZIP</b> of your OrcaSlicer filament folder or single <b>.json</b> files.<br>In OrcaSlicer: <i>Help → Show Configuration Folder → user/&lt;id&gt;/filament/</i>",
"orca_profile_dropmsg": "Drop here or click",
"orca_profile_list_label": "Currently imported",
"orca_profile_user_label": "Own profiles",
"orca_profile_user_empty": " none ",
"orca_profile_uploading": "Uploading…",
"orca_profile_done": "Imported",
"orca_profile_skipped": "skipped",
"log_dir_all": "All",
"log_lvl_label": "Level:",
"file_ready_btn": "▶ Start Print",

View File

@@ -164,6 +164,19 @@
"slot_edit_profile": "Perfil de OrcaSlicer",
"slot_edit_profile_hint": "Envía al sincronizar con OrcaSlicer la marca concreta en lugar de solo \"Generic\"",
"slot_edit_profile_default": "— Genérico (Predeterminado) —",
"orca_profile_section": "Perfiles de OrcaSlicer",
"orca_profile_hint": "Importa tus propios perfiles de filamento de OrcaSlicer (abre el directorio del usuario vía Help → Show Configuration Folder)",
"orca_profile_import_btn": "Importar perfiles",
"orca_profile_import_link": "★ Importar perfiles propios…",
"orca_profile_import_title": "Importar tus perfiles de OrcaSlicer",
"orca_profile_help_html": "Sube un <b>ZIP</b> de tu carpeta de filamentos de OrcaSlicer o archivos <b>.json</b> sueltos.<br>En OrcaSlicer: <i>Help → Show Configuration Folder → user/&lt;id&gt;/filament/</i>",
"orca_profile_dropmsg": "Suelta aquí o haz clic",
"orca_profile_list_label": "Actualmente importados",
"orca_profile_user_label": "Perfiles propios",
"orca_profile_user_empty": " ninguno ",
"orca_profile_uploading": "Subiendo…",
"orca_profile_done": "Importado",
"orca_profile_skipped": "omitido",
"log_dir_all": "Todos",
"log_lvl_label": "Nivel:",
"file_ready_btn": "▶ Iniciar impresión",

View File

@@ -164,6 +164,19 @@
"slot_edit_profile": "OrcaSlicer 配置",
"slot_edit_profile_hint": "在 OrcaSlicer 同步时发送具体品牌而不仅仅是“Generic”",
"slot_edit_profile_default": "— 通用 (默认) —",
"orca_profile_section": "OrcaSlicer 配置",
"orca_profile_hint": "导入你自己的 OrcaSlicer 耗材配置(在 Help → Show Configuration Folder 打开用户目录)",
"orca_profile_import_btn": "导入配置",
"orca_profile_import_link": "★ 导入自己的配置…",
"orca_profile_import_title": "导入你的 OrcaSlicer 配置",
"orca_profile_help_html": "上传 OrcaSlicer 耗材文件夹的 <b>ZIP</b> 或单个 <b>.json</b> 文件。<br>在 OrcaSlicer 中: <i>Help → Show Configuration Folder → user/&lt;id&gt;/filament/</i>",
"orca_profile_dropmsg": "拖到此处或点击",
"orca_profile_list_label": "已导入",
"orca_profile_user_label": "自己的配置",
"orca_profile_user_empty": "",
"orca_profile_uploading": "上传中…",
"orca_profile_done": "已导入",
"orca_profile_skipped": "跳过",
"log_dir_all": "全部",
"log_lvl_label": "级别:",
"file_ready_btn": "▶ 开始打印",