Compare commits

..

3 Commits

Author SHA1 Message Date
Phil Merricks
b39577ad4d feat: 2-column dashboard layout toggle + local timelapse
2-column layout: settings toggle switches dashboard between 1-col
(current behaviour) and 2-col (Camera|Progress, Temps|AMS side by
side, motion cards in 2×2). Preference stored in localStorage only
(per-browser, not printer config). Mobile always stays 1-col via CSS.

Local timelapse: new TimelapseSpool class captures JPEG frames from
the camera stream during a print at a configurable interval (default
4s) and encodes them to MP4 with ffmpeg on print end. Stored in
data/timelapses/, indexed in SQLite. New /kx/timelapses API + browser
tab in the file store panel with download and delete. Also wires the
undocumented printer-native timelapse MQTT field as an experimental
opt-in setting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:20:35 +01:00
Phil Merricks
cfe46b4cad feat: add vibration_compensation and flow_calibration print settings
Expose resonance compensation and flow calibration as configurable
toggles in the settings UI, config.ini [print] section, and CLI args.
Both default to 0 (off). Previously hardcoded to 0 in all three
print-start MQTT payload paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:21:29 +01:00
Phil Merricks
250e89f18a 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-02 12:05:11 +01:00
20 changed files with 6291 additions and 1020 deletions

View File

@@ -1,84 +1,5 @@
# 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
@@ -90,8 +11,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 Drucker muss im Idle-Zustand
sein, und leere Slots lassen sich nicht beschriften.
Workbench-Vue-Source). **Achtung:** der aktiv geladene Slot kann
während des Drucks nicht umgeschrieben werden — vorher ausziehen.
- **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,84 +1,5 @@
# 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
@@ -89,8 +10,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 printer must be idle, and
empty slots can not be labelled.
Workbench-Vue source). **Note:** the currently loaded slot can not
be overwritten during a print — unload it first.
- **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,7 +13,6 @@ 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,11 +8,6 @@
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>
@@ -37,17 +32,12 @@ Feedback willkommen.</sub>
|---|---|
| 🖨️ | **Druckersteuerung** — Start, Pause, Resume, Abbruch, Temperaturen, Druckgeschwindigkeit |
| 📊 | **Live-Status** — Temperatur, Fortschritt, Layer, Restzeit, Kamera-Stream |
| 🎨 | **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) |
| 🎨 | **AMS / Multicolor**Filament-Slots, Per-Kanal-Remapping, MMU-Emulation für OrcaSlicer Filament-Sync |
| 🗂️ | **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); für korrekten Vendor-Match pro Slot den [OrcaSlicer-KX-Build](#-empfohlener-slicer) nutzen |
| 🌐 | **OrcaSlicer** — volles Moonraker-Protokoll (HTTP + WebSocket), DE/EN UI |
---
@@ -113,28 +103,18 @@ Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125`
## 🎨 Empfohlener Slicer
Für sauberen AMS-Filament-Sync gibt es einen **gepatchten OrcaSlicer-Build**:
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.
**[OrcaSlicer-KX Releases](https://gitea.it-drui.de/viewit/OrcaSlicer-KX/releases/latest)** (Linux AppImage + Windows ZIP)
**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.
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.
---
@@ -143,13 +123,6 @@ OrcaSlicer-KX ist ein Build von [OrcaSlicer](https://github.com/SoftFever/OrcaSl
- **[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,10 +8,6 @@
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>
@@ -36,17 +32,12 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
|---|---|
| 🖨️ | **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 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) |
| 🎨 | **AMS / multicolor** — ranuras de filamento, reasignación por canal, emulación MMU para sincronización de filamento en OrcaSlicer |
| 🗂️ | **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); usa el [build OrcaSlicer-KX](#-slicer-recomendado) para emparejamiento correcto de vendor por ranura |
| 🌐 | **OrcaSlicer** — protocolo Moonraker completo (HTTP + WebSocket), interfaz EN/ES |
---
@@ -112,28 +103,16 @@ Impresora → Tipo de conexión **Moonraker** → Host: `http://IP-DEL-PUENTE:71
## 🎨 Slicer recomendado
Para una sincronización de filamento AMS correcta ofrecemos una **versión modificada de OrcaSlicer**:
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.
**[Lanzamientos de OrcaSlicer-KX](https://gitea.it-drui.de/viewit/OrcaSlicer-KX/releases/latest)** (Linux AppImage + Windows ZIP)
**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.
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.
---
@@ -142,12 +121,6 @@ OrcaSlicer-KX es un build de [OrcaSlicer](https://github.com/SoftFever/OrcaSlice
- **[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,10 +8,6 @@
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>
@@ -36,17 +32,12 @@ officially tested or supported. Feedback welcome.</sub>
|---|---|
| 🖨️ | **Printer control** — start, pause, resume, cancel, temperatures, print speed |
| 📊 | **Live status** — temperature, progress, layers, remaining time, camera stream |
| 🎨 | **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 |
| 🎨 | **AMS / multicolor**filament slots, per-channel remapping, MMU emulation for OrcaSlicer filament sync |
| 🗂️ | **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); pair with the [OrcaSlicer-KX build](#-recommended-slicer) for proper per-slot vendor matching |
| 🌐 | **OrcaSlicer** — full Moonraker protocol (HTTP + WebSocket), EN/DE UI |
---
@@ -112,28 +103,16 @@ Printer → Connection type **Moonraker** → Host: `http://BRIDGE-IP:7125`
## 🎨 Recommended Slicer
For proper AMS filament-sync we ship a **patched OrcaSlicer build**:
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.
**[OrcaSlicer-KX releases](https://gitea.it-drui.de/viewit/OrcaSlicer-KX/releases/latest)** (Linux AppImage + Windows ZIP)
**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.
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.
---
@@ -142,12 +121,6 @@ OrcaSlicer-KX is a build of [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer
- **[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.19.1
0.9.18

View File

@@ -57,10 +57,15 @@ def _load_config_file(path: pathlib.Path):
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
"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"),
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
"FLOW_CALIBRATION": (CONFIG_SECTION_PRINT, "flow_calibration"),
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
"TIMELAPSE_LOCAL": (CONFIG_SECTION_PRINT, "timelapse_local"),
"TIMELAPSE_INTERVAL_SEC": (CONFIG_SECTION_PRINT, "timelapse_interval_sec"),
"TIMELAPSE_PRINTER": (CONFIG_SECTION_PRINT, "timelapse_printer"),
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
}
for env_key, (section, option) in mapping.items():
@@ -95,10 +100,15 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
"device_id": env_vals.get("DEVICE_ID", ""),
}
cfg[CONFIG_SECTION_PRINT] = {
"default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"),
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
"web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"),
"default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"),
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
"vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"),
"flow_calibration": env_vals.get("FLOW_CALIBRATION", "0"),
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
"web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"),
"timelapse_local": env_vals.get("TIMELAPSE_LOCAL", "0"),
"timelapse_interval_sec": env_vals.get("TIMELAPSE_INTERVAL_SEC", "4"),
"timelapse_printer": env_vals.get("TIMELAPSE_PRINTER", "0"),
}
cfg[CONFIG_SECTION_BRIDGE] = {
"poll_interval": "3",
@@ -256,6 +266,11 @@ PASSWORD = get("MQTT_PASSWORD", "")
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"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
FLOW_CALIBRATION = int(get("FLOW_CALIBRATION", "0"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
TIMELAPSE_LOCAL = int(get("TIMELAPSE_LOCAL", "0"))
TIMELAPSE_INTERVAL_SEC = int(get("TIMELAPSE_INTERVAL_SEC", "4"))
TIMELAPSE_PRINTER = int(get("TIMELAPSE_PRINTER", "0"))

File diff suppressed because it is too large Load Diff

View File

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

@@ -407,6 +407,18 @@ class GCodeStore:
filament_assignments TEXT,
abort_reason TEXT
);
CREATE TABLE IF NOT EXISTS timelapses (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
printer_id TEXT NOT NULL,
created_at TEXT NOT NULL,
filename TEXT NOT NULL,
path TEXT NOT NULL,
frame_count INTEGER DEFAULT 0,
fps INTEGER DEFAULT 24,
status TEXT NOT NULL,
duration_sec INTEGER DEFAULT 0
);
""")
# Migration: Spalte gcode_filaments nachrüsten falls DB älter
try:
@@ -570,6 +582,56 @@ class GCodeStore:
).fetchall()
return [dict(r) for r in rows]
def create_timelapse(self, job_id: str, printer_id: str, path: str, fps: int = 24) -> str:
tid = str(uuid.uuid4())
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
filename = os.path.basename(path)
with self._lock:
self._conn.execute(
"""INSERT INTO timelapses (id, job_id, printer_id, created_at, filename, path, fps, status, frame_count, duration_sec)
VALUES (?,?,?,?,?,?,?,'recording',0,0)""",
(tid, job_id, printer_id, now, filename, path, fps),
)
self._conn.commit()
return tid
def update_timelapse(self, tid: str, status: str, frame_count: int = 0, duration_sec: int = 0):
with self._lock:
self._conn.execute(
"UPDATE timelapses SET status=?, frame_count=?, duration_sec=? WHERE id=?",
(status, frame_count, duration_sec, tid),
)
self._conn.commit()
def list_timelapses(self) -> list:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM timelapses ORDER BY created_at DESC"
).fetchall()
return [dict(r) for r in rows]
def get_timelapse(self, tid: str) -> "dict | None":
with self._lock:
row = self._conn.execute(
"SELECT * FROM timelapses WHERE id=?", (tid,)
).fetchone()
return dict(row) if row else None
def delete_timelapse(self, tid: str) -> bool:
rec = self.get_timelapse(tid)
if not rec:
return False
path = rec.get("path", "")
if path and os.path.isfile(path):
try:
os.remove(path)
except Exception:
pass
with self._lock:
self._conn.execute("DELETE FROM timelapses WHERE id=?", (tid,))
self._conn.commit()
return True
class CameraCache:
"""Zentraler Kamera-Demuxer.
@@ -732,6 +794,96 @@ class CameraCache:
await asyncio.sleep(2.0)
class TimelapseSpool:
"""Erfasst JPEG-Frames von CameraCache während eines Drucks und kodiert sie
nach Druckende zu einer MP4-Datei mit ffmpeg."""
def __init__(self, camera: CameraCache, timelapse_dir: str):
self._camera = camera
self._dir = timelapse_dir
self._task: "asyncio.Task | None" = None
self._frame_dir: str = ""
self._frame_count: int = 0
self._last_ts: float = 0.0
async def start(self, job_id: str, interval_sec: float = 4.0):
safe_id = job_id.replace("/", "_").replace("\\", "_")
self._frame_dir = os.path.join(self._dir, safe_id, "frames")
os.makedirs(self._frame_dir, exist_ok=True)
self._frame_count = 0
self._last_ts = 0.0
self._task = asyncio.create_task(self._capture_loop(interval_sec))
log.info(f"Timelapse-Aufnahme gestartet: {self._frame_dir} @ {interval_sec}s Intervall")
async def stop(self, output_fps: int = 24) -> "str | None":
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
if self._frame_count == 0:
log.warning("Timelapse: keine Frames aufgenommen")
return None
job_dir = os.path.dirname(self._frame_dir)
output_path = job_dir.rstrip("/\\") + ".mp4"
ok = await self._encode(output_path, output_fps)
import shutil
try:
shutil.rmtree(job_dir)
except Exception as e:
log.warning(f"Timelapse: Frames-Verzeichnis löschen fehlgeschlagen: {e}")
return output_path if ok else None
async def _capture_loop(self, interval_sec: float):
while True:
try:
ts = self._camera.latest_jpeg_ts
frame = self._camera.latest_jpeg
if ts > self._last_ts and frame:
path = os.path.join(self._frame_dir, f"frame_{self._frame_count:05d}.jpg")
with open(path, "wb") as f:
f.write(frame)
self._frame_count += 1
self._last_ts = ts
except Exception as e:
log.warning(f"Timelapse capture-Fehler: {e}")
await asyncio.sleep(interval_sec)
async def _encode(self, output_path: str, fps: int) -> bool:
try:
ffmpeg = _find_ffmpeg()
pattern = os.path.join(self._frame_dir, "frame_%05d.jpg")
cmd = [
ffmpeg, "-y", "-loglevel", "error",
"-framerate", str(fps),
"-i", pattern,
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-movflags", "+faststart",
output_path,
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
log.error(f"Timelapse encode fehlgeschlagen (rc={proc.returncode}): "
f"{stderr.decode(errors='replace')[:300]}")
return False
log.info(f"Timelapse kodiert: {output_path} ({self._frame_count} Frames @ {fps}fps)")
return True
except Exception as e:
log.error(f"Timelapse encode Ausnahme: {e}")
return False
class KobraXBridge:
def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None):
self.client = client
@@ -796,9 +948,15 @@ class KobraXBridge:
self._last_uploaded_file: str = ""
self._store = store if store is not None else GCodeStore(args.data_dir)
self._serve_dir_path: str = self._store._gcode_dir
self._timelapse_dir: str = os.path.join(args.data_dir, "timelapses")
os.makedirs(self._timelapse_dir, exist_ok=True)
self._current_job_id: str = ""
self._camera_autostarted: bool = False
self._timelapse_spool: "TimelapseSpool | None" = None
self._current_timelapse_id: str = ""
self._timelapse_autostarted: bool = False
self.camera_cache: CameraCache = CameraCache()
self._loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
self._thumbnail_b64: str = ""
self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config()
@@ -924,6 +1082,18 @@ class KobraXBridge:
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False
# Timelapse: Druckstart
if kobra_state == "printing" and not self._timelapse_autostarted:
if getattr(self._args, "timelapse_local", 0):
self._timelapse_autostarted = True
asyncio.run_coroutine_threadsafe(self._start_timelapse(), self._loop)
elif kobra_state in ("finished", "stoped", "canceled"):
self._timelapse_autostarted = False
if self._timelapse_spool:
asyncio.run_coroutine_threadsafe(
self._stop_timelapse(success=(kobra_state == "finished")), self._loop
)
# Job-History: Druckstart erkennen
if kobra_state == "printing" and not self._current_job_id:
filename = d.get("filename", self._state.get("filename", ""))
@@ -1010,6 +1180,13 @@ class KobraXBridge:
log.warning(f"Kamera-Autostart fehlgeschlagen: {e}")
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False
# Timelapse-Autostart auch hier (Guard verhindert Doppel-Start mit _on_print).
if kobra_state == "printing" and not self._timelapse_autostarted:
if getattr(self._args, "timelapse_local", 0):
self._timelapse_autostarted = True
asyncio.run_coroutine_threadsafe(self._start_timelapse(), self._loop)
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._timelapse_autostarted = False
if project:
if "filename" in project:
self._state["filename"] = project["filename"]
@@ -1494,30 +1671,12 @@ 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 = {
# 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",
"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",
}
def _build_lane_data(self) -> dict:
@@ -1574,13 +1733,9 @@ class KobraXBridge:
fila_name = user_profile.get("name", "")
tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99")
else:
# 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")
vendor = ""
fila_name = ""
tray_info_idx = self._TRAY_INFO_IDX.get(material, "OGFL99")
tray_array.append({
"id": str(slot_id),
"tag_uid": "0000000000000000",
@@ -1588,20 +1743,17 @@ class KobraXBridge:
"tray_type": material,
"tray_color": color_hex,
"tray_sub_brands": vendor,
# 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,
# 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.
"filament_id": tray_info_idx,
"preset": fila_name,
"filament_name": fila_name, # ältere Aliase
})
else:
tray_array.append({
@@ -1720,7 +1872,6 @@ 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)
@@ -1732,17 +1883,6 @@ 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)
@@ -1754,36 +1894,13 @@ class KobraXBridge:
"gate_color": gate_color,
"gate_temperature": gate_temperature,
"gate_color_rgb": gate_color_rgb,
"gate_filament_name": gate_filament_name,
"gate_filament_name": [""] * num_gates,
"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 {
@@ -2006,128 +2123,6 @@ 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:
@@ -2204,45 +2199,21 @@ class KobraXBridge:
"id": entry.get("id", "")})
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
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."""
"""Lädt orca_filaments.json einmalig in den Cache. Bei wiederholten
Aufrufen wird die Liste aus dem RAM geliefert."""
if getattr(self, "_orca_filaments_cache", None) is not None:
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(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
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
try:
with open(data_path, encoding="utf-8") as f:
self._orca_filaments_cache = json.load(f) or []
except Exception as e:
log.warning(f"orca_filaments.json read error: {e}")
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."""
@@ -2403,7 +2374,9 @@ 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 = getattr(self._args, "auto_leveling", 1)
vibration_compensation = getattr(self._args, "vibration_compensation", 0)
flow_calibration = getattr(self._args, "flow_calibration", 0)
filename = gcode_file["filename"]
file_path = gcode_file["path"]
@@ -2425,11 +2398,11 @@ class KobraXBridge:
},
"task_settings": {
"auto_leveling": auto_leveling,
"vibration_compensation": 0,
"flow_calibration": 0,
"vibration_compensation": vibration_compensation,
"flow_calibration": flow_calibration,
"dry_mode": 0,
"ai_settings": {"status": 0, "count": 0, "type": 1},
"timelapse": {"status": 0, "count": 0, "type": 64},
"timelapse": {"status": getattr(self._args, "timelapse_printer", 0), "count": 0, "type": 64},
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
"model_objects_skip_parts": excluded_objects,
},
@@ -2825,7 +2798,9 @@ class KobraXBridge:
use_ams = len(loaded) > 0
ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded)
log.debug(f"AMS-Slots: {len(loaded)} gemappt (genutzte Paints: {used_paint_indices}) → {[i for i, _ in loaded]}")
auto_leveling = getattr(self._args, "auto_leveling", 1)
auto_leveling = getattr(self._args, "auto_leveling", 1)
vibration_compensation = getattr(self._args, "vibration_compensation", 0)
flow_calibration = getattr(self._args, "flow_calibration", 0)
payload = {
"taskid": "-1",
"url": url,
@@ -2841,11 +2816,11 @@ class KobraXBridge:
},
"task_settings": {
"auto_leveling": auto_leveling,
"vibration_compensation": 0,
"flow_calibration": 0,
"vibration_compensation": vibration_compensation,
"flow_calibration": flow_calibration,
"dry_mode": 0,
"ai_settings": {"status": 0, "count": 0, "type": 1},
"timelapse": {"status": 0, "count": 0, "type": 64},
"timelapse": {"status": getattr(self._args, "timelapse_printer", 0), "count": 0, "type": 64},
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
"model_objects_skip_parts": [],
},
@@ -2913,7 +2888,9 @@ 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 = getattr(self._args, "auto_leveling", 1)
vibration_compensation = getattr(self._args, "vibration_compensation", 0)
flow_calibration = getattr(self._args, "flow_calibration", 0)
url = self._state.get("last_upload_url", "")
filesize = self._state.get("last_upload_size", 0)
md5 = self._state.get("last_upload_md5", "")
@@ -2933,11 +2910,11 @@ class KobraXBridge:
},
"task_settings": {
"auto_leveling": auto_leveling,
"vibration_compensation": 0,
"flow_calibration": 0,
"vibration_compensation": vibration_compensation,
"flow_calibration": flow_calibration,
"dry_mode": 0,
"ai_settings": {"status": 0, "count": 0, "type": 0},
"timelapse": {"status": 0, "count": 0, "type": 0},
"timelapse": {"status": getattr(self._args, "timelapse_printer", 0), "count": 0, "type": 0},
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
"model_objects_skip_parts": excluded_objects,
},
@@ -3709,6 +3686,97 @@ class KobraXBridge:
self._ams_loaded_slot = global_loaded
return self._ams_slots
# ─── Timelapse ───────────────────────────────────────────────────────────
async def _start_timelapse(self):
camera_url = self._state.get("camera_url", "")
if not camera_url:
log.info("Timelapse: kein Kamera-URL bekannt — übersprungen")
return
await self.camera_cache.ensure_running()
interval = float(getattr(self._args, "timelapse_interval_sec", 4))
spool = TimelapseSpool(self.camera_cache, self._timelapse_dir)
job_id = self._current_job_id or str(uuid.uuid4())
safe_id = job_id.replace("/", "_").replace("\\", "_")
mp4_path = os.path.join(self._timelapse_dir, safe_id + ".mp4")
tid = self._store.create_timelapse(
job_id=job_id,
printer_id=self._printer_id,
path=mp4_path,
fps=24,
)
self._timelapse_spool = spool
self._current_timelapse_id = tid
await spool.start(job_id, interval_sec=interval)
log.info(f"Timelapse ID={tid} für Job {job_id} gestartet")
async def _stop_timelapse(self, success: bool = True):
spool = self._timelapse_spool
if not spool:
return
self._timelapse_spool = None
tid = self._current_timelapse_id
self._current_timelapse_id = ""
frame_count = spool._frame_count
self._store.update_timelapse(tid, "processing", frame_count=frame_count)
mp4_path = await spool.stop(output_fps=24)
if mp4_path:
interval = float(getattr(self._args, "timelapse_interval_sec", 4))
duration_sec = int(frame_count * interval)
self._store.update_timelapse(tid, "completed", frame_count=frame_count,
duration_sec=duration_sec)
log.info(f"Timelapse {tid} abgeschlossen: {mp4_path}")
else:
self._store.update_timelapse(tid, "failed", frame_count=frame_count)
log.warning(f"Timelapse {tid} fehlgeschlagen")
async def handle_kx_timelapses(self, request):
rows = self._store.list_timelapses()
# Laufende Aufnahme in der DB-Ansicht sichtbar machen
if self._current_timelapse_id:
spool = self._timelapse_spool
for r in rows:
if r["id"] == self._current_timelapse_id and spool:
r["frame_count"] = spool._frame_count
return self._json_cors(rows)
async def handle_kx_timelapse_download(self, request):
tid = request.match_info["tid"]
rec = self._store.get_timelapse(tid)
if not rec or not os.path.isfile(rec.get("path", "")):
return web.Response(status=404)
filename = rec.get("filename") or os.path.basename(rec["path"])
return web.FileResponse(
rec["path"],
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
async def handle_kx_timelapse_thumb(self, request):
tid = request.match_info["tid"]
rec = self._store.get_timelapse(tid)
if not rec:
return web.Response(status=404)
job_id = rec.get("job_id", "")
safe_id = job_id.replace("/", "_").replace("\\", "_")
frame_dir = os.path.join(self._timelapse_dir, safe_id, "frames")
try:
if os.path.isdir(frame_dir):
frames = sorted(f for f in os.listdir(frame_dir) if f.endswith(".jpg"))
if frames:
with open(os.path.join(frame_dir, frames[0]), "rb") as f:
data = f.read()
return web.Response(body=data, content_type="image/jpeg")
except Exception:
pass
return web.Response(status=404)
async def handle_kx_timelapse_delete(self, request):
tid = request.match_info["tid"]
ok = self._store.delete_timelapse(tid)
if not ok:
return self._json_cors({"error": "not found"}, status=404)
return self._json_cors({"status": "deleted"})
# ─── Settings ────────────────────────────────────────────────────────────
def _find_config_path(self) -> pathlib.Path:
@@ -3732,10 +3800,15 @@ class KobraXBridge:
"password": self._args.password,
"mode_id": self._args.mode_id,
"device_id": self._args.device_id,
"default_ams_slot": getattr(self._args, "default_ams_slot", "auto"),
"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),
"default_ams_slot": getattr(self._args, "default_ams_slot", "auto"),
"auto_leveling": getattr(self._args, "auto_leveling", 1),
"vibration_compensation": getattr(self._args, "vibration_compensation", 0),
"flow_calibration": getattr(self._args, "flow_calibration", 0),
"camera_on_print": getattr(self._args, "camera_on_print", 0),
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
"timelapse_local": getattr(self._args, "timelapse_local", 0),
"timelapse_interval_sec": getattr(self._args, "timelapse_interval_sec", 4),
"timelapse_printer": getattr(self._args, "timelapse_printer", 0),
"ace_dry_presets": self._ace_dry_presets,
})
@@ -3762,10 +3835,15 @@ class KobraXBridge:
cfg.set("connection", "password", str(data.get("password", self._args.password or "")))
cfg.set("connection", "mode_id", str(data.get("mode_id", self._args.mode_id or "")))
cfg.set("connection", "device_id", str(data.get("device_id", self._args.device_id or "")))
cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto"))))
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))))))
cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto"))))
cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1))))
cfg.set("print", "vibration_compensation", str(int(bool(data.get("vibration_compensation", getattr(self._args, "vibration_compensation", 0))))))
cfg.set("print", "flow_calibration", str(int(bool(data.get("flow_calibration", getattr(self._args, "flow_calibration", 0))))))
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))))))
cfg.set("print", "timelapse_local", str(int(bool(data.get("timelapse_local", getattr(self._args, "timelapse_local", 0))))))
cfg.set("print", "timelapse_interval_sec", str(max(1, int(data.get("timelapse_interval_sec", getattr(self._args, "timelapse_interval_sec", 4))))))
cfg.set("print", "timelapse_printer", str(int(bool(data.get("timelapse_printer", getattr(self._args, "timelapse_printer", 0))))))
if not cfg.has_option("bridge", "poll_interval"):
cfg.set("bridge", "poll_interval", "3")
printer_name = str(data.get("printer_name", "")).strip()
@@ -3973,9 +4051,7 @@ class KobraXBridge:
GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag"
def _read_version(self) -> str:
# 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):
for base in (pathlib.Path(_BASE), pathlib.Path(_BASE).parent):
p = base / "VERSION"
if p.is_file():
return p.read_text(encoding="utf-8").strip()
@@ -4647,13 +4723,11 @@ 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/timelapses", bridge.handle_kx_timelapses)
r.add_get("/kx/timelapse/{tid}/download", bridge.handle_kx_timelapse_download)
r.add_get("/kx/timelapse/{tid}/thumb", bridge.handle_kx_timelapse_thumb)
r.add_delete("/kx/timelapse/{tid}", bridge.handle_kx_timelapse_delete)
r.add_get("/kx/ui/{name:.*}", bridge.handle_kx_ui_asset)
r.add_get("/kx/files/{id}/objects", bridge.handle_kx_file_objects)
r.add_post("/kx/skip", bridge.handle_kx_skip)
@@ -4816,9 +4890,14 @@ def main():
parser.add_argument("--mode-id", default=env_loader.MODE_ID)
parser.add_argument("--device-id", default=env_loader.DEVICE_ID)
parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT)
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("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING)
parser.add_argument("--vibration-compensation", type=int, default=env_loader.VIBRATION_COMPENSATION)
parser.add_argument("--flow-calibration", type=int, default=env_loader.FLOW_CALIBRATION)
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("--timelapse-local", type=int, default=env_loader.TIMELAPSE_LOCAL)
parser.add_argument("--timelapse-interval-sec", type=int, default=env_loader.TIMELAPSE_INTERVAL_SEC)
parser.add_argument("--timelapse-printer", type=int, default=env_loader.TIMELAPSE_PRINTER)
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"), ("VERSION", ".")] # bridge/data/ → static/ im _MEIPASS
datas = [("web", "web"), ("data", "static")] # bridge/data/ → static/ im _MEIPASS
binaries = []
hiddenimports = []

View File

@@ -1,147 +0,0 @@
"""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,17 +312,6 @@ 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);
@@ -334,8 +323,18 @@ 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-vibration-compensation',T.settings_vibration_compensation);
setText('lbl-flow-calibration',T.settings_flow_calibration);
setText('lbl-camera-on-print',T.settings_camera_on_print);
setText('lbl-web-upload-warning',T.settings_web_upload_warning);
setText('lbl-timelapse-local',T.settings_timelapse_local);
setText('lbl-timelapse-interval',T.settings_timelapse_interval);
setText('lbl-timelapse-printer',T.settings_timelapse_printer);
setText('lbl-layout',T.settings_layout);
setText('opt-layout-1col',T.settings_layout_1col);
setText('opt-layout-2col',T.settings_layout_2col);
setText('store-tab-files-label',T.store_tab_files||'Dateien');
setText('store-tab-tl-label',T.timelapse_tab);
setText('lbl-update-check',T.update_check);
setText('lbl-update-apply',T.update_apply);
@@ -901,8 +900,14 @@ function openSettings(){
document.getElementById('s-mode-id').value=d.mode_id||'';
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 vc=document.getElementById('s-vibration-compensation');if(vc)vc.checked=!!d.vibration_compensation;
var fc=document.getElementById('s-flow-calibration');if(fc)fc.checked=!!d.flow_calibration;
var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print;
var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning);
var tlLocal=document.getElementById('s-timelapse-local');if(tlLocal){tlLocal.checked=!!d.timelapse_local;_toggleTlInterval();}
var tlInt=document.getElementById('s-timelapse-interval');if(tlInt)tlInt.value=d.timelapse_interval_sec||4;
var tlPrinter=document.getElementById('s-timelapse-printer');if(tlPrinter)tlPrinter.checked=!!d.timelapse_printer;
var lay=document.getElementById('s-layout');if(lay)lay.value=localStorage.getItem('layout')||'1col';
});
var v=localStorage.getItem('pollInterval')||'2000';
document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')});
@@ -914,114 +919,95 @@ 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)';
});
// ── Layout ──
function applyLayout(mode){
var panel=document.getElementById('panel-dashboard');
if(!panel)return;
var fullWidth=['d-cam-card','d-progress-card','d-temps-card','d-ams-card'];
if(mode==='2col'){
panel.classList.add('layout-2col');
fullWidth.forEach(function(id){var el=document.getElementById(id);if(el)el.style.gridColumn='auto';});
}else{
panel.classList.remove('layout-2col');
fullWidth.forEach(function(id){var el=document.getElementById(id);if(el)el.style.gridColumn='1/-1';});
}
_one(0);
localStorage.setItem('layout',mode);
}
function _toggleTlInterval(){
var cb=document.getElementById('s-timelapse-local');
var row=document.getElementById('timelapse-interval-row');
if(row)row.style.display=(cb&&cb.checked)?'':'none';
}
// ── Store tabs ──
var _currentStoreTab='files';
var _timelapseData=[];
function switchStoreTab(tab){
_currentStoreTab=tab;
var filesSection=document.getElementById('store-files-section');
var tlSection=document.getElementById('store-timelapses-section');
var tabFiles=document.getElementById('store-tab-files');
var tabTl=document.getElementById('store-tab-timelapses');
if(filesSection)filesSection.style.display=tab==='files'?'':'none';
if(tlSection)tlSection.style.display=tab==='timelapses'?'':'none';
if(tabFiles){tabFiles.classList.toggle('active',tab==='files');}
if(tabTl){tabTl.classList.toggle('active',tab==='timelapses');}
if(tab==='timelapses')loadTimelapses();
}
function refreshStoreTab(){
if(_currentStoreTab==='timelapses')loadTimelapses();else loadStore();
}
function loadTimelapses(){
fetch(_apiUrl('/kx/timelapses')).then(function(r){return r.json();}).then(function(d){
_timelapseData=Array.isArray(d)?d:[];
renderTimelapses();
}).catch(function(e){clog('Timelapse-Fehler: '+e,'msg-err');});
}
function renderTimelapses(){
var list=document.getElementById('timelapse-list');
if(!list)return;
if(!_timelapseData.length){
list.innerHTML='<div class="tl-row"><span style="color:var(--txt2);padding:20px">'+(T.timelapse_empty||'Keine Timelapses vorhanden')+'</span></div>';
return;
}
list.innerHTML=_timelapseData.map(function(t){
var statusCls=t.status==='completed'?'tl-status-ok':t.status==='failed'?'tl-status-err':'tl-status-busy';
var statusTxt=t.status==='recording'?(T.timelapse_recording||'Aufnahme…'):t.status==='processing'?(T.timelapse_processing||'Kodierung…'):t.status;
var dur=t.duration_sec>0?Math.floor(t.duration_sec/60)+'m ':' ';
var dlBtn=t.status==='completed'?'<a href="'+_apiUrl('/kx/timelapse/'+t.id+'/download')+'" style="flex-shrink:0;padding:6px 12px;background:var(--accent);color:#000;border-radius:6px;font-size:12px;font-weight:600;text-decoration:none" download>⬇ Download</a>':'';
return '<div class="tl-row">'+
'<div class="tl-icon">🎬</div>'+
'<div class="tl-info">'+
'<div class="tl-name" title="'+t.filename+'">'+t.filename+'</div>'+
'<div class="tl-meta '+statusCls+'">'+statusTxt+'</div>'+
'<div class="tl-meta">'+t.frame_count+' frames &bull; '+dur+t.created_at.slice(0,10)+'</div>'+
'</div>'+
dlBtn+
'<button onclick="deleteTimelapse(\''+t.id+'\')" style="flex-shrink:0;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer;font-size:12px">✕</button>'+
'</div>';
}).join('');
}
function deleteTimelapse(id){
if(!confirm(T.confirm_delete_timelapse||'Dieses Timelapse löschen?'))return;
fetch(_apiUrl('/kx/timelapse/'+id),{method:'DELETE'}).then(function(){loadTimelapses();});
}
// ── AMS Slot Edit ──
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;
@@ -1059,31 +1045,21 @@ 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>';
// 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.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
// Gruppieren nach Vendor
var byVendor={};
systemProfs.forEach(function(p){ (byVendor[p.vendor]=byVendor[p.vendor]||[]).push(p); });
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){ _appendOption(g, p); });
byVendor[v].forEach(function(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;
if(o.value===wantKey) o.selected=true;
g.appendChild(o);
});
sel.appendChild(g);
});
});
@@ -1232,10 +1208,6 @@ 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');});
@@ -1269,10 +1241,16 @@ function saveSettings(){
device_id: document.getElementById('s-device-id').value,
mode_id: document.getElementById('s-mode-id').value,
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,
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0,
flow_calibration: (document.getElementById('s-flow-calibration')||{}).checked?1:0,
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
web_upload_warning:webUploadWarning,
timelapse_local: (document.getElementById('s-timelapse-local')||{}).checked?1:0,
timelapse_interval_sec: parseInt((document.getElementById('s-timelapse-interval')||{value:'4'}).value)||4,
timelapse_printer: (document.getElementById('s-timelapse-printer')||{}).checked?1:0,
}).then(function(){
applyLayout((document.getElementById('s-layout')||{value:'1col'}).value);
btn.textContent=T.update_restarting;
setTimeout(function(){
btn.disabled=false;
@@ -1351,6 +1329,7 @@ var pollTimer;
});
}).catch(function(){});
poll();pollTimer=setInterval(poll,ms);
applyLayout(localStorage.getItem('layout')||'1col');
})();
// ── Print actions ──
@@ -2108,6 +2087,20 @@ 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){

View File

@@ -102,6 +102,14 @@
<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-vibration-compensation" style="width:auto;margin:0">
<label id="lbl-vibration-compensation" style="margin:0;cursor:pointer" for="s-vibration-compensation">Resonance Compensation</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-flow-calibration" style="width:auto;margin:0">
<label id="lbl-flow-calibration" style="margin:0;cursor:pointer" for="s-flow-calibration">Flow Calibration</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>
@@ -110,6 +118,29 @@
<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 class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-timelapse-local" style="width:auto;margin:0" onchange="_toggleTlInterval()">
<label id="lbl-timelapse-local" style="margin:0;cursor:pointer" for="s-timelapse-local">Lokales Timelapse während Druck</label>
</div>
<div class="modal-field" id="timelapse-interval-row" style="display:none">
<label id="lbl-timelapse-interval" style="font-size:12px;color:var(--txt2)">Aufnahme-Intervall (Sekunden)</label>
<input type="number" id="s-timelapse-interval" min="1" max="60" value="4" style="width:80px">
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-timelapse-printer" style="width:auto;margin:0">
<label id="lbl-timelapse-printer" style="margin:0;cursor:pointer" for="s-timelapse-printer">Drucker-Timelapse aktivieren (experimentell)</label>
</div>
</div>
<div>
<div class="modal-section" id="modal-sec-layout">Layout</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px;flex-wrap:wrap">
<label id="lbl-layout" style="margin:0;font-size:12px;color:var(--txt2)">Dashboard-Layout</label>
<select id="s-layout" style="width:auto;margin:0">
<option value="1col" id="opt-layout-1col">1 Spalte</option>
<option value="2col" id="opt-layout-2col">2 Spalten</option>
</select>
</div>
</div>
<div>
@@ -121,19 +152,6 @@
</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">
@@ -183,42 +201,12 @@
<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">
@@ -236,7 +224,7 @@
<div class="panel active" id="panel-dashboard">
<div class="grid">
<!-- Kamera -->
<div class="card" style="grid-column:1/-1">
<div class="card" id="d-cam-card" style="grid-column:1/-1">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<div class="card-title" style="margin-bottom:0"><span>📷</span> <span id="d-card-cam">Kamera</span></div>
<div style="display:flex;align-items:center;gap:10px">
@@ -260,7 +248,7 @@
</div>
<!-- Fortschritt -->
<div class="card" style="grid-column:1/-1">
<div class="card" id="d-progress-card" style="grid-column:1/-1">
<div class="card-title"><span></span> <span id="d-card-progress">Fortschritt</span></div>
<img id="d-thumbnail" src="" alt="" style="display:none;width:100%;max-height:160px;object-fit:contain;border-radius:8px;background:#111;margin-bottom:10px">
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
@@ -294,7 +282,7 @@
</div>
<!-- Temperatursteuerung + Verlauf -->
<div class="card" style="grid-column:1/-1">
<div class="card" id="d-temps-card" style="grid-column:1/-1">
<div class="card-title"><span></span> <span id="d-card-temps">Temperaturen</span></div>
<div class="temp-card-inner">
<div class="temp-block">
@@ -444,8 +432,15 @@
<div class="card">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<span id="store-panel-title">🗂 Datei-Browser</span>
<button id="store-refresh-btn" onclick="loadStore()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">↻ Aktualisieren</button>
<div style="display:flex;gap:6px;align-items:center">
<button id="store-tab-files" class="store-tab-btn active" onclick="switchStoreTab('files')">📁 <span id="store-tab-files-label">Dateien</span></button>
<button id="store-tab-timelapses" class="store-tab-btn" onclick="switchStoreTab('timelapses')">🎬 <span id="store-tab-tl-label">Timelapses</span></button>
<button id="store-refresh-btn" onclick="refreshStoreTab()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer"></button>
</div>
</div>
<!-- Dateien-Bereich -->
<div id="store-files-section">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
@@ -476,6 +471,12 @@
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
</div>
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div><!-- /store-files-section -->
<!-- Timelapse-Bereich -->
<div id="store-timelapses-section" style="display:none">
<div id="timelapse-list"><div style="text-align:center;padding:40px;color:var(--txt2)" id="timelapse-empty-msg">Keine Timelapses vorhanden</div></div>
</div>
</div>
</div>

View File

@@ -313,3 +313,25 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
.modal-box{padding:16px;border-radius:10px}
.poll-btns{gap:6px}
}
/* ── 2-Column Dashboard Layout ── */
#panel-dashboard.layout-2col .grid{grid-template-columns:1fr 1fr}
@media(max-width:768px){
#panel-dashboard.layout-2col .grid{grid-template-columns:1fr}
}
/* ── Store tabs ── */
.store-tab-btn{padding:6px 16px;border-radius:6px;border:1px solid var(--border);
background:var(--raised);color:var(--txt2);cursor:pointer;font-size:13px;font-weight:600;transition:.12s}
.store-tab-btn.active{background:var(--accent);border-color:var(--accent);color:#000}
/* ── Timelapse list ── */
.tl-row{display:flex;align-items:center;gap:12px;padding:10px;
border:1px solid var(--border);border-radius:8px;margin-bottom:8px}
.tl-icon{font-size:28px;color:var(--txt2);flex-shrink:0}
.tl-info{flex:1;min-width:0}
.tl-name{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tl-meta{font-size:11px;color:var(--txt2);margin-top:2px}
.tl-status-ok{color:var(--ok)}
.tl-status-err{color:var(--err)}
.tl-status-busy{color:var(--warn)}

View File

@@ -140,8 +140,22 @@
"settings_default_slot": "Standard-Slot (Einfarbdruck)",
"settings_slot_auto": "Auto (alle belegten Slots)",
"settings_auto_leveling": "Auto-Leveling vor Druck",
"settings_vibration_compensation": "Resonanzkompensation vor Druck",
"settings_flow_calibration": "Flow-Kalibrierung vor Druck",
"settings_camera_on_print": "Kamera bei Druckstart einschalten",
"settings_web_upload_warning": "Warnung bei Web-Upload-Druck anzeigen",
"settings_timelapse_local": "Lokales Timelapse während Druck",
"settings_timelapse_interval": "Aufnahme-Intervall (Sekunden)",
"settings_timelapse_printer": "Drucker-Timelapse aktivieren (experimentell)",
"settings_layout": "Dashboard-Layout",
"settings_layout_1col": "1 Spalte",
"settings_layout_2col": "2 Spalten",
"store_tab_files": "Dateien",
"timelapse_tab": "Timelapses",
"timelapse_empty": "Noch keine Timelapses vorhanden",
"timelapse_recording": "Aufnahme läuft…",
"timelapse_processing": "Kodierung läuft…",
"confirm_delete_timelapse": "Dieses Timelapse löschen?",
"update_check": "Auf Updates prüfen",
"update_checking": "Prüfe...",
"update_available": "verfügbar",
@@ -164,19 +178,6 @@
"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

@@ -140,8 +140,22 @@
"settings_default_slot": "Default Slot (single color)",
"settings_slot_auto": "Auto (all loaded slots)",
"settings_auto_leveling": "Auto-Leveling before print",
"settings_vibration_compensation": "Resonance Compensation before print",
"settings_flow_calibration": "Flow Calibration before print",
"settings_camera_on_print": "Turn camera on at print start",
"settings_web_upload_warning": "Show warning when printing web uploads",
"settings_timelapse_local": "Local timelapse during print",
"settings_timelapse_interval": "Capture interval (seconds)",
"settings_timelapse_printer": "Enable printer timelapse (experimental)",
"settings_layout": "Dashboard layout",
"settings_layout_1col": "1 column",
"settings_layout_2col": "2 columns",
"store_tab_files": "Files",
"timelapse_tab": "Timelapses",
"timelapse_empty": "No timelapses yet",
"timelapse_recording": "Recording…",
"timelapse_processing": "Encoding…",
"confirm_delete_timelapse": "Delete this timelapse?",
"update_check": "Check for Updates",
"update_checking": "Checking...",
"update_available": "available",
@@ -164,19 +178,6 @@
"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

@@ -140,8 +140,22 @@
"settings_default_slot": "Ranura predeterminada (un color)",
"settings_slot_auto": "Auto (todos los slots cargados)",
"settings_auto_leveling": "Autonivelado antes de imprimir",
"settings_vibration_compensation": "Compensación de resonancia antes de imprimir",
"settings_flow_calibration": "Calibración de flujo antes de imprimir",
"settings_camera_on_print": "Encender cámara al iniciar impresión",
"settings_web_upload_warning": "Mostrar advertencia al imprimir subidas web",
"settings_timelapse_local": "Timelapse local durante la impresión",
"settings_timelapse_interval": "Intervalo de captura (segundos)",
"settings_timelapse_printer": "Activar timelapse de impresora (experimental)",
"settings_layout": "Diseño del panel",
"settings_layout_1col": "1 columna",
"settings_layout_2col": "2 columnas",
"store_tab_files": "Archivos",
"timelapse_tab": "Timelapses",
"timelapse_empty": "Aún no hay timelapses",
"timelapse_recording": "Grabando…",
"timelapse_processing": "Codificando…",
"confirm_delete_timelapse": "¿Eliminar este timelapse?",
"update_check": "Buscar actualizaciones",
"update_checking": "Comprobando...",
"update_available": "disponible",
@@ -164,19 +178,6 @@
"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

@@ -140,8 +140,22 @@
"settings_default_slot": "默认槽位 (单色)",
"settings_slot_auto": "自动 (所有已装载槽位)",
"settings_auto_leveling": "打印前自动调平",
"settings_vibration_compensation": "打印前共振补偿",
"settings_flow_calibration": "打印前流量校准",
"settings_camera_on_print": "打印开始时开启相机",
"settings_web_upload_warning": "打印网页上传文件时显示警告",
"settings_timelapse_local": "打印期间本地延时摄影",
"settings_timelapse_interval": "拍摄间隔(秒)",
"settings_timelapse_printer": "启用打印机延时摄影(实验性)",
"settings_layout": "仪表板布局",
"settings_layout_1col": "单列",
"settings_layout_2col": "双列",
"store_tab_files": "文件",
"timelapse_tab": "延时视频",
"timelapse_empty": "暂无延时视频",
"timelapse_recording": "录制中…",
"timelapse_processing": "编码中…",
"confirm_delete_timelapse": "删除此延时视频?",
"update_check": "检查更新",
"update_checking": "检查中...",
"update_available": "可用",
@@ -164,19 +178,6 @@
"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": "▶ 开始打印",