forked from viewit/KX-Bridge-Release
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f5a8cbc72 | |||
| a40f14af8e | |||
| 466b8c518d | |||
| 1c5396b37d | |||
| c23deebde5 | |||
| 76738e5961 | |||
| 9c82073540 |
@@ -1,5 +1,84 @@
|
|||||||
# Changelog
|
# 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
|
## [0.9.18] – 2026-05-31
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
@@ -11,8 +90,8 @@
|
|||||||
(`slicer/printer/…`) gesendet, der Drucker hat ihn stillschweigend
|
(`slicer/printer/…`) gesendet, der Drucker hat ihn stillschweigend
|
||||||
ignoriert. Jetzt geht er über `web/printer/…` wie der Anycubic
|
ignoriert. Jetzt geht er über `web/printer/…` wie der Anycubic
|
||||||
Slicer Next es macht (verifiziert via Live-MQTT-Sniff +
|
Slicer Next es macht (verifiziert via Live-MQTT-Sniff +
|
||||||
Workbench-Vue-Source). **Achtung:** der aktiv geladene Slot kann
|
Workbench-Vue-Source). **Achtung:** der Drucker muss im Idle-Zustand
|
||||||
während des Drucks nicht umgeschrieben werden — vorher ausziehen.
|
sein, und leere Slots lassen sich nicht beschriften.
|
||||||
- **Mehrsprachiges UI – spanische Übersetzung von Muttersprachler
|
- **Mehrsprachiges UI – spanische Übersetzung von Muttersprachler
|
||||||
überarbeitet (PR #40 von @pezfisk):** fehlende Akzente
|
überarbeitet (PR #40 von @pezfisk):** fehlende Akzente
|
||||||
(impresión, cámara, después, animación, …), Begriffe vereinheitlicht
|
(impresión, cámara, después, animación, …), Begriffe vereinheitlicht
|
||||||
|
|||||||
83
CHANGELOG.md
83
CHANGELOG.md
@@ -1,5 +1,84 @@
|
|||||||
# Changelog
|
# 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
|
## [0.9.18] – 2026-05-31
|
||||||
|
|
||||||
### New
|
### New
|
||||||
@@ -10,8 +89,8 @@
|
|||||||
sent over the wrong MQTT topic (`slicer/printer/…`) and the printer
|
sent over the wrong MQTT topic (`slicer/printer/…`) and the printer
|
||||||
silently dropped it. It now goes via `web/printer/…` like the
|
silently dropped it. It now goes via `web/printer/…` like the
|
||||||
Anycubic Slicer Next does (verified by live MQTT sniff +
|
Anycubic Slicer Next does (verified by live MQTT sniff +
|
||||||
Workbench-Vue source). **Note:** the currently loaded slot can not
|
Workbench-Vue source). **Note:** the printer must be idle, and
|
||||||
be overwritten during a print — unload it first.
|
empty slots can not be labelled.
|
||||||
- **Spanish translation reviewed by a native speaker (PR #40 by
|
- **Spanish translation reviewed by a native speaker (PR #40 by
|
||||||
@pezfisk):** missing accents (impresión, cámara, después,
|
@pezfisk):** missing accents (impresión, cámara, después,
|
||||||
animación, …) and term consistency (Pause → Pausa, Start →
|
animación, …) and term consistency (Pause → Pausa, Start →
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ COPY data/ ./static/
|
|||||||
COPY config_loader.py .
|
COPY config_loader.py .
|
||||||
COPY env_loader.py .
|
COPY env_loader.py .
|
||||||
COPY kobrax_client.py .
|
COPY kobrax_client.py .
|
||||||
|
COPY orca_filaments.py .
|
||||||
COPY VERSION .
|
COPY VERSION .
|
||||||
COPY anycubic_slicer.crt .
|
COPY anycubic_slicer.crt .
|
||||||
COPY anycubic_slicer.key .
|
COPY anycubic_slicer.key .
|
||||||
|
|||||||
49
README.de.md
49
README.de.md
@@ -8,6 +8,11 @@
|
|||||||
|
|
||||||
Eine Moonraker-kompatible Bridge, die direkt mit dem Drucker spricht.
|
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>
|
<sub>🇬🇧 <a href="README.md">English version</a> · 🇪🇸 <a href="README.es.md">Versión española</a></sub>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -32,12 +37,17 @@ Eine Moonraker-kompatible Bridge, die direkt mit dem Drucker spricht.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 🖨️ | **Druckersteuerung** — Start, Pause, Resume, Abbruch, Temperaturen, Druckgeschwindigkeit |
|
| 🖨️ | **Druckersteuerung** — Start, Pause, Resume, Abbruch, Temperaturen, Druckgeschwindigkeit |
|
||||||
| 📊 | **Live-Status** — Temperatur, Fortschritt, Layer, Restzeit, Kamera-Stream |
|
| 📊 | **Live-Status** — Temperatur, Fortschritt, Layer, Restzeit, Kamera-Stream |
|
||||||
| 🎨 | **AMS / Multicolor** — Filament-Slots, Per-Kanal-Remapping, MMU-Emulation für OrcaSlicer Filament-Sync |
|
| 🎨 | **AMS / Multicolor** — Slots mit **Profil-Picker pro Slot** (eigene Marke aus OrcaSlicer-Profilen pro Slot zuweisen); Bridge schreibt Material und Farbe ans Drucker-Display zurück |
|
||||||
|
| 📦 | **Eigene OrcaSlicer-Profile importieren** — ZIP aus `~/.config/OrcaSlicer/user/<id>/filament/` in die Bridge ziehen; tauchen im Slot-Dropdown unter ★ Eigene Profile auf |
|
||||||
|
| 📷 | **Obico-Integration (experimentell)** — Time-Lapse und WebRTC-Livestream gegen einen selbst gehosteten [Obico-Server](https://github.com/TheSpaghettiDetective/obico-server) via moonraker-obico |
|
||||||
|
| 📐 | **H.264-Direkt-Stream + Z-Höhe** — sparsamer Kamera-Pfad für Obico, aktuelle Z aus der Layer-Höhe abgeleitet (Mm-Progress-Widget) |
|
||||||
| 🗂️ | **GCode-Browser** — hochgeladene Dateien mit Thumbnail, Druckhistorie, Suche & Filter |
|
| 🗂️ | **GCode-Browser** — hochgeladene Dateien mit Thumbnail, Druckhistorie, Suche & Filter |
|
||||||
| 🧩 | **Multi-Printer** — mehrere Drucker in **einer** Bridge-Instanz, Umschalten per Dropdown |
|
| 🧩 | **Multi-Printer** — mehrere Drucker in **einer** Bridge-Instanz, Umschalten per Dropdown |
|
||||||
| ➕ | **Drucker hinzufügen per Klick** — nur die IP eingeben, Zugangsdaten werden automatisch importiert |
|
| ➕ | **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 |
|
| 🔄 | **Self-Update** — neue Versionen direkt im Browser installieren |
|
||||||
| 🌐 | **OrcaSlicer** — volles Moonraker-Protokoll (HTTP + WebSocket), DE/EN UI |
|
| 🧠 | **OrcaSlicer** — volles Moonraker-Protokoll (HTTP + WebSocket); für korrekten Vendor-Match pro Slot den [OrcaSlicer-KX-Build](#-empfohlener-slicer) nutzen |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -103,18 +113,28 @@ Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125`
|
|||||||
|
|
||||||
## 🎨 Empfohlener Slicer
|
## 🎨 Empfohlener Slicer
|
||||||
|
|
||||||
Für die beste Erfahrung mit der KX-Bridge bieten wir einen **gepatchten
|
Für sauberen AMS-Filament-Sync gibt es einen **gepatchten OrcaSlicer-Build**:
|
||||||
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)
|
→ **[OrcaSlicer-KX Releases](https://gitea.it-drui.de/viewit/OrcaSlicer-KX/releases/latest)** (Linux AppImage + Windows ZIP)
|
||||||
|
|
||||||
Standard-OrcaSlicer funktioniert auch; der gepatchte Build verbessert
|
**Upstream-PRs im KX-Build:**
|
||||||
hauptsächlich das AMS-Handling. Es ist ein Build von
|
|
||||||
[OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0); der Quellcode
|
- **[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)
|
||||||
ist über die verlinkten PRs verfügbar.
|
- **[PR #13719](https://github.com/SoftFever/OrcaSlicer/pull/13719)** — Vendor- + Name-Matching für Moonraker (liest `name` + `vendor_name` pro Slot und matched gegen deine Filament-Presets), von [@LordGuenni](https://github.com/LordGuenni)
|
||||||
|
- **[PR #13315](https://github.com/SoftFever/OrcaSlicer/pull/13315)** — Eindeutige `filament_id` für User-Presets (neu erstellte eigene Profile bekommen eine frische ID statt das `OGFL99` vom Generic-Parent zu erben), von [@mrnoisytiger](https://github.com/mrnoisytiger)
|
||||||
|
|
||||||
|
**Plus vier KX-eigene Verbesserungen on top:**
|
||||||
|
|
||||||
|
- Bridge-Filament-Hint (`tray_info_idx` + Vendor) respektieren
|
||||||
|
- Vendor-Match auch wenn das gewählte Base-Preset **nicht is_compatible** mit dem aktiven Drucker ist (so matchen Profile aus anderen Drucker-Setups trotzdem über die Marke)
|
||||||
|
- Vendor-Match wenn `tray_info_idx` gesetzt ist, das Preset aber inkompatibel
|
||||||
|
- Zwei-Pass-Suche: erst kompatible Presets, dann alle sichtbaren
|
||||||
|
|
||||||
|
**Warum das zusammen wichtig ist:** ohne #13719 landen die AMS-Slots in OrcaSlicer alle auf `Generic PLA` / `Generic PETG`, obwohl die Bridge die konkrete Marke schon mitsendet (`name + vendor_name + gate_filament_name`). Mit dem KX-Build matched OrcaSlicer deine echten User-Presets — auch die, die du via [Eigene OrcaSlicer-Profile importieren](#-features) in die Bridge gezogen hast.
|
||||||
|
|
||||||
|
Stock-Upstream-OrcaSlicer funktioniert für Slicing und Drucken weiterhin — nur das Per-Slot-Vendor-Matching beim AMS-Sync fällt dann weg. Material und Farbe pro Slot kannst du auch ohne den KX-Build über die Bridge ans Drucker-Display schreiben (das läuft über MQTT, nicht über den Slicer).
|
||||||
|
|
||||||
|
OrcaSlicer-KX ist ein Build von [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0); der Quellcode der Upstream-PRs ist auf GitHub, die KX-spezifischen Patches im OrcaSlicer-KX-Repo.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,6 +143,13 @@ ist über die verlinkten PRs verfügbar.
|
|||||||
- **[Home-Assistant-Integration](https://github.com/gangoke/kobrax-lan-hass-component)**
|
- **[Home-Assistant-Integration](https://github.com/gangoke/kobrax-lan-hass-component)**
|
||||||
von [@gangoke](https://github.com/gangoke) — bindet Sensoren, Drucksteuerung,
|
von [@gangoke](https://github.com/gangoke) — bindet Sensoren, Drucksteuerung,
|
||||||
Licht, Kamera und das GCode-Vorschaubild als native Home-Assistant-Entitäten ein.
|
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
|
> Dies sind **Community-Projekte**, die nicht von KX-Bridge betreut oder
|
||||||
> supportet werden. Bei Fragen oder Problemen bitte das verlinkte Repository nutzen.
|
> supportet werden. Bei Fragen oder Problemen bitte das verlinkte Repository nutzen.
|
||||||
|
|||||||
45
README.es.md
45
README.es.md
@@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
Un puente compatible con Moonraker que se comunica directamente con la impresora.
|
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>
|
<sub>🇬🇧 <a href="README.md">English version</a> · 🇩🇪 <a href="README.de.md">Deutsche Version</a></sub>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -32,12 +36,17 @@ Un puente compatible con Moonraker que se comunica directamente con la impresora
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 🖨️ | **Control de impresora** — iniciar, pausar, reanudar, cancelar, temperaturas, velocidad de impresión |
|
| 🖨️ | **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 |
|
| 📊 | **Estado en tiempo real** — temperatura, progreso, capas, tiempo restante, transmisión de cámara |
|
||||||
| 🎨 | **AMS / multicolor** — ranuras de filamento, reasignación por canal, emulación MMU para sincronización de filamento en OrcaSlicer |
|
| 🎨 | **AMS / multicolor** — ranuras con **selector de perfil por ranura** (asigna tu propia marca de los perfiles de OrcaSlicer a cada ranura); el puente escribe material y color al display de la impresora |
|
||||||
|
| 📦 | **Importa tus propios perfiles de OrcaSlicer** — arrastra un ZIP de `~/.config/OrcaSlicer/user/<id>/filament/` al puente; aparecen en el desplegable de la ranura bajo ★ Perfiles propios |
|
||||||
|
| 📷 | **Integración con Obico (experimental)** — Time-Lapse y stream en vivo WebRTC contra un [servidor Obico](https://github.com/TheSpaghettiDetective/obico-server) autoalojado vía moonraker-obico |
|
||||||
|
| 📐 | **Stream H.264 directo + altura Z** — ruta de cámara de bajo consumo de CPU para Obico, Z actual derivada de la altura de capa (widget de progreso) |
|
||||||
| 🗂️ | **Explorador de GCode** — archivos subidos con vistas previas, historial de impresión, búsqueda y filtros |
|
| 🗂️ | **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 |
|
| 🧩 | **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 |
|
| ➕ | **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 |
|
| 🔄 | **Actualización automática** — instala nuevas versiones directamente desde el navegador |
|
||||||
| 🌐 | **OrcaSlicer** — protocolo Moonraker completo (HTTP + WebSocket), interfaz EN/ES |
|
| 🧠 | **OrcaSlicer** — protocolo Moonraker completo (HTTP + WebSocket); usa el [build OrcaSlicer-KX](#-slicer-recomendado) para emparejamiento correcto de vendor por ranura |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -103,16 +112,28 @@ Impresora → Tipo de conexión **Moonraker** → Host: `http://IP-DEL-PUENTE:71
|
|||||||
|
|
||||||
## 🎨 Slicer recomendado
|
## 🎨 Slicer recomendado
|
||||||
|
|
||||||
Para la mejor experiencia con KX-Bridge ofrecemos una **versión modificada de OrcaSlicer** que
|
Para una sincronización de filamento AMS correcta ofrecemos una **versión modificada de OrcaSlicer**:
|
||||||
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)
|
→ **[Lanzamientos de OrcaSlicer-KX](https://gitea.it-drui.de/viewit/OrcaSlicer-KX/releases/latest)** (Linux AppImage + Windows ZIP)
|
||||||
|
|
||||||
OrcaSlicer estándar también funciona; la versión modificada mejora principalmente el manejo de AMS.
|
**PRs upstream incluidos en el build KX:**
|
||||||
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.
|
- **[PR #13372](https://github.com/SoftFever/OrcaSlicer/pull/13372)** — Corrección de sincronización Moonraker / Happy-Hare AMS (las posiciones de las ranuras se mantienen correctas incluso con ranuras vacías)
|
||||||
|
- **[PR #13719](https://github.com/SoftFever/OrcaSlicer/pull/13719)** — Coincidencia de Vendor + Nombre para Moonraker (lee `name` + `vendor_name` por ranura y los empareja con los presets de filamento del usuario), por [@LordGuenni](https://github.com/LordGuenni)
|
||||||
|
- **[PR #13315](https://github.com/SoftFever/OrcaSlicer/pull/13315)** — `filament_id` único para presets de usuario (los perfiles nuevos reciben un ID nuevo en vez de heredar `OGFL99` del padre genérico), por [@mrnoisytiger](https://github.com/mrnoisytiger)
|
||||||
|
|
||||||
|
**Más cuatro mejoras específicas de KX encima:**
|
||||||
|
|
||||||
|
- Respetar el hint de filamento del puente (`tray_info_idx` + vendor)
|
||||||
|
- Coincidencia por vendor incluso cuando el preset base no es **is_compatible** con la impresora activa (así un perfil copiado de otra máquina sigue coincidiendo por vendor)
|
||||||
|
- Coincidencia por vendor cuando `tray_info_idx` está definido pero su preset es incompatible
|
||||||
|
- Búsqueda de dos pasadas: primero presets compatibles, luego todos los visibles
|
||||||
|
|
||||||
|
**Por qué importa:** sin #13719 todas las ranuras AMS caen en `Generic PLA` / `Generic PETG` aunque el puente ya envíe la marca concreta (`name + vendor_name + gate_filament_name`). Con el build KX, OrcaSlicer coincide con tus presets de usuario reales — incluyendo los perfiles que importaste al puente vía [Importa tus propios perfiles de OrcaSlicer](#-características).
|
||||||
|
|
||||||
|
OrcaSlicer upstream también funciona para rebanar e imprimir — solo pierdes la coincidencia de vendor por ranura en la sincronización AMS. El material y color por ranura se pueden empujar puente → impresora con cualquier slicer (eso va por MQTT, no por el slicer).
|
||||||
|
|
||||||
|
OrcaSlicer-KX es un build de [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0); el código fuente de cada PR upstream está en GitHub, los parches específicos de KX en el repo OrcaSlicer-KX.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -121,6 +142,12 @@ el código fuente está disponible a través de los PRs enlazados.
|
|||||||
- **[Integración con Home Assistant](https://github.com/gangoke/kobrax-lan-hass-component)**
|
- **[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,
|
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.
|
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.
|
> Estos son **proyectos de la comunidad**, no mantenidos ni soportados por KX-Bridge.
|
||||||
> Para preguntas o problemas, utiliza el repositorio enlazado.
|
> Para preguntas o problemas, utiliza el repositorio enlazado.
|
||||||
|
|||||||
45
README.md
45
README.md
@@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
A Moonraker-compatible bridge that talks directly to the printer.
|
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>
|
<sub>🇩🇪 <a href="README.de.md">Deutsche Version</a> · 🇪🇸 <a href="README.es.md">Versión española</a></sub>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -32,12 +36,17 @@ A Moonraker-compatible bridge that talks directly to the printer.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 🖨️ | **Printer control** — start, pause, resume, cancel, temperatures, print speed |
|
| 🖨️ | **Printer control** — start, pause, resume, cancel, temperatures, print speed |
|
||||||
| 📊 | **Live status** — temperature, progress, layers, remaining time, camera stream |
|
| 📊 | **Live status** — temperature, progress, layers, remaining time, camera stream |
|
||||||
| 🎨 | **AMS / multicolor** — filament slots, per-channel remapping, MMU emulation for OrcaSlicer filament sync |
|
| 🎨 | **AMS / multicolor** — slots with per-slot **profile picker** (assign your own brand from OrcaSlicer profiles per slot); bridge writes material & colour back to the printer display |
|
||||||
|
| 📦 | **Import your own OrcaSlicer profiles** — drag a ZIP from `~/.config/OrcaSlicer/user/<id>/filament/` into the bridge; they show up in the slot dropdown under ★ Own profiles |
|
||||||
|
| 📷 | **Obico integration (experimental)** — Time-Lapse and WebRTC live stream against a self-hosted [Obico server](https://github.com/TheSpaghettiDetective/obico-server) via moonraker-obico |
|
||||||
|
| 📐 | **Direct H.264 stream + Z-height** — low-CPU camera path for Obico, current Z derived from layer-height for the print-progress widget |
|
||||||
| 🗂️ | **GCode browser** — uploaded files with thumbnails, print history, search & filter |
|
| 🗂️ | **GCode browser** — uploaded files with thumbnails, print history, search & filter |
|
||||||
| 🧩 | **Multi-printer** — multiple printers in **one** bridge instance, switch via dropdown |
|
| 🧩 | **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 |
|
| ➕ | **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 |
|
| 🔄 | **Self-update** — install new versions directly in the browser |
|
||||||
| 🌐 | **OrcaSlicer** — full Moonraker protocol (HTTP + WebSocket), EN/DE UI |
|
| 🧠 | **OrcaSlicer** — full Moonraker protocol (HTTP + WebSocket); pair with the [OrcaSlicer-KX build](#-recommended-slicer) for proper per-slot vendor matching |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -103,16 +112,28 @@ Printer → Connection type **Moonraker** → Host: `http://BRIDGE-IP:7125`
|
|||||||
|
|
||||||
## 🎨 Recommended Slicer
|
## 🎨 Recommended Slicer
|
||||||
|
|
||||||
For the best KX-Bridge experience we offer a **patched OrcaSlicer build** that
|
For proper AMS filament-sync we ship a **patched OrcaSlicer build**:
|
||||||
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)
|
→ **[OrcaSlicer-KX releases](https://gitea.it-drui.de/viewit/OrcaSlicer-KX/releases/latest)** (Linux AppImage + Windows ZIP)
|
||||||
|
|
||||||
Standard OrcaSlicer also works; the patched build mainly improves AMS handling.
|
**Upstream PRs bundled in the KX build:**
|
||||||
It's a build of [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0);
|
|
||||||
source is available via the linked PRs.
|
- **[PR #13372](https://github.com/SoftFever/OrcaSlicer/pull/13372)** — Moonraker / Happy-Hare AMS sync fix (slot positions stay correct even with empty slots)
|
||||||
|
- **[PR #13719](https://github.com/SoftFever/OrcaSlicer/pull/13719)** — Vendor + Name matching for Moonraker (reads `name` + `vendor_name` per slot and matches against the user's filament presets), by [@LordGuenni](https://github.com/LordGuenni)
|
||||||
|
- **[PR #13315](https://github.com/SoftFever/OrcaSlicer/pull/13315)** — Unique `filament_id` for user presets (so newly created custom profiles get a fresh ID instead of inheriting `OGFL99` from the generic parent), by [@mrnoisytiger](https://github.com/mrnoisytiger)
|
||||||
|
|
||||||
|
**Plus four KX-specific matching improvements on top:**
|
||||||
|
|
||||||
|
- Respect the bridge filament hint (`tray_info_idx` + vendor)
|
||||||
|
- Vendor match also when the chosen base preset is **not is_compatible** with the active printer (so a profile copied from a different machine still matches by vendor)
|
||||||
|
- Vendor match when `tray_info_idx` is set but its preset is incompatible
|
||||||
|
- Two-pass lookup: first compatible presets, then all visible ones
|
||||||
|
|
||||||
|
**Why this matters:** without #13719 the AMS slots in OrcaSlicer all fall back to `Generic PLA` / `Generic PETG` even though the bridge already sends the concrete brand (`name + vendor_name + gate_filament_name`). With the KX build OrcaSlicer matches your actual user presets — including profiles you imported into the bridge via the [Import your own OrcaSlicer profiles](#-features) flow.
|
||||||
|
|
||||||
|
Stock upstream OrcaSlicer still works for slicing and printing — you just lose the per-slot brand matching on AMS sync. Slot material + colour can still be pushed bridge → printer either way (that goes over MQTT, not via the slicer).
|
||||||
|
|
||||||
|
OrcaSlicer-KX is a build of [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0); source for each upstream PR is on GitHub, KX-specific patches live in the OrcaSlicer-KX repo.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -121,6 +142,12 @@ source is available via the linked PRs.
|
|||||||
- **[Home Assistant integration](https://github.com/gangoke/kobrax-lan-hass-component)**
|
- **[Home Assistant integration](https://github.com/gangoke/kobrax-lan-hass-component)**
|
||||||
by [@gangoke](https://github.com/gangoke) — exposes sensors, print controls,
|
by [@gangoke](https://github.com/gangoke) — exposes sensors, print controls,
|
||||||
light, camera and the GCode thumbnail as native Home Assistant entities.
|
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.
|
> These are **community projects**, not maintained or supported by KX-Bridge.
|
||||||
> For questions or issues, please use the linked repository.
|
> For questions or issues, please use the linked repository.
|
||||||
|
|||||||
@@ -57,15 +57,10 @@ def _load_config_file(path: pathlib.Path):
|
|||||||
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
|
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
|
||||||
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
|
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
|
||||||
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
|
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
|
||||||
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
||||||
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
||||||
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
|
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
|
||||||
"FLOW_CALIBRATION": (CONFIG_SECTION_PRINT, "flow_calibration"),
|
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
|
||||||
"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"),
|
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
|
||||||
}
|
}
|
||||||
for env_key, (section, option) in mapping.items():
|
for env_key, (section, option) in mapping.items():
|
||||||
@@ -100,15 +95,10 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
|
|||||||
"device_id": env_vals.get("DEVICE_ID", ""),
|
"device_id": env_vals.get("DEVICE_ID", ""),
|
||||||
}
|
}
|
||||||
cfg[CONFIG_SECTION_PRINT] = {
|
cfg[CONFIG_SECTION_PRINT] = {
|
||||||
"default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"),
|
"default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"),
|
||||||
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
|
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
|
||||||
"vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"),
|
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
|
||||||
"flow_calibration": env_vals.get("FLOW_CALIBRATION", "0"),
|
"web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"),
|
||||||
"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] = {
|
cfg[CONFIG_SECTION_BRIDGE] = {
|
||||||
"poll_interval": "3",
|
"poll_interval": "3",
|
||||||
@@ -266,11 +256,6 @@ PASSWORD = get("MQTT_PASSWORD", "")
|
|||||||
MODE_ID = get("MODE_ID", "")
|
MODE_ID = get("MODE_ID", "")
|
||||||
DEVICE_ID = get("DEVICE_ID", "")
|
DEVICE_ID = get("DEVICE_ID", "")
|
||||||
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
AUTO_LEVELING = int(get("AUTO_LEVELING","1"))
|
||||||
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0"))
|
||||||
FLOW_CALIBRATION = int(get("FLOW_CALIBRATION", "0"))
|
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
|
||||||
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
@@ -154,6 +154,13 @@ class KobraXClient:
|
|||||||
# -- Connection ----------------------------------------------------------
|
# -- Connection ----------------------------------------------------------
|
||||||
|
|
||||||
def _do_connect(self):
|
def _do_connect(self):
|
||||||
|
if not os.path.exists(CERT_FILE) or not os.path.exists(KEY_FILE):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"TLS-Zertifikate fehlen: anycubic_slicer.crt + anycubic_slicer.key "
|
||||||
|
f"müssen neben der kx-bridge Binary liegen ({_SCRIPT_DIR}/). "
|
||||||
|
f"Lade anycubic-certs.zip vom Gitea-Release herunter und entpacke "
|
||||||
|
f"die Dateien dorthin."
|
||||||
|
)
|
||||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
ctx.check_hostname = False
|
ctx.check_hostname = False
|
||||||
ctx.verify_mode = ssl.CERT_NONE
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
|||||||
@@ -407,18 +407,6 @@ class GCodeStore:
|
|||||||
filament_assignments TEXT,
|
filament_assignments TEXT,
|
||||||
abort_reason 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
|
# Migration: Spalte gcode_filaments nachrüsten falls DB älter
|
||||||
try:
|
try:
|
||||||
@@ -582,56 +570,6 @@ class GCodeStore:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
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:
|
class CameraCache:
|
||||||
"""Zentraler Kamera-Demuxer.
|
"""Zentraler Kamera-Demuxer.
|
||||||
@@ -794,96 +732,6 @@ class CameraCache:
|
|||||||
await asyncio.sleep(2.0)
|
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:
|
class KobraXBridge:
|
||||||
def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None):
|
def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None):
|
||||||
self.client = client
|
self.client = client
|
||||||
@@ -948,15 +796,9 @@ class KobraXBridge:
|
|||||||
self._last_uploaded_file: str = ""
|
self._last_uploaded_file: str = ""
|
||||||
self._store = store if store is not None else GCodeStore(args.data_dir)
|
self._store = store if store is not None else GCodeStore(args.data_dir)
|
||||||
self._serve_dir_path: str = self._store._gcode_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._current_job_id: str = ""
|
||||||
self._camera_autostarted: bool = False
|
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.camera_cache: CameraCache = CameraCache()
|
||||||
self._loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
|
|
||||||
|
|
||||||
self._thumbnail_b64: str = ""
|
self._thumbnail_b64: str = ""
|
||||||
self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config()
|
self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config()
|
||||||
@@ -1082,18 +924,6 @@ class KobraXBridge:
|
|||||||
elif kobra_state in ("free", "finished", "stoped", "canceled"):
|
elif kobra_state in ("free", "finished", "stoped", "canceled"):
|
||||||
self._camera_autostarted = False
|
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
|
# Job-History: Druckstart erkennen
|
||||||
if kobra_state == "printing" and not self._current_job_id:
|
if kobra_state == "printing" and not self._current_job_id:
|
||||||
filename = d.get("filename", self._state.get("filename", ""))
|
filename = d.get("filename", self._state.get("filename", ""))
|
||||||
@@ -1180,13 +1010,6 @@ class KobraXBridge:
|
|||||||
log.warning(f"Kamera-Autostart fehlgeschlagen: {e}")
|
log.warning(f"Kamera-Autostart fehlgeschlagen: {e}")
|
||||||
elif kobra_state in ("free", "finished", "stoped", "canceled"):
|
elif kobra_state in ("free", "finished", "stoped", "canceled"):
|
||||||
self._camera_autostarted = False
|
self._camera_autostarted = False
|
||||||
# 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 project:
|
||||||
if "filename" in project:
|
if "filename" in project:
|
||||||
self._state["filename"] = project["filename"]
|
self._state["filename"] = project["filename"]
|
||||||
@@ -1671,12 +1494,30 @@ class KobraXBridge:
|
|||||||
self._push_status_update()
|
self._push_status_update()
|
||||||
|
|
||||||
# OrcaSlicer filament preset IDs (MoonrakerPrinterAgent.cpp mapping)
|
# 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 = {
|
_TRAY_INFO_IDX = {
|
||||||
"PLA": "OGFL99", "PLA-CF": "OGFL98", "PLA SILK": "OGFL96",
|
# Anycubic-eigene Kobra-X-Profile
|
||||||
"PETG": "OGFG99", "PETG-CF": "OGFG98",
|
"PLA": "GFPLA",
|
||||||
"ABS": "OGFB99", "ASA": "OGFB98",
|
"PLA+": "GFPLA+",
|
||||||
"TPU": "OGFT99", "PA": "OGFP99", "PA-CF": "OGFP98",
|
"PLA SILK": "GFPLA Silk",
|
||||||
"PC": "OGFC99", "HIPS": "OGFH99", "PVA": "OGFV99",
|
"PETG": "GFPETG",
|
||||||
|
"ABS": "GFABS",
|
||||||
|
"ASA": "GFASA",
|
||||||
|
"TPU": "GFTPU 95A",
|
||||||
|
"PVA": "GFPVA",
|
||||||
|
# Kein Anycubic-Kobra-X-Profil → Library-Fallback
|
||||||
|
"PLA-CF": "OGFL98",
|
||||||
|
"PETG-CF": "OGFG98",
|
||||||
|
"PA": "OGFN99",
|
||||||
|
"PA-CF": "OGFN98",
|
||||||
|
"PC": "OGFC99",
|
||||||
|
"HIPS": "OGFS98",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_lane_data(self) -> dict:
|
def _build_lane_data(self) -> dict:
|
||||||
@@ -1733,9 +1574,13 @@ class KobraXBridge:
|
|||||||
fila_name = user_profile.get("name", "")
|
fila_name = user_profile.get("name", "")
|
||||||
tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99")
|
tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99")
|
||||||
else:
|
else:
|
||||||
vendor = ""
|
# Default: Library-Generic-Profil (siehe _default_filament_name) —
|
||||||
fila_name = ""
|
# ist mit allen Druckern kompatibel und garantiert sichtbar.
|
||||||
tray_info_idx = self._TRAY_INFO_IDX.get(material, "OGFL99")
|
# Der User wählt pro Slot bewusst eine konkrete Marke wenn er
|
||||||
|
# eine will; Default bleibt neutral.
|
||||||
|
fila_name = self._default_filament_name(material)
|
||||||
|
vendor = "Generic" if fila_name.startswith("Generic ") else ""
|
||||||
|
tray_info_idx = self._lookup_filament_id(vendor, fila_name) or self._TRAY_INFO_IDX.get(material, "OGFL99")
|
||||||
tray_array.append({
|
tray_array.append({
|
||||||
"id": str(slot_id),
|
"id": str(slot_id),
|
||||||
"tag_uid": "0000000000000000",
|
"tag_uid": "0000000000000000",
|
||||||
@@ -1743,17 +1588,20 @@ class KobraXBridge:
|
|||||||
"tray_type": material,
|
"tray_type": material,
|
||||||
"tray_color": color_hex,
|
"tray_color": color_hex,
|
||||||
"tray_sub_brands": vendor,
|
"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_vendor": vendor,
|
||||||
# Für den OrcaSlicer-Empfangs-Patch (Variante 2,
|
"filament_name": fila_name,
|
||||||
# MoonrakerPrinterAgent.cpp): `filament_id` direkt
|
"preset": fila_name,
|
||||||
# ü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:
|
else:
|
||||||
tray_array.append({
|
tray_array.append({
|
||||||
@@ -1872,6 +1720,7 @@ class KobraXBridge:
|
|||||||
"TPU": 220, "PA": 260, "PC": 270, "HIPS": 220}
|
"TPU": 220, "PA": 260, "PC": 270, "HIPS": 220}
|
||||||
num_gates = len(slots)
|
num_gates = len(slots)
|
||||||
gate_status, gate_material, gate_color, gate_temperature, gate_color_rgb = [], [], [], [], []
|
gate_status, gate_material, gate_color, gate_temperature, gate_color_rgb = [], [], [], [], []
|
||||||
|
gate_filament_name = []
|
||||||
for _global_index, slot in slots:
|
for _global_index, slot in slots:
|
||||||
occupied = slot.get("status") == 5
|
occupied = slot.get("status") == 5
|
||||||
gate_status.append(1 if occupied else 0)
|
gate_status.append(1 if occupied else 0)
|
||||||
@@ -1883,6 +1732,17 @@ class KobraXBridge:
|
|||||||
gate_color.append("{:02X}{:02X}{:02X}".format(*c[:3]) if occupied else "")
|
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_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_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)}
|
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)
|
active_gate = loaded_index_map.get(int(self._ams_loaded_slot), -1)
|
||||||
@@ -1894,13 +1754,36 @@ class KobraXBridge:
|
|||||||
"gate_color": gate_color,
|
"gate_color": gate_color,
|
||||||
"gate_temperature": gate_temperature,
|
"gate_temperature": gate_temperature,
|
||||||
"gate_color_rgb": gate_color_rgb,
|
"gate_color_rgb": gate_color_rgb,
|
||||||
"gate_filament_name": [""] * num_gates,
|
"gate_filament_name": gate_filament_name,
|
||||||
"gate_spool_id": [-1] * num_gates,
|
"gate_spool_id": [-1] * num_gates,
|
||||||
"ttg_map": list(range(num_gates)),
|
"ttg_map": list(range(num_gates)),
|
||||||
"tool": active_gate,
|
"tool": active_gate,
|
||||||
"gate": 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:
|
def _build_printer_objects(self) -> dict:
|
||||||
s = self._state
|
s = self._state
|
||||||
return {
|
return {
|
||||||
@@ -2123,6 +2006,128 @@ class KobraXBridge:
|
|||||||
profiles = [p for p in profiles if p.get("vendor", "") == vendor_filter]
|
profiles = [p for p in profiles if p.get("vendor", "") == vendor_filter]
|
||||||
return self._json_cors({"result": profiles})
|
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:
|
def _find_orca_filaments_json(self) -> str | None:
|
||||||
"""Findet die statische JSON-Datei. Liegt analog zu web/ unter _WEB_BASE/data/
|
"""Findet die statische JSON-Datei. Liegt analog zu web/ unter _WEB_BASE/data/
|
||||||
— in allen 3 Deployment-Modi:
|
— in allen 3 Deployment-Modi:
|
||||||
@@ -2199,21 +2204,45 @@ class KobraXBridge:
|
|||||||
"id": entry.get("id", "")})
|
"id": entry.get("id", "")})
|
||||||
|
|
||||||
def _load_orca_filaments(self) -> list[dict]:
|
def _load_orca_filaments(self) -> list[dict]:
|
||||||
"""Lädt orca_filaments.json einmalig in den Cache. Bei wiederholten
|
"""Lädt System- + User-Profile aus dem Cache. System-Profile kommen
|
||||||
Aufrufen wird die Liste aus dem RAM geliefert."""
|
aus bridge/data/orca_filaments.json (Image-embedded), User-Profile
|
||||||
|
aus <KX_DATA_DIR>/orca_filaments.user.json (Volume-persistent —
|
||||||
|
überlebt Image-Updates). User-Profile bekommen ein `is_user: True`-
|
||||||
|
Flag damit das Frontend sie markieren kann."""
|
||||||
if getattr(self, "_orca_filaments_cache", None) is not None:
|
if getattr(self, "_orca_filaments_cache", None) is not None:
|
||||||
return self._orca_filaments_cache
|
return self._orca_filaments_cache
|
||||||
self._orca_filaments_cache = []
|
merged: list[dict] = []
|
||||||
data_path = self._find_orca_filaments_json()
|
# System
|
||||||
if not data_path or not os.path.isfile(data_path):
|
sys_path = self._find_orca_filaments_json()
|
||||||
return self._orca_filaments_cache
|
if sys_path and os.path.isfile(sys_path):
|
||||||
try:
|
try:
|
||||||
with open(data_path, encoding="utf-8") as f:
|
with open(sys_path, encoding="utf-8") as f:
|
||||||
self._orca_filaments_cache = json.load(f) or []
|
merged.extend(json.load(f) or [])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"orca_filaments.json read error: {e}")
|
log.warning(f"orca_filaments.json read error: {e}")
|
||||||
|
# User
|
||||||
|
usr_path = self._orca_filaments_user_path()
|
||||||
|
if usr_path and os.path.isfile(usr_path):
|
||||||
|
try:
|
||||||
|
with open(usr_path, encoding="utf-8") as f:
|
||||||
|
for p in (json.load(f) or []):
|
||||||
|
p["is_user"] = True
|
||||||
|
merged.append(p)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"orca_filaments.user.json read error: {e}")
|
||||||
|
self._orca_filaments_cache = merged
|
||||||
return self._orca_filaments_cache
|
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:
|
def _lookup_filament_id(self, vendor: str, name: str) -> str:
|
||||||
"""Sucht in orca_filaments.json die filament_id zu einem (vendor,name)-
|
"""Sucht in orca_filaments.json die filament_id zu einem (vendor,name)-
|
||||||
Tupel. Liefert '' wenn nicht gefunden."""
|
Tupel. Liefert '' wenn nicht gefunden."""
|
||||||
@@ -2374,9 +2403,7 @@ class KobraXBridge:
|
|||||||
ams_box_mapping = self._build_auto_ams_box_mapping()
|
ams_box_mapping = self._build_auto_ams_box_mapping()
|
||||||
|
|
||||||
use_ams = len(ams_box_mapping) > 0
|
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"]
|
filename = gcode_file["filename"]
|
||||||
file_path = gcode_file["path"]
|
file_path = gcode_file["path"]
|
||||||
|
|
||||||
@@ -2398,11 +2425,11 @@ class KobraXBridge:
|
|||||||
},
|
},
|
||||||
"task_settings": {
|
"task_settings": {
|
||||||
"auto_leveling": auto_leveling,
|
"auto_leveling": auto_leveling,
|
||||||
"vibration_compensation": vibration_compensation,
|
"vibration_compensation": 0,
|
||||||
"flow_calibration": flow_calibration,
|
"flow_calibration": 0,
|
||||||
"dry_mode": 0,
|
"dry_mode": 0,
|
||||||
"ai_settings": {"status": 0, "count": 0, "type": 1},
|
"ai_settings": {"status": 0, "count": 0, "type": 1},
|
||||||
"timelapse": {"status": getattr(self._args, "timelapse_printer", 0), "count": 0, "type": 64},
|
"timelapse": {"status": 0, "count": 0, "type": 64},
|
||||||
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
|
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
|
||||||
"model_objects_skip_parts": excluded_objects,
|
"model_objects_skip_parts": excluded_objects,
|
||||||
},
|
},
|
||||||
@@ -2798,9 +2825,7 @@ class KobraXBridge:
|
|||||||
use_ams = len(loaded) > 0
|
use_ams = len(loaded) > 0
|
||||||
ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded)
|
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]}")
|
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 = {
|
payload = {
|
||||||
"taskid": "-1",
|
"taskid": "-1",
|
||||||
"url": url,
|
"url": url,
|
||||||
@@ -2816,11 +2841,11 @@ class KobraXBridge:
|
|||||||
},
|
},
|
||||||
"task_settings": {
|
"task_settings": {
|
||||||
"auto_leveling": auto_leveling,
|
"auto_leveling": auto_leveling,
|
||||||
"vibration_compensation": vibration_compensation,
|
"vibration_compensation": 0,
|
||||||
"flow_calibration": flow_calibration,
|
"flow_calibration": 0,
|
||||||
"dry_mode": 0,
|
"dry_mode": 0,
|
||||||
"ai_settings": {"status": 0, "count": 0, "type": 1},
|
"ai_settings": {"status": 0, "count": 0, "type": 1},
|
||||||
"timelapse": {"status": getattr(self._args, "timelapse_printer", 0), "count": 0, "type": 64},
|
"timelapse": {"status": 0, "count": 0, "type": 64},
|
||||||
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
|
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
|
||||||
"model_objects_skip_parts": [],
|
"model_objects_skip_parts": [],
|
||||||
},
|
},
|
||||||
@@ -2888,9 +2913,7 @@ class KobraXBridge:
|
|||||||
ams_box_mapping = self._build_auto_ams_box_mapping()
|
ams_box_mapping = self._build_auto_ams_box_mapping()
|
||||||
|
|
||||||
use_ams = len(ams_box_mapping) > 0
|
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", "")
|
url = self._state.get("last_upload_url", "")
|
||||||
filesize = self._state.get("last_upload_size", 0)
|
filesize = self._state.get("last_upload_size", 0)
|
||||||
md5 = self._state.get("last_upload_md5", "")
|
md5 = self._state.get("last_upload_md5", "")
|
||||||
@@ -2910,11 +2933,11 @@ class KobraXBridge:
|
|||||||
},
|
},
|
||||||
"task_settings": {
|
"task_settings": {
|
||||||
"auto_leveling": auto_leveling,
|
"auto_leveling": auto_leveling,
|
||||||
"vibration_compensation": vibration_compensation,
|
"vibration_compensation": 0,
|
||||||
"flow_calibration": flow_calibration,
|
"flow_calibration": 0,
|
||||||
"dry_mode": 0,
|
"dry_mode": 0,
|
||||||
"ai_settings": {"status": 0, "count": 0, "type": 0},
|
"ai_settings": {"status": 0, "count": 0, "type": 0},
|
||||||
"timelapse": {"status": getattr(self._args, "timelapse_printer", 0), "count": 0, "type": 0},
|
"timelapse": {"status": 0, "count": 0, "type": 0},
|
||||||
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
|
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
|
||||||
"model_objects_skip_parts": excluded_objects,
|
"model_objects_skip_parts": excluded_objects,
|
||||||
},
|
},
|
||||||
@@ -3686,97 +3709,6 @@ class KobraXBridge:
|
|||||||
self._ams_loaded_slot = global_loaded
|
self._ams_loaded_slot = global_loaded
|
||||||
return self._ams_slots
|
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 ────────────────────────────────────────────────────────────
|
# ─── Settings ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _find_config_path(self) -> pathlib.Path:
|
def _find_config_path(self) -> pathlib.Path:
|
||||||
@@ -3800,15 +3732,10 @@ class KobraXBridge:
|
|||||||
"password": self._args.password,
|
"password": self._args.password,
|
||||||
"mode_id": self._args.mode_id,
|
"mode_id": self._args.mode_id,
|
||||||
"device_id": self._args.device_id,
|
"device_id": self._args.device_id,
|
||||||
"default_ams_slot": getattr(self._args, "default_ams_slot", "auto"),
|
"default_ams_slot": getattr(self._args, "default_ams_slot", "auto"),
|
||||||
"auto_leveling": getattr(self._args, "auto_leveling", 1),
|
"auto_leveling": getattr(self._args, "auto_leveling", 1),
|
||||||
"vibration_compensation": getattr(self._args, "vibration_compensation", 0),
|
"camera_on_print": getattr(self._args, "camera_on_print", 0),
|
||||||
"flow_calibration": getattr(self._args, "flow_calibration", 0),
|
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
|
||||||
"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,
|
"ace_dry_presets": self._ace_dry_presets,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3835,15 +3762,10 @@ class KobraXBridge:
|
|||||||
cfg.set("connection", "password", str(data.get("password", self._args.password or "")))
|
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", "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("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", "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", "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", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0))))))
|
||||||
cfg.set("print", "flow_calibration", str(int(bool(data.get("flow_calibration", getattr(self._args, "flow_calibration", 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", "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"):
|
if not cfg.has_option("bridge", "poll_interval"):
|
||||||
cfg.set("bridge", "poll_interval", "3")
|
cfg.set("bridge", "poll_interval", "3")
|
||||||
printer_name = str(data.get("printer_name", "")).strip()
|
printer_name = str(data.get("printer_name", "")).strip()
|
||||||
@@ -4051,7 +3973,9 @@ class KobraXBridge:
|
|||||||
GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag"
|
GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag"
|
||||||
|
|
||||||
def _read_version(self) -> str:
|
def _read_version(self) -> str:
|
||||||
for base in (pathlib.Path(_BASE), pathlib.Path(_BASE).parent):
|
# PyInstaller-Onefile entpackt VERSION (per kx-bridge.spec datas) nach
|
||||||
|
# sys._MEIPASS — daher _WEB_BASE statt _BASE benutzen.
|
||||||
|
for base in (pathlib.Path(_WEB_BASE), pathlib.Path(_BASE), pathlib.Path(_BASE).parent):
|
||||||
p = base / "VERSION"
|
p = base / "VERSION"
|
||||||
if p.is_file():
|
if p.is_file():
|
||||||
return p.read_text(encoding="utf-8").strip()
|
return p.read_text(encoding="utf-8").strip()
|
||||||
@@ -4723,11 +4647,13 @@ def build_app(bridge: KobraXBridge) -> web.Application:
|
|||||||
r.add_get("/kx/filament/slots", bridge.handle_kx_filament_slots)
|
r.add_get("/kx/filament/slots", bridge.handle_kx_filament_slots)
|
||||||
r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles)
|
r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles)
|
||||||
r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile)
|
r.add_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/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/ui/{name:.*}", bridge.handle_kx_ui_asset)
|
||||||
r.add_get("/kx/files/{id}/objects", bridge.handle_kx_file_objects)
|
r.add_get("/kx/files/{id}/objects", bridge.handle_kx_file_objects)
|
||||||
r.add_post("/kx/skip", bridge.handle_kx_skip)
|
r.add_post("/kx/skip", bridge.handle_kx_skip)
|
||||||
@@ -4890,14 +4816,9 @@ def main():
|
|||||||
parser.add_argument("--mode-id", default=env_loader.MODE_ID)
|
parser.add_argument("--mode-id", default=env_loader.MODE_ID)
|
||||||
parser.add_argument("--device-id", default=env_loader.DEVICE_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("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT)
|
||||||
parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING)
|
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("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT)
|
||||||
parser.add_argument("--flow-calibration", type=int, default=env_loader.FLOW_CALIBRATION)
|
parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING)
|
||||||
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",
|
parser.add_argument("--host", default="0.0.0.0",
|
||||||
help="Bind-Adresse für den Bridge-Server")
|
help="Bind-Adresse für den Bridge-Server")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
# ein → zur Laufzeit über sys._MEIPASS lesbar (_WEB_BASE in der Bridge).
|
# ein → zur Laufzeit über sys._MEIPASS lesbar (_WEB_BASE in der Bridge).
|
||||||
from PyInstaller.utils.hooks import collect_all
|
from PyInstaller.utils.hooks import collect_all
|
||||||
|
|
||||||
datas = [("web", "web"), ("data", "static")] # bridge/data/ → static/ im _MEIPASS
|
datas = [("web", "web"), ("data", "static"), ("VERSION", ".")] # bridge/data/ → static/ im _MEIPASS
|
||||||
binaries = []
|
binaries = []
|
||||||
hiddenimports = []
|
hiddenimports = []
|
||||||
|
|
||||||
|
|||||||
147
orca_filaments.py
Normal file
147
orca_filaments.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""OrcaSlicer Filament-Profil Parser.
|
||||||
|
|
||||||
|
Geteilt zwischen dem Generator (tools/gen_orca_filament_list.py) und dem
|
||||||
|
Custom-Profile-Import-Endpoint (bridge/kobrax_moonraker_bridge.py).
|
||||||
|
|
||||||
|
Liest Orca-Filament-JSON-Dateien (System- oder User-Profile) und gibt
|
||||||
|
sie als normalisierte Liste mit (id, name, vendor, type, color) zurück.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def first_str(value, default: str = "") -> str:
|
||||||
|
"""Orca-Profile speichern manche Felder als ['wert']. Liefert erstes
|
||||||
|
Element als String."""
|
||||||
|
if isinstance(value, list):
|
||||||
|
return str(value[0]) if value else default
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def clean_name(raw: str) -> str:
|
||||||
|
"""Strippt printer-/varianten-spezifische Suffixe:
|
||||||
|
'PolyTerra PLA @base' → 'PolyTerra PLA'
|
||||||
|
'Anycubic PLA @Anycubic Kobra X 0.4 nozzle' → 'Anycubic PLA'
|
||||||
|
'Anker Generic PLA 0.4 nozzle' → 'Anker Generic PLA'
|
||||||
|
"""
|
||||||
|
name = re.sub(r"\s*@.*$", "", raw).strip()
|
||||||
|
name = re.sub(r"\s+\d+(\.\d+)?\s*nozzle\s*$", "", name, flags=re.IGNORECASE).strip()
|
||||||
|
return name or raw
|
||||||
|
|
||||||
|
|
||||||
|
def parse_profile(data: dict, by_name: dict | None = None,
|
||||||
|
path_vendor: str | None = None,
|
||||||
|
source_path: str = "",
|
||||||
|
system_index: list | None = None) -> dict | None:
|
||||||
|
"""Parsed ein einzelnes Orca-Filament-Profil zum Bridge-Schema.
|
||||||
|
|
||||||
|
`by_name` ist optional ein {name: [profile, …]}-Index für Inherits-Resolve
|
||||||
|
aus dem rohen Source-Tree (Generator). Bei Single-File-Import (User-Datei
|
||||||
|
aus OrcaSlicer-User-Dir) reichen wir stattdessen `system_index` rein —
|
||||||
|
die fertige System-Profile-Liste aus orca_filaments.json. Damit können
|
||||||
|
wir filament_id/vendor/type/color über die `inherits`-Kette aus dem
|
||||||
|
System-Parent ableiten, auch wenn das User-Profil diese Felder nicht
|
||||||
|
selbst setzt (typisch: User-Override-Profile haben nur Tweaks).
|
||||||
|
|
||||||
|
Liefert {id, name, vendor, type, color} oder None wenn das Profil
|
||||||
|
keine filament_id hat (z.B. abstrakte @base-Templates).
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
# User-Profile aus dem OrcaSlicer-User-Dir setzen oft KEIN "type"-Feld —
|
||||||
|
# das kommt vom System-Parent. Wir akzeptieren das wenn entweder "type"
|
||||||
|
# explizit "filament" ist ODER ein "inherits" auf ein anderes Profil zeigt.
|
||||||
|
if data.get("type") not in (None, "filament") and not data.get("inherits"):
|
||||||
|
return None
|
||||||
|
if data.get("type") == "filament" and data.get("inherits") is None and not data.get("filament_id"):
|
||||||
|
# type=filament aber kein parent + keine ID → wertloses Stub
|
||||||
|
return None
|
||||||
|
inst = data.get("instantiation", "true")
|
||||||
|
if isinstance(inst, str) and inst.lower() == "false":
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Build system-name-Index für den fallback-Lookup wenn system_index gesetzt.
|
||||||
|
sys_by_name: dict[str, dict] = {}
|
||||||
|
if system_index:
|
||||||
|
for p in system_index:
|
||||||
|
if isinstance(p, dict) and p.get("name"):
|
||||||
|
sys_by_name[p["name"]] = p
|
||||||
|
|
||||||
|
def _resolve(key: str, depth: int = 5):
|
||||||
|
cur_list = [data]
|
||||||
|
for _ in range(depth):
|
||||||
|
for cur in cur_list:
|
||||||
|
v = cur.get(key)
|
||||||
|
if v not in ("", [], None, [""]) and v is not None:
|
||||||
|
return v
|
||||||
|
# Erst raw-Inherits via by_name (Generator-Pfad)
|
||||||
|
if by_name:
|
||||||
|
next_list: list[dict] = []
|
||||||
|
for cur in cur_list:
|
||||||
|
parent_name = cur.get("inherits")
|
||||||
|
if parent_name and parent_name in by_name:
|
||||||
|
next_list.extend(by_name[parent_name])
|
||||||
|
if next_list:
|
||||||
|
cur_list = next_list
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _resolve_via_system_index(key: str):
|
||||||
|
"""Inherits-Kette über system_index (clean_name-Match)."""
|
||||||
|
parent_raw = data.get("inherits")
|
||||||
|
if not parent_raw or not sys_by_name:
|
||||||
|
return None
|
||||||
|
parent_clean = clean_name(parent_raw)
|
||||||
|
sys_p = sys_by_name.get(parent_clean)
|
||||||
|
if not sys_p:
|
||||||
|
return None
|
||||||
|
# System-JSON benutzt schon das normalisierte Schema
|
||||||
|
mapping = {
|
||||||
|
"filament_id": "id",
|
||||||
|
"filament_vendor": "vendor",
|
||||||
|
"filament_type": "type",
|
||||||
|
"default_filament_colour": "color",
|
||||||
|
}
|
||||||
|
return sys_p.get(mapping.get(key, key))
|
||||||
|
|
||||||
|
def _resolve_full(key: str):
|
||||||
|
v = _resolve(key)
|
||||||
|
if v not in ("", [], None, [""]) and v is not None:
|
||||||
|
return v
|
||||||
|
return _resolve_via_system_index(key)
|
||||||
|
|
||||||
|
fid = _resolve_full("filament_id")
|
||||||
|
if not fid or not isinstance(fid, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
name_raw = data.get("name", fid)
|
||||||
|
name = clean_name(name_raw)
|
||||||
|
vendor = first_str(_resolve_full("filament_vendor")) or (path_vendor or "Generic")
|
||||||
|
ftype = first_str(_resolve_full("filament_type"), "")
|
||||||
|
color = first_str(_resolve_full("default_filament_colour"), "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": fid,
|
||||||
|
"name": name,
|
||||||
|
"vendor": vendor,
|
||||||
|
"type": ftype,
|
||||||
|
"color": color,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_profile_bytes(blob: bytes, source_name: str = "",
|
||||||
|
system_index: list | None = None) -> dict | None:
|
||||||
|
"""Liest ein einzelnes Profil aus JSON-Bytes. Für File-Upload-Pfad.
|
||||||
|
`system_index` ist optional die fertige Liste aus orca_filaments.json —
|
||||||
|
wird für die Inherits-Resolve von User-Profilen genutzt die das volle
|
||||||
|
Schema vom System-Parent erben."""
|
||||||
|
try:
|
||||||
|
data = json.loads(blob.decode("utf-8", errors="replace"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return parse_profile(data, source_path=source_name, system_index=system_index)
|
||||||
@@ -312,6 +312,17 @@ function applyLang(){
|
|||||||
setText('modal-sec-print',T.settings_print);
|
setText('modal-sec-print',T.settings_print);
|
||||||
setText('modal-sec-poll',T.settings_poll);
|
setText('modal-sec-poll',T.settings_poll);
|
||||||
setText('modal-sec-version',T.settings_version);
|
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('btn-save-settings',T.settings_save);
|
||||||
setText('lbl-printer-name',T.settings_printer_name);
|
setText('lbl-printer-name',T.settings_printer_name);
|
||||||
setText('lbl-printer-ip',T.settings_printer_ip);
|
setText('lbl-printer-ip',T.settings_printer_ip);
|
||||||
@@ -323,18 +334,8 @@ function applyLang(){
|
|||||||
setText('lbl-default-slot',T.settings_default_slot);
|
setText('lbl-default-slot',T.settings_default_slot);
|
||||||
setText('opt-slot-auto',T.settings_slot_auto);
|
setText('opt-slot-auto',T.settings_slot_auto);
|
||||||
setText('lbl-auto-leveling',T.settings_auto_leveling);
|
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-camera-on-print',T.settings_camera_on_print);
|
||||||
setText('lbl-web-upload-warning',T.settings_web_upload_warning);
|
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-check',T.update_check);
|
||||||
setText('lbl-update-apply',T.update_apply);
|
setText('lbl-update-apply',T.update_apply);
|
||||||
@@ -900,14 +901,8 @@ function openSettings(){
|
|||||||
document.getElementById('s-mode-id').value=d.mode_id||'';
|
document.getElementById('s-mode-id').value=d.mode_id||'';
|
||||||
document.getElementById('s-default-slot').value=d.default_ams_slot||'auto';
|
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);
|
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 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 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';
|
var v=localStorage.getItem('pollInterval')||'2000';
|
||||||
document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')});
|
document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')});
|
||||||
@@ -919,95 +914,114 @@ function openSettings(){
|
|||||||
var cl=document.getElementById('update-changelog');if(cl)cl.style.display='none';
|
var cl=document.getElementById('update-changelog');if(cl)cl.style.display='none';
|
||||||
_updateTag='';_updateUrl='';
|
_updateTag='';_updateUrl='';
|
||||||
document.getElementById('settings-modal').classList.add('open');
|
document.getElementById('settings-modal').classList.add('open');
|
||||||
|
// Custom-Profile-Liste laden (Issue #41 — Verwaltung von User-importierten
|
||||||
|
// OrcaSlicer-Filament-Profilen)
|
||||||
|
refreshUserProfileList();
|
||||||
}
|
}
|
||||||
function closeSettings(){
|
function closeSettings(){
|
||||||
document.getElementById('settings-modal').classList.remove('open');
|
document.getElementById('settings-modal').classList.remove('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Layout ──
|
// ── Custom Filament Profile Import (Issue #41) ──
|
||||||
function applyLayout(mode){
|
function refreshUserProfileList(){
|
||||||
var panel=document.getElementById('panel-dashboard');
|
var listEl=document.getElementById('orca-profiles-list');
|
||||||
if(!panel)return;
|
if(!listEl) return;
|
||||||
var fullWidth=['d-cam-card','d-progress-card','d-temps-card','d-ams-card'];
|
fetch(_apiUrl('/kx/filament/profiles/user')).then(function(r){return r.json();}).then(function(d){
|
||||||
if(mode==='2col'){
|
var profs=(d && d.result)||[];
|
||||||
panel.classList.add('layout-2col');
|
if(!profs.length){
|
||||||
fullWidth.forEach(function(id){var el=document.getElementById(id);if(el)el.style.gridColumn='auto';});
|
listEl.innerHTML='<i style="color:var(--txt2)">'+(tr('orca_profile_user_empty')||'– keine –')+'</i>';
|
||||||
}else{
|
return;
|
||||||
panel.classList.remove('layout-2col');
|
}
|
||||||
fullWidth.forEach(function(id){var el=document.getElementById(id);if(el)el.style.gridColumn='1/-1';});
|
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)';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
localStorage.setItem('layout',mode);
|
_one(0);
|
||||||
}
|
|
||||||
|
|
||||||
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 • '+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 ──
|
// ── AMS Slot Edit ──
|
||||||
var _slotEditIndex=-1;
|
var _slotEditIndex=-1;
|
||||||
var _slotEditLoaded=false;
|
var _slotEditLoaded=false;
|
||||||
var _MAT_PRESETS=['PLA','PETG','ABS','ASA','TPU','PA','PC','HIPS'];
|
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(){
|
function updateSlotEditFeedButton(){
|
||||||
var btn=document.getElementById('btn-slot-edit-feed');
|
var btn=document.getElementById('btn-slot-edit-feed');
|
||||||
if(!btn)return;
|
if(!btn)return;
|
||||||
@@ -1045,21 +1059,31 @@ function _fillSlotProfileDropdown(material, currentVendor, currentName){
|
|||||||
return matU==='' || pt===matU || pt.startsWith(matU+'-') || pt.startsWith(matU+' ');
|
return matU==='' || pt===matU || pt.startsWith(matU+'-') || pt.startsWith(matU+' ');
|
||||||
});
|
});
|
||||||
sel.innerHTML='<option value="">'+tr('slot_edit_profile_default')+'</option>';
|
sel.innerHTML='<option value="">'+tr('slot_edit_profile_default')+'</option>';
|
||||||
// Gruppieren nach Vendor
|
// 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
|
||||||
var byVendor={};
|
var byVendor={};
|
||||||
matched.forEach(function(p){ (byVendor[p.vendor]=byVendor[p.vendor]||[]).push(p); });
|
systemProfs.forEach(function(p){ (byVendor[p.vendor]=byVendor[p.vendor]||[]).push(p); });
|
||||||
Object.keys(byVendor).sort().forEach(function(v){
|
Object.keys(byVendor).sort().forEach(function(v){
|
||||||
var g=document.createElement('optgroup'); g.label=v;
|
var g=document.createElement('optgroup'); g.label=v;
|
||||||
byVendor[v].forEach(function(p){
|
byVendor[v].forEach(function(p){ _appendOption(g, p); });
|
||||||
var o=document.createElement('option');
|
|
||||||
o.value=_profileKey(p.vendor, p.name);
|
|
||||||
o.dataset.vendor=p.vendor;
|
|
||||||
o.dataset.name=p.name;
|
|
||||||
o.dataset.id=p.id || '';
|
|
||||||
o.textContent=p.name;
|
|
||||||
if(o.value===wantKey) o.selected=true;
|
|
||||||
g.appendChild(o);
|
|
||||||
});
|
|
||||||
sel.appendChild(g);
|
sel.appendChild(g);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1208,6 +1232,10 @@ function saveSlotEdit(){
|
|||||||
closeSlotEdit();
|
closeSlotEdit();
|
||||||
var profSuffix=newProfName?(' ['+newProfVendor+' '+newProfName+']'):'';
|
var profSuffix=newProfName?(' ['+newProfVendor+' '+newProfName+']'):'';
|
||||||
clog(tr('slot_edit_ok')+' '+(slotIdx+1)+': '+mat+' '+hex+profSuffix,'msg-ok');
|
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();
|
if(typeof poll==='function') poll();
|
||||||
})
|
})
|
||||||
.catch(function(e){clog('Fehler: '+e,'msg-err');});
|
.catch(function(e){clog('Fehler: '+e,'msg-err');});
|
||||||
@@ -1241,16 +1269,10 @@ function saveSettings(){
|
|||||||
device_id: document.getElementById('s-device-id').value,
|
device_id: document.getElementById('s-device-id').value,
|
||||||
mode_id: document.getElementById('s-mode-id').value,
|
mode_id: document.getElementById('s-mode-id').value,
|
||||||
default_ams_slot: document.getElementById('s-default-slot').value,
|
default_ams_slot: document.getElementById('s-default-slot').value,
|
||||||
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
|
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
|
||||||
vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0,
|
camera_on_print: (document.getElementById('s-camera-on-print')||{}).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,
|
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(){
|
}).then(function(){
|
||||||
applyLayout((document.getElementById('s-layout')||{value:'1col'}).value);
|
|
||||||
btn.textContent=T.update_restarting;
|
btn.textContent=T.update_restarting;
|
||||||
setTimeout(function(){
|
setTimeout(function(){
|
||||||
btn.disabled=false;
|
btn.disabled=false;
|
||||||
@@ -1329,7 +1351,6 @@ var pollTimer;
|
|||||||
});
|
});
|
||||||
}).catch(function(){});
|
}).catch(function(){});
|
||||||
poll();pollTimer=setInterval(poll,ms);
|
poll();pollTimer=setInterval(poll,ms);
|
||||||
applyLayout(localStorage.getItem('layout')||'1col');
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ── Print actions ──
|
// ── Print actions ──
|
||||||
@@ -2087,20 +2108,6 @@ function _normalizeMaterialKey(material){
|
|||||||
var key=(material||'').toUpperCase().replace(/[^A-Z0-9+]/g,'');
|
var key=(material||'').toUpperCase().replace(/[^A-Z0-9+]/g,'');
|
||||||
// Orca often uses PLA for PLA+, while AMS may report PLA+.
|
// Orca often uses PLA for PLA+, while AMS may report PLA+.
|
||||||
if(key==='PLA+'||key==='PLAPLUS') return '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;
|
return key;
|
||||||
}
|
}
|
||||||
function _materialsCompatible(a,b){
|
function _materialsCompatible(a,b){
|
||||||
|
|||||||
@@ -102,14 +102,6 @@
|
|||||||
<input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0">
|
<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>
|
<label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label>
|
||||||
</div>
|
</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">
|
<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">
|
<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>
|
<label id="lbl-camera-on-print" style="margin:0;cursor:pointer" for="s-camera-on-print">Kamera bei Druckstart einschalten</label>
|
||||||
@@ -118,29 +110,6 @@
|
|||||||
<input type="checkbox" id="s-web-upload-warning" style="width:auto;margin:0">
|
<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>
|
<label id="lbl-web-upload-warning" style="margin:0;cursor:pointer" for="s-web-upload-warning">Warnung bei Web-Upload-Druck anzeigen</label>
|
||||||
</div>
|
</div>
|
||||||
<div 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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -152,6 +121,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div class="modal-section" id="modal-sec-version">Version</div>
|
<div class="modal-section" id="modal-sec-version">Version</div>
|
||||||
<div class="update-row">
|
<div class="update-row">
|
||||||
@@ -201,12 +183,42 @@
|
|||||||
<option value="" id="slot-profile-default-opt"></option>
|
<option value="" id="slot-profile-default-opt"></option>
|
||||||
</select>
|
</select>
|
||||||
<div style="font-size:11px;color:var(--txt2);margin-top:4px" id="slot-profile-hint"></div>
|
<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>
|
</div>
|
||||||
<button class="btn" id="btn-slot-edit-feed" style="width:100%;margin-bottom:8px" onclick="slotEditFeed()"></button>
|
<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>
|
<button class="modal-save" id="btn-slot-edit-save" onclick="saveSlotEdit()"></button>
|
||||||
</div>
|
</div>
|
||||||
</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/<id>/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">
|
<div class="layout">
|
||||||
<nav class="sidebar">
|
<nav class="sidebar">
|
||||||
<button class="nav-btn active" onclick="showPanel('dashboard')" id="nb-dashboard">
|
<button class="nav-btn active" onclick="showPanel('dashboard')" id="nb-dashboard">
|
||||||
@@ -224,7 +236,7 @@
|
|||||||
<div class="panel active" id="panel-dashboard">
|
<div class="panel active" id="panel-dashboard">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<!-- Kamera -->
|
<!-- Kamera -->
|
||||||
<div class="card" id="d-cam-card" style="grid-column:1/-1">
|
<div class="card" style="grid-column:1/-1">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
|
<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 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">
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
@@ -248,7 +260,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fortschritt -->
|
<!-- Fortschritt -->
|
||||||
<div class="card" id="d-progress-card" style="grid-column:1/-1">
|
<div class="card" style="grid-column:1/-1">
|
||||||
<div class="card-title"><span>◉</span> <span id="d-card-progress">Fortschritt</span></div>
|
<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">
|
<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>
|
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
|
||||||
@@ -282,7 +294,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Temperatursteuerung + Verlauf -->
|
<!-- Temperatursteuerung + Verlauf -->
|
||||||
<div class="card" id="d-temps-card" style="grid-column:1/-1">
|
<div class="card" style="grid-column:1/-1">
|
||||||
<div class="card-title"><span>⊙</span> <span id="d-card-temps">Temperaturen</span></div>
|
<div class="card-title"><span>⊙</span> <span id="d-card-temps">Temperaturen</span></div>
|
||||||
<div class="temp-card-inner">
|
<div class="temp-card-inner">
|
||||||
<div class="temp-block">
|
<div class="temp-block">
|
||||||
@@ -432,15 +444,8 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
<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>
|
<span id="store-panel-title">🗂 Datei-Browser</span>
|
||||||
<div style="display:flex;gap:6px;align-items:center">
|
<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>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Dateien-Bereich -->
|
|
||||||
<div id="store-files-section">
|
|
||||||
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
|
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
|
||||||
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
|
<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">
|
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">
|
||||||
@@ -471,12 +476,6 @@
|
|||||||
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
|
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
|
||||||
</div>
|
</div>
|
||||||
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap: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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -313,25 +313,3 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
|
|||||||
.modal-box{padding:16px;border-radius:10px}
|
.modal-box{padding:16px;border-radius:10px}
|
||||||
.poll-btns{gap:6px}
|
.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)}
|
|
||||||
|
|||||||
@@ -140,22 +140,8 @@
|
|||||||
"settings_default_slot": "Standard-Slot (Einfarbdruck)",
|
"settings_default_slot": "Standard-Slot (Einfarbdruck)",
|
||||||
"settings_slot_auto": "Auto (alle belegten Slots)",
|
"settings_slot_auto": "Auto (alle belegten Slots)",
|
||||||
"settings_auto_leveling": "Auto-Leveling vor Druck",
|
"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_camera_on_print": "Kamera bei Druckstart einschalten",
|
||||||
"settings_web_upload_warning": "Warnung bei Web-Upload-Druck anzeigen",
|
"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_check": "Auf Updates prüfen",
|
||||||
"update_checking": "Prüfe...",
|
"update_checking": "Prüfe...",
|
||||||
"update_available": "verfügbar",
|
"update_available": "verfügbar",
|
||||||
@@ -178,6 +164,19 @@
|
|||||||
"slot_edit_profile": "OrcaSlicer-Profil",
|
"slot_edit_profile": "OrcaSlicer-Profil",
|
||||||
"slot_edit_profile_hint": "Sendet beim OrcaSlicer-Sync die konkrete Marke statt nur „Generic\"",
|
"slot_edit_profile_hint": "Sendet beim OrcaSlicer-Sync die konkrete Marke statt nur „Generic\"",
|
||||||
"slot_edit_profile_default": "— Generic (Default) —",
|
"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/<id>/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_dir_all": "Alle",
|
||||||
"log_lvl_label": "Level:",
|
"log_lvl_label": "Level:",
|
||||||
"file_ready_btn": "▶ Druck starten",
|
"file_ready_btn": "▶ Druck starten",
|
||||||
|
|||||||
@@ -140,22 +140,8 @@
|
|||||||
"settings_default_slot": "Default Slot (single color)",
|
"settings_default_slot": "Default Slot (single color)",
|
||||||
"settings_slot_auto": "Auto (all loaded slots)",
|
"settings_slot_auto": "Auto (all loaded slots)",
|
||||||
"settings_auto_leveling": "Auto-Leveling before print",
|
"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_camera_on_print": "Turn camera on at print start",
|
||||||
"settings_web_upload_warning": "Show warning when printing web uploads",
|
"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_check": "Check for Updates",
|
||||||
"update_checking": "Checking...",
|
"update_checking": "Checking...",
|
||||||
"update_available": "available",
|
"update_available": "available",
|
||||||
@@ -178,6 +164,19 @@
|
|||||||
"slot_edit_profile": "OrcaSlicer profile",
|
"slot_edit_profile": "OrcaSlicer profile",
|
||||||
"slot_edit_profile_hint": "Sent on OrcaSlicer sync as the specific brand instead of just \"Generic\"",
|
"slot_edit_profile_hint": "Sent on OrcaSlicer sync as the specific brand instead of just \"Generic\"",
|
||||||
"slot_edit_profile_default": "— Generic (default) —",
|
"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/<id>/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_dir_all": "All",
|
||||||
"log_lvl_label": "Level:",
|
"log_lvl_label": "Level:",
|
||||||
"file_ready_btn": "▶ Start Print",
|
"file_ready_btn": "▶ Start Print",
|
||||||
|
|||||||
@@ -140,22 +140,8 @@
|
|||||||
"settings_default_slot": "Ranura predeterminada (un color)",
|
"settings_default_slot": "Ranura predeterminada (un color)",
|
||||||
"settings_slot_auto": "Auto (todos los slots cargados)",
|
"settings_slot_auto": "Auto (todos los slots cargados)",
|
||||||
"settings_auto_leveling": "Autonivelado antes de imprimir",
|
"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_camera_on_print": "Encender cámara al iniciar impresión",
|
||||||
"settings_web_upload_warning": "Mostrar advertencia al imprimir subidas web",
|
"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_check": "Buscar actualizaciones",
|
||||||
"update_checking": "Comprobando...",
|
"update_checking": "Comprobando...",
|
||||||
"update_available": "disponible",
|
"update_available": "disponible",
|
||||||
@@ -178,6 +164,19 @@
|
|||||||
"slot_edit_profile": "Perfil de OrcaSlicer",
|
"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_hint": "Envía al sincronizar con OrcaSlicer la marca concreta en lugar de solo \"Generic\"",
|
||||||
"slot_edit_profile_default": "— Genérico (Predeterminado) —",
|
"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/<id>/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_dir_all": "Todos",
|
||||||
"log_lvl_label": "Nivel:",
|
"log_lvl_label": "Nivel:",
|
||||||
"file_ready_btn": "▶ Iniciar impresión",
|
"file_ready_btn": "▶ Iniciar impresión",
|
||||||
|
|||||||
@@ -140,22 +140,8 @@
|
|||||||
"settings_default_slot": "默认槽位 (单色)",
|
"settings_default_slot": "默认槽位 (单色)",
|
||||||
"settings_slot_auto": "自动 (所有已装载槽位)",
|
"settings_slot_auto": "自动 (所有已装载槽位)",
|
||||||
"settings_auto_leveling": "打印前自动调平",
|
"settings_auto_leveling": "打印前自动调平",
|
||||||
"settings_vibration_compensation": "打印前共振补偿",
|
|
||||||
"settings_flow_calibration": "打印前流量校准",
|
|
||||||
"settings_camera_on_print": "打印开始时开启相机",
|
"settings_camera_on_print": "打印开始时开启相机",
|
||||||
"settings_web_upload_warning": "打印网页上传文件时显示警告",
|
"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_check": "检查更新",
|
||||||
"update_checking": "检查中...",
|
"update_checking": "检查中...",
|
||||||
"update_available": "可用",
|
"update_available": "可用",
|
||||||
@@ -178,6 +164,19 @@
|
|||||||
"slot_edit_profile": "OrcaSlicer 配置",
|
"slot_edit_profile": "OrcaSlicer 配置",
|
||||||
"slot_edit_profile_hint": "在 OrcaSlicer 同步时发送具体品牌,而不仅仅是“Generic”",
|
"slot_edit_profile_hint": "在 OrcaSlicer 同步时发送具体品牌,而不仅仅是“Generic”",
|
||||||
"slot_edit_profile_default": "— 通用 (默认) —",
|
"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/<id>/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_dir_all": "全部",
|
||||||
"log_lvl_label": "级别:",
|
"log_lvl_label": "级别:",
|
||||||
"file_ready_btn": "▶ 开始打印",
|
"file_ready_btn": "▶ 开始打印",
|
||||||
|
|||||||
Reference in New Issue
Block a user