Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76738e5961 | |||
| 9c82073540 | |||
| 031e34d8ea | |||
|
|
fc89dfffa5 | ||
| ac695ecf36 | |||
| 23b8a69065 | |||
| 22dc58258c | |||
| e4b4d091f3 |
128
CHANGELOG.de.md
128
CHANGELOG.de.md
@@ -1,5 +1,133 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [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 (z.B.
|
||||||
|
„Bert - PLA") werden korrekt erkannt — die Bridge resolved die
|
||||||
|
vererbten Felder aus dem System-Parent.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- **AMS-Sync landete hartnäckig auf „Generic PLA":** das Orca-
|
||||||
|
Datenmodell hat 68 duplikate `filament_id`-Werte (`OGFL99` allein
|
||||||
|
136 mal), und die Bridge wählte oft eine ID die für den Kobra X
|
||||||
|
nicht `is_compatible` war (z.B. `GFL92` aus dem Kobra-2-Profil →
|
||||||
|
Orca verwarf es). Generator priorisiert jetzt Kobra-X-Varianten und
|
||||||
|
filtert Phantom-Profile (Cross-Vendor-Overrides) raus —
|
||||||
|
`orca_filaments.json` von 1035 → 400 saubere Profile.
|
||||||
|
- **Slot ohne expliziten Override sendet jetzt `Generic <Typ>`** statt
|
||||||
|
einer impliziten Vendor-Annahme. Library-Generic-Profile haben
|
||||||
|
`compatible_printers: []` (= alle Drucker), sind also immer sichtbar
|
||||||
|
und matchen verlässlich.
|
||||||
|
- **Slot-Karte zeigt den Hersteller direkt nach dem Speichern** —
|
||||||
|
ohne Browser-Reload. `poll()` war async, das Re-Render kam erst
|
||||||
|
beim nächsten Tick.
|
||||||
|
- **ACE-Trockner-Toggle warf 502-Fehler obwohl der Trockner ein-/aus-
|
||||||
|
ging (PR #42 von @gangoke):** `setDry` jetzt fire-and-forget wie
|
||||||
|
`setAutoFeed`. Der Drucker antwortet auf diesem Push-Topic mit
|
||||||
|
`code: 0` statt `code: 200`, das hat die Bridge fälschlich als
|
||||||
|
Fehler interpretiert.
|
||||||
|
|
||||||
|
### Datenmodell / API
|
||||||
|
- `orca_filaments.json` regeneriert: nur echte Vendor-Profile und
|
||||||
|
OrcaFilamentLibrary-Profile bleiben drin. Bambu/Polymaker/SUNLU-
|
||||||
|
Library-Profile dabei, Qidi-Cross-Bundles raus.
|
||||||
|
- Neue Endpoints: `POST /kx/filament/profiles/user` (ZIP/JSON-Upload),
|
||||||
|
`GET /kx/filament/profiles/user` (Liste der User-Imports),
|
||||||
|
`DELETE /kx/filament/profiles/user[?vendor=…&name=…]`. Persistenz
|
||||||
|
in `<KX_DATA_DIR>/orca_filaments.user.json` (Volume — überlebt
|
||||||
|
Image-Updates).
|
||||||
|
|
||||||
|
### Build
|
||||||
|
- Neues Modul `bridge/orca_filaments.py` (gemeinsame Parser-Helfer
|
||||||
|
für Generator und Import-Endpoint).
|
||||||
|
- Dockerfile + release.sh um `orca_filaments.py` erweitert.
|
||||||
|
|
||||||
|
## [0.9.18] – 2026-05-31
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
- **🎉 Filament-Material und -Farbe pro AMS-Slot aus der Bridge an den
|
||||||
|
Drucker senden:** Im Slot-Edit-Dialog gewählte Werte gehen jetzt
|
||||||
|
tatsächlich an den Drucker und werden persistent übernommen — am
|
||||||
|
Drucker-Display siehst du sofort dieselbe Belegung wie in der Bridge-UI.
|
||||||
|
In 0.9.17 wurde der Befehl über das falsche MQTT-Topic
|
||||||
|
(`slicer/printer/…`) gesendet, der Drucker hat ihn stillschweigend
|
||||||
|
ignoriert. Jetzt geht er über `web/printer/…` wie der Anycubic
|
||||||
|
Slicer Next es macht (verifiziert via Live-MQTT-Sniff +
|
||||||
|
Workbench-Vue-Source). **Achtung:** der Drucker muss im Idle-Zustand
|
||||||
|
sein, und leere Slots lassen sich nicht beschriften.
|
||||||
|
- **Mehrsprachiges UI – spanische Übersetzung von Muttersprachler
|
||||||
|
überarbeitet (PR #40 von @pezfisk):** fehlende Akzente
|
||||||
|
(impresión, cámara, después, animación, …), Begriffe vereinheitlicht
|
||||||
|
(Pause → Pausa, Start → Iniciar, Layer → Capa). Plus neues
|
||||||
|
`README.es.md` und Cross-Links in den drei READMEs.
|
||||||
|
- **Z-Höhen-Anzeige in Obico** funktioniert jetzt. Der Drucker liefert
|
||||||
|
keine echte Z-Position via MQTT (per Live-Sniff bestätigt), die Bridge
|
||||||
|
schätzt sie aus `curr_layer × layer_height + first_layer_height`.
|
||||||
|
Layer-Heights kommen aus dem GCode-Header beim Upload, persistiert
|
||||||
|
im GCode-Store; Fallback aus dem OrcaSlicer-Default-Filename
|
||||||
|
(`…_0.2_…gcode`). `/server/files/metadata` liefert zusätzlich
|
||||||
|
`object_height` (Gesamt-Z), damit Obicos `mmProgress`-Widget
|
||||||
|
`aktuelles Z / Gesamt-Z` anzeigt.
|
||||||
|
- **Slot-Karte zeigt den OrcaSlicer-Profil-Vendor** unter dem Material
|
||||||
|
(z.B. „PLA / Polymaker"), mit Profil-Namen + interner ID als
|
||||||
|
Tooltip. So ist auf einen Blick erkennbar welcher Slot-Override
|
||||||
|
aktiv ist.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- **Slot-Profil-Auswahl im AMS-Dialog (Issue #39 von @harrygeier):**
|
||||||
|
drei separate Bugs in 0.9.17 sorgten dafür dass die gewählte Marke
|
||||||
|
nach dem Speichern verschwand und beim erneuten Öffnen ein falsches
|
||||||
|
Material angezeigt wurde.
|
||||||
|
- `multiColorBox/setInfo` über das falsche MQTT-Topic — siehe oben.
|
||||||
|
- Speichern lief in zwei parallelen Requests (Profil-Override +
|
||||||
|
Material/Farbe) → Race-Bedingung. Läuft jetzt sequenziell und
|
||||||
|
reloaded den lokalen State bevor der Dialog geschlossen wird.
|
||||||
|
- OrcaSlicer-Filament-IDs sind nicht eindeutig — `orca_filaments.json`
|
||||||
|
hat 68 duplikate IDs, `OGFL99` allein ist 136 Vendor-Profilen
|
||||||
|
zugeordnet (Erkenntnis von @gangoke). Der primäre Selector ist
|
||||||
|
jetzt `(vendor, name)` — über alle 1002 Profile eindeutig.
|
||||||
|
- **MQTT-Reconnect (Issue #33 von @icebear):** wurde der Drucker über
|
||||||
|
Nacht ausgeschaltet, schlug die Bridge nach 5 Reconnect-Versuchen
|
||||||
|
(~60 s gesamt) endgültig fehl — Filament-Sync ging morgens noch
|
||||||
|
(weil das HTTP ist), aber Print-Start scheiterte mit
|
||||||
|
„connection refused", User musste die Bridge selbst neu starten.
|
||||||
|
Reader-Thread reconnectet jetzt **endlos** (Backoff cappt bei 60 s)
|
||||||
|
bis der Drucker wieder antwortet, mit DEBUG-Logging nach den ersten
|
||||||
|
5 Versuchen damit das Log nicht über Nacht zugemüllt wird.
|
||||||
|
- **„Unknown child pid"-Warnungen im Log:** beim Killen der Kamera-
|
||||||
|
`ffmpeg`-Prozesse fehlte das `wait()` — Children blieben als
|
||||||
|
Zombies und asyncio meldete sie alle ~20 s. Gefixt im CameraCache
|
||||||
|
+ `/api/camera/stream`.
|
||||||
|
|
||||||
|
### UI-Aufräumen
|
||||||
|
- **Pause-Button als Toggle:** druckt der Drucker → `⏸ Pause`, ist
|
||||||
|
pausiert → `▶ Weiter`. Der separate „Weiter"-Button entfällt.
|
||||||
|
- **Pause + Stopp komplett ausgeblendet wenn Drucker idle** — bei
|
||||||
|
Standby waren beide Buttons vorher dauerhaft sichtbar, was beim
|
||||||
|
Idle-Drucker verwirrend wirkte.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
- **GCode-Store-Migration:** neue Spalten `layer_height` +
|
||||||
|
`first_layer_height` in `gcode_files` (automatisch beim ersten
|
||||||
|
Start von 0.9.18 angelegt).
|
||||||
|
|
||||||
## [0.9.17] – 2026-05-30
|
## [0.9.17] – 2026-05-30
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|||||||
128
CHANGELOG.md
128
CHANGELOG.md
@@ -1,5 +1,133 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [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 (e.g. "Bert - PLA")
|
||||||
|
are detected correctly — the bridge resolves the inherited fields
|
||||||
|
from the system parent.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- **AMS sync stuck on "Generic PLA":** the Orca data model has 68
|
||||||
|
duplicate `filament_id` values (`OGFL99` alone shared by 136
|
||||||
|
profiles), and the bridge often picked an ID that was not
|
||||||
|
`is_compatible` with the Kobra X (e.g. `GFL92` from the Kobra-2
|
||||||
|
profile → Orca rejected it). The generator now prioritises Kobra-X
|
||||||
|
variants and filters out phantom profiles (cross-vendor overrides) —
|
||||||
|
`orca_filaments.json` dropped from 1035 to 400 clean profiles.
|
||||||
|
- **Slot without an explicit override now sends `Generic <type>`**
|
||||||
|
instead of an implicit vendor guess. Library generic profiles have
|
||||||
|
`compatible_printers: []` (= all printers), so they are always
|
||||||
|
visible and match reliably.
|
||||||
|
- **Slot card shows the vendor right after save** without a browser
|
||||||
|
reload. `poll()` was async, the re-render only happened on the next
|
||||||
|
tick.
|
||||||
|
- **ACE dryer toggle threw a 502 even though the dryer worked
|
||||||
|
(PR #42 by @gangoke):** `setDry` is now fire-and-forget like
|
||||||
|
`setAutoFeed`. The printer answers on that push topic with
|
||||||
|
`code: 0` instead of `code: 200`, which the bridge wrongly treated
|
||||||
|
as an error.
|
||||||
|
|
||||||
|
### Data model / API
|
||||||
|
- `orca_filaments.json` regenerated: only real vendor profiles and
|
||||||
|
OrcaFilamentLibrary profiles. Bambu/Polymaker/SUNLU library profiles
|
||||||
|
in, Qidi cross-bundles out.
|
||||||
|
- New endpoints: `POST /kx/filament/profiles/user` (ZIP/JSON upload),
|
||||||
|
`GET /kx/filament/profiles/user` (list user imports),
|
||||||
|
`DELETE /kx/filament/profiles/user[?vendor=…&name=…]`. Persisted in
|
||||||
|
`<KX_DATA_DIR>/orca_filaments.user.json` (volume — survives image
|
||||||
|
updates).
|
||||||
|
|
||||||
|
### Build
|
||||||
|
- New module `bridge/orca_filaments.py` (shared parser helpers used by
|
||||||
|
the generator and the import endpoint).
|
||||||
|
- Dockerfile + release.sh updated to include `orca_filaments.py`.
|
||||||
|
|
||||||
|
## [0.9.18] – 2026-05-31
|
||||||
|
|
||||||
|
### New
|
||||||
|
- **🎉 Push filament material and colour from the bridge to the
|
||||||
|
printer:** The values you pick in the slot-edit dialog now actually
|
||||||
|
reach the printer and stick — the printer display shows the same
|
||||||
|
slot setup as the bridge UI right away. In 0.9.17 the command was
|
||||||
|
sent over the wrong MQTT topic (`slicer/printer/…`) and the printer
|
||||||
|
silently dropped it. It now goes via `web/printer/…` like the
|
||||||
|
Anycubic Slicer Next does (verified by live MQTT sniff +
|
||||||
|
Workbench-Vue source). **Note:** the printer must be idle, and
|
||||||
|
empty slots can not be labelled.
|
||||||
|
- **Spanish translation reviewed by a native speaker (PR #40 by
|
||||||
|
@pezfisk):** missing accents (impresión, cámara, después,
|
||||||
|
animación, …) and term consistency (Pause → Pausa, Start →
|
||||||
|
Iniciar, Layer → Capa). New `README.es.md` and cross-links between
|
||||||
|
the three READMEs.
|
||||||
|
- **Z-height now shows up in Obico.** The printer does not report a
|
||||||
|
real Z position over MQTT (live-sniff confirmed), so the bridge
|
||||||
|
estimates it from `current_layer × layer_height + first_layer_height`.
|
||||||
|
Layer heights are parsed from the gcode header at upload time and
|
||||||
|
persisted in the gcode store; fallback for prints started directly
|
||||||
|
from the slicer is the OrcaSlicer default filename pattern
|
||||||
|
(`…_0.2_…gcode`). `/server/files/metadata` also serves
|
||||||
|
`object_height` (total Z) so Obicos `mmProgress` widget can render
|
||||||
|
`current Z / total Z`.
|
||||||
|
- **Slot card shows the OrcaSlicer profile vendor** under the
|
||||||
|
material (e.g. `PLA / Polymaker`), with the profile name + internal
|
||||||
|
ID as tooltip. Lets you see at a glance which slot override is
|
||||||
|
active.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- **Slot profile picker in the AMS dialog (issue #39 by
|
||||||
|
@harrygeier):** three separate bugs in 0.9.17 caused the chosen
|
||||||
|
brand to disappear after save and a different material to show up
|
||||||
|
on re-open.
|
||||||
|
- `multiColorBox/setInfo` was sent on the wrong MQTT topic — see
|
||||||
|
above.
|
||||||
|
- Save fired two parallel requests (profile override + material/
|
||||||
|
colour) → race. Now sequential, and the local state is reloaded
|
||||||
|
before the dialog closes.
|
||||||
|
- OrcaSlicer filament IDs are not unique — `orca_filaments.json`
|
||||||
|
has 68 duplicate IDs, `OGFL99` alone is shared by 136 vendor
|
||||||
|
profiles (caught by @gangoke). The primary selector is now
|
||||||
|
`(vendor, name)` — unique across all 1002 profiles.
|
||||||
|
- **MQTT reconnect (issue #33 by @icebear):** if the printer was
|
||||||
|
powered off overnight the bridge gave up after 5 reconnect attempts
|
||||||
|
(~60 s total) — filament sync still worked in the morning (its
|
||||||
|
HTTP), but starting a print failed with `connection refused` and
|
||||||
|
the user had to restart the bridge itself. The reader thread now
|
||||||
|
reconnects **forever** (backoff caps at 60 s) until the printer
|
||||||
|
responds again, with logs dropping to DEBUG after the first 5
|
||||||
|
attempts so an overnight outage does not spam the log.
|
||||||
|
- **`Unknown child pid` warnings in the log:** the camera ffmpeg
|
||||||
|
helpers were killed without awaiting their `wait()` — children
|
||||||
|
lingered as zombies and asyncio reported them every ~20 s. Fixed
|
||||||
|
in CameraCache + `/api/camera/stream`.
|
||||||
|
|
||||||
|
### UI polish
|
||||||
|
- **Pause button is now a toggle:** while printing → `⏸ Pause`,
|
||||||
|
while paused → `▶ Resume`. The separate resume button is gone.
|
||||||
|
- **Pause + stop hidden when the printer is idle** — both used to be
|
||||||
|
visible at all times, which was confusing on a standby printer.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
- **gcode store migration:** new columns `layer_height` +
|
||||||
|
`first_layer_height` on `gcode_files` (added automatically on first
|
||||||
|
start of 0.9.18).
|
||||||
|
|
||||||
## [0.9.17] – 2026-05-30
|
## [0.9.17] – 2026-05-30
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
|||||||
@@ -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 .
|
||||||
|
|||||||
51
README.de.md
51
README.de.md
@@ -8,7 +8,12 @@
|
|||||||
|
|
||||||
Eine Moonraker-kompatible Bridge, die direkt mit dem Drucker spricht.
|
Eine Moonraker-kompatible Bridge, die direkt mit dem Drucker spricht.
|
||||||
|
|
||||||
<sub>🇬🇧 <a href="README.md">English version</a></sub>
|
<sub>🧪 Ein Community-Bericht auf Reddit deutet darauf hin, dass die Bridge auch
|
||||||
|
mit dem **Kobra S1** und **Kobra S1 Max** funktioniert — die Protokolle wirken
|
||||||
|
kompatibel, beides ist aber weder offiziell getestet noch unterstützt.
|
||||||
|
Feedback willkommen.</sub>
|
||||||
|
|
||||||
|
<sub>🇬🇧 <a href="README.md">English version</a> · 🇪🇸 <a href="README.es.md">Versión española</a></sub>
|
||||||
|
|
||||||
<br>
|
<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.
|
||||||
|
|||||||
251
README.es.md
Normal file
251
README.es.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="knlogo.png" alt="KX-Bridge" width="160"/>
|
||||||
|
|
||||||
|
# KX-Bridge
|
||||||
|
|
||||||
|
**Controla tu Anycubic Kobra X con OrcaSlicer — sin Klipper, sin Raspberry Pi.**
|
||||||
|
|
||||||
|
Un puente compatible con Moonraker que se comunica directamente con la impresora.
|
||||||
|
|
||||||
|
<sub>🧪 Un usuario en Reddit ha reportado que el puente también funciona con la
|
||||||
|
**Kobra S1** y la **Kobra S1 Max** — los protocolos parecen compatibles, pero
|
||||||
|
ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
|
||||||
|
|
||||||
|
<sub>🇬🇧 <a href="README.md">English version</a> · 🇩🇪 <a href="README.de.md">Deutsche Version</a></sub>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
[](https://ko-fi.com/viewitde)
|
||||||
|
|
||||||
|
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
||||||
|
|
||||||
|
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
||||||
|
|
||||||
|
[](https://www.youtube.com/watch?v=1Ql4wfH27fM)
|
||||||
|
|
||||||
|
<sub>¿Te gusta KX-Bridge? Un café en <a href="https://ko-fi.com/viewitde">Ko-fi</a> mantiene el proyecto vivo. ☕</sub>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Características
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 🖨️ | **Control de impresora** — iniciar, pausar, reanudar, cancelar, temperaturas, velocidad de impresión |
|
||||||
|
| 📊 | **Estado en tiempo real** — temperatura, progreso, capas, tiempo restante, transmisión de cámara |
|
||||||
|
| 🎨 | **AMS / multicolor** — ranuras con **selector de perfil por ranura** (asigna tu propia marca de los perfiles de OrcaSlicer a cada ranura); el puente escribe material y color al display de la impresora |
|
||||||
|
| 📦 | **Importa tus propios perfiles de OrcaSlicer** — arrastra un ZIP de `~/.config/OrcaSlicer/user/<id>/filament/` al puente; aparecen en el desplegable de la ranura bajo ★ Perfiles propios |
|
||||||
|
| 📷 | **Integración con Obico (experimental)** — Time-Lapse y stream en vivo WebRTC contra un [servidor Obico](https://github.com/TheSpaghettiDetective/obico-server) autoalojado vía moonraker-obico |
|
||||||
|
| 📐 | **Stream H.264 directo + altura Z** — ruta de cámara de bajo consumo de CPU para Obico, Z actual derivada de la altura de capa (widget de progreso) |
|
||||||
|
| 🗂️ | **Explorador de GCode** — archivos subidos con vistas previas, historial de impresión, búsqueda y filtros |
|
||||||
|
| 🧩 | **Multi-impresora** — múltiples impresoras en **una** instancia del puente, cambia mediante un menú desplegable |
|
||||||
|
| ➕ | **Añade una impresora con un clic** — solo introduce la IP, las credenciales se importan automáticamente |
|
||||||
|
| 🔁 | **Reconexión MQTT robusta** — el puente sobrevive a reinicios nocturnos de la impresora sin reinicio manual |
|
||||||
|
| 🌐 | **Interfaz multilingüe** — DE / EN / ES / 中文, detecta automáticamente el idioma del navegador |
|
||||||
|
| 🔄 | **Actualización automática** — instala nuevas versiones directamente desde el navegador |
|
||||||
|
| 🧠 | **OrcaSlicer** — protocolo Moonraker completo (HTTP + WebSocket); usa el [build OrcaSlicer-KX](#-slicer-recomendado) para emparejamiento correcto de vendor por ranura |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Inicio rápido
|
||||||
|
|
||||||
|
### 1. Prepara la impresora
|
||||||
|
|
||||||
|
Activa el modo LAN en la Kobra X:
|
||||||
|
**Pantalla de la impresora → Ajustes → Activar modo LAN**
|
||||||
|
|
||||||
|
### 2. Inicia el puente
|
||||||
|
|
||||||
|
**Docker (recomendado):**
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Binario Linux (sin Docker):**
|
||||||
|
```bash
|
||||||
|
chmod +x kx-bridge && ./kx-bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
**EXE Windows (sin Docker):**
|
||||||
|
```
|
||||||
|
kx-bridge.exe
|
||||||
|
```
|
||||||
|
> `config\` y `data\` se crean junto al EXE — instalación portátil.
|
||||||
|
|
||||||
|
> Con los binarios de Linux y Windows, `config/` y `data/` (configuración, SQLite, almacén de GCode)
|
||||||
|
> viven junto al programa. Copia toda la carpeta para mover la instalación.
|
||||||
|
|
||||||
|
**Python directamente:**
|
||||||
|
```bash
|
||||||
|
pip install -r bridge/requirements.txt
|
||||||
|
python bridge/kobrax_moonraker_bridge.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configura la impresora
|
||||||
|
|
||||||
|
Abre la interfaz web: **`http://IP-DEL-PUENTE:7125`**
|
||||||
|
|
||||||
|
En el primer inicio, la pestaña **Impresoras** muestra *"+ Añadir impresora"* — solo introduce la dirección IP
|
||||||
|
de la impresora, el resto (usuario, contraseña, ID de dispositivo) se obtiene de la impresora y se desencripta
|
||||||
|
automáticamente. Listo.
|
||||||
|
|
||||||
|
> ¿Más de una impresora? Simplemente haz clic en *"+ Añadir impresora"* de nuevo — cada una recibe su propio puerto
|
||||||
|
> (7125, 7126, …) y se puede seleccionar desde el menú desplegable del encabezado.
|
||||||
|
|
||||||
|
### 4. Conecta OrcaSlicer
|
||||||
|
|
||||||
|
Impresora → Tipo de conexión **Moonraker** → Host: `http://IP-DEL-PUENTE:7125`
|
||||||
|
|
||||||
|
> ⚠️ El tipo de conexión debe ser **Moonraker** (no "Bambu" ni "Klipper").
|
||||||
|
> Introduce la URL completa incluyendo `http://` y el puerto `:7125` en el campo de host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📺 Vídeo tutorial
|
||||||
|
|
||||||
|
[](https://www.youtube.com/watch?v=1Ql4wfH27fM)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Slicer recomendado
|
||||||
|
|
||||||
|
Para una sincronización de filamento AMS correcta ofrecemos una **versión modificada de OrcaSlicer**:
|
||||||
|
|
||||||
|
→ **[Lanzamientos de OrcaSlicer-KX](https://gitea.it-drui.de/viewit/OrcaSlicer-KX/releases/latest)** (Linux AppImage + Windows ZIP)
|
||||||
|
|
||||||
|
**PRs upstream incluidos en el build KX:**
|
||||||
|
|
||||||
|
- **[PR #13372](https://github.com/SoftFever/OrcaSlicer/pull/13372)** — Corrección de sincronización Moonraker / Happy-Hare AMS (las posiciones de las ranuras se mantienen correctas incluso con ranuras vacías)
|
||||||
|
- **[PR #13719](https://github.com/SoftFever/OrcaSlicer/pull/13719)** — Coincidencia de Vendor + Nombre para Moonraker (lee `name` + `vendor_name` por ranura y los empareja con los presets de filamento del usuario), por [@LordGuenni](https://github.com/LordGuenni)
|
||||||
|
- **[PR #13315](https://github.com/SoftFever/OrcaSlicer/pull/13315)** — `filament_id` único para presets de usuario (los perfiles nuevos reciben un ID nuevo en vez de heredar `OGFL99` del padre genérico), por [@mrnoisytiger](https://github.com/mrnoisytiger)
|
||||||
|
|
||||||
|
**Más cuatro mejoras específicas de KX encima:**
|
||||||
|
|
||||||
|
- Respetar el hint de filamento del puente (`tray_info_idx` + vendor)
|
||||||
|
- Coincidencia por vendor incluso cuando el preset base no es **is_compatible** con la impresora activa (así un perfil copiado de otra máquina sigue coincidiendo por vendor)
|
||||||
|
- Coincidencia por vendor cuando `tray_info_idx` está definido pero su preset es incompatible
|
||||||
|
- Búsqueda de dos pasadas: primero presets compatibles, luego todos los visibles
|
||||||
|
|
||||||
|
**Por qué importa:** sin #13719 todas las ranuras AMS caen en `Generic PLA` / `Generic PETG` aunque el puente ya envíe la marca concreta (`name + vendor_name + gate_filament_name`). Con el build KX, OrcaSlicer coincide con tus presets de usuario reales — incluyendo los perfiles que importaste al puente vía [Importa tus propios perfiles de OrcaSlicer](#-características).
|
||||||
|
|
||||||
|
OrcaSlicer upstream también funciona para rebanar e imprimir — solo pierdes la coincidencia de vendor por ranura en la sincronización AMS. El material y color por ranura se pueden empujar puente → impresora con cualquier slicer (eso va por MQTT, no por el slicer).
|
||||||
|
|
||||||
|
OrcaSlicer-KX es un build de [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0); el código fuente de cada PR upstream está en GitHub, los parches específicos de KX en el repo OrcaSlicer-KX.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏠 Comunidad e integraciones
|
||||||
|
|
||||||
|
- **[Integración con Home Assistant](https://github.com/gangoke/kobrax-lan-hass-component)**
|
||||||
|
por [@gangoke](https://github.com/gangoke) — expone sensores, controles de impresión,
|
||||||
|
luz, cámara y la vista previa del GCode como entidades nativas de Home Assistant.
|
||||||
|
- **[Obico (autoalojado)](https://github.com/TheSpaghettiDetective/obico-server)** —
|
||||||
|
el puente expone una API compatible con Moonraker que
|
||||||
|
[moonraker-obico](https://github.com/TheSpaghettiDetective/moonraker-obico)
|
||||||
|
acepta, así obtienes Time-Lapse y streaming en vivo WebRTC contra tu propio
|
||||||
|
servidor Obico. La detección de fallos por IA es experimental en la Kobra X
|
||||||
|
(el ángulo cenital de la cámara difiere del que el modelo fue entrenado).
|
||||||
|
|
||||||
|
> Estos son **proyectos de la comunidad**, no mantenidos ni soportados por KX-Bridge.
|
||||||
|
> Para preguntas o problemas, utiliza el repositorio enlazado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Obtener credenciales manualmente
|
||||||
|
|
||||||
|
Normalmente no es necesario — *"+ Añadir impresora"* lo hace automáticamente. Si lo necesitas:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fetch_credentials --ip 192.168.x.x --write-config
|
||||||
|
```
|
||||||
|
Obtiene las credenciales directamente de la impresora vía HTTP y las escribe en `config/config.ini`.
|
||||||
|
Solo se requiere la IP de la impresora, no hace falta un slicer.
|
||||||
|
|
||||||
|
Alternativamente (si se desconoce la IP): abre AnycubicSlicerNext, conecta la impresora, luego ejecuta
|
||||||
|
`extract_credentials` → muestra usuario, contraseña, ID de dispositivo y la IP de la impresora.
|
||||||
|
|
||||||
|
> **Descargas:** [Lanzamientos](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) → `fetch_credentials` / `extract_credentials` (Linux y Windows)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Comandos útiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f # mostrar registros
|
||||||
|
docker compose down # detener el puente
|
||||||
|
docker compose pull && docker compose up -d # actualizar a la imagen publicada más reciente
|
||||||
|
docker compose up -d --build # recompilar localmente (en lugar de descargar)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🩹 Solución de problemas
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>"Credenciales MQTT incorrectas" al iniciar</b></summary>
|
||||||
|
|
||||||
|
- Vuelve a añadir la impresora mediante *"+ Añadir impresora"*, o ejecuta
|
||||||
|
`fetch_credentials --ip <ip> --write-config` y reinicia el puente
|
||||||
|
- Introduce solo la dirección IP, sin puerto (✗ `192.168.1.102:9883` → ✓ `192.168.1.102`)
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Impresora no encontrada / modo LAN no activado</b></summary>
|
||||||
|
|
||||||
|
- En la pantalla de la impresora: Ajustes → Activar modo LAN
|
||||||
|
- La impresora y el puente deben estar en la misma red
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Docker: Permiso denegado</b></summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo usermod -aG docker $USER # luego cierra la sesión y vuelve a iniciarla
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Actualizar desde 0.9.1 o anterior</b></summary>
|
||||||
|
|
||||||
|
A partir de 0.9.2, KX-Bridge almacena la configuración en `config/config.ini` en lugar de `.env`.
|
||||||
|
La migración se ejecuta automáticamente en el primer inicio después de la actualización — no requiere acción.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Seguridad
|
||||||
|
|
||||||
|
- El puente es accesible en la red local en `http://<IP-del-host>:7125` — **no** lo expongas a internet
|
||||||
|
- `config/config.ini` contiene las credenciales de la impresora — no las compartas públicamente
|
||||||
|
- Las credenciales **no** otorgan acceso a los servicios en la nube de Anycubic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 Licencia
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
KX-Bridge se publica bajo la **GNU General Public License v3.0**. Consulta
|
||||||
|
[LICENSE](LICENSE) para el texto completo. Las bifurcaciones y modificaciones deben
|
||||||
|
permanecer bajo GPLv3 si se redistribuyen.
|
||||||
|
|
||||||
|
La implementación del protocolo MQTT es el resultado de una ingeniería inversa
|
||||||
|
independiente con fines de interoperabilidad (§69e UrhG / Directiva de Software de la UE
|
||||||
|
Art. 6). El material de terceros en el repositorio (certificados TLS de Anycubic)
|
||||||
|
**no** está cubierto por GPLv3 y se incluye únicamente para permitir la
|
||||||
|
autenticación contra impresoras que el usuario final ya posee. Consulta
|
||||||
|
[NOTICE.md](NOTICE.md) para más detalles y el aviso legal.
|
||||||
|
|
||||||
|
Este proyecto es independiente y no está afiliado con Anycubic.
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<br>
|
||||||
|
|
||||||
|
**Si KX-Bridge te ayuda, el proyecto agradece tu apoyo:**
|
||||||
|
|
||||||
|
[](https://ko-fi.com/viewitde)
|
||||||
|
|
||||||
|
</div>
|
||||||
47
README.md
47
README.md
@@ -8,7 +8,11 @@
|
|||||||
|
|
||||||
A Moonraker-compatible bridge that talks directly to the printer.
|
A Moonraker-compatible bridge that talks directly to the printer.
|
||||||
|
|
||||||
<sub>🇩🇪 <a href="README.de.md">Deutsche Version</a></sub>
|
<sub>🧪 A community report on Reddit suggests the bridge also works with the
|
||||||
|
**Kobra S1** and **Kobra S1 Max** — protocols look compatible, but neither is
|
||||||
|
officially tested or supported. Feedback welcome.</sub>
|
||||||
|
|
||||||
|
<sub>🇩🇪 <a href="README.de.md">Deutsche Version</a> · 🇪🇸 <a href="README.es.md">Versión española</a></sub>
|
||||||
|
|
||||||
<br>
|
<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.
|
||||||
|
|||||||
@@ -169,16 +169,23 @@ def list_printers() -> list[dict]:
|
|||||||
def list_filament_profiles() -> dict[int, dict]:
|
def list_filament_profiles() -> dict[int, dict]:
|
||||||
"""Liest die [filament_profiles]-Sektion aus config.ini.
|
"""Liest die [filament_profiles]-Sektion aus config.ini.
|
||||||
|
|
||||||
Format pro AMS-Slot (slot_N_id + slot_N_vendor):
|
Format pro AMS-Slot — primärer Selector ist (vendor, name), die `id` wird
|
||||||
[filament_profiles]
|
aus der orca_filaments.json beim Speichern nachgeschlagen und mitgeführt
|
||||||
slot_0_id = OGFL01
|
(als Hint für OrcaSlicer; das Orca-Datenmodell hat ~136 Profile mit
|
||||||
slot_0_vendor = Polymaker
|
derselben filament_id wie 'OGFL99', d.h. die ID ist nicht eindeutig):
|
||||||
slot_1_id = OGFG23
|
|
||||||
slot_1_vendor = Polymaker
|
|
||||||
|
|
||||||
Gibt einen Dict {slot_index: {"id": ..., "vendor": ...}} zurück.
|
[filament_profiles]
|
||||||
Leere/fehlende Slots werden NICHT aufgenommen — das Default-Mapping
|
slot_0_vendor = Polymaker
|
||||||
|
slot_0_name = PolyTerra PLA
|
||||||
|
slot_0_id = OGFL01
|
||||||
|
|
||||||
|
Gibt einen Dict {slot_index: {"id": ..., "vendor": ..., "name": ...}}
|
||||||
|
zurück. Leere/fehlende Slots werden NICHT aufgenommen — das Default-Mapping
|
||||||
(per filament_type) in der Bridge bleibt dann aktiv.
|
(per filament_type) in der Bridge bleibt dann aktiv.
|
||||||
|
|
||||||
|
Backwards-Kompat: alte Configs mit nur (vendor, id) bleiben lesbar; `name`
|
||||||
|
fehlt dann und der Aufrufer kann optional aus der orca_filaments.json
|
||||||
|
rekonstruieren.
|
||||||
"""
|
"""
|
||||||
path = _find_config_file()
|
path = _find_config_file()
|
||||||
if not path:
|
if not path:
|
||||||
@@ -189,7 +196,7 @@ def list_filament_profiles() -> dict[int, dict]:
|
|||||||
return {}
|
return {}
|
||||||
result: dict[int, dict] = {}
|
result: dict[int, dict] = {}
|
||||||
for key, value in cfg.items("filament_profiles"):
|
for key, value in cfg.items("filament_profiles"):
|
||||||
# Erwartet: slot_<idx>_id oder slot_<idx>_vendor
|
# Erwartet: slot_<idx>_id oder slot_<idx>_vendor oder slot_<idx>_name
|
||||||
if not key.startswith("slot_"):
|
if not key.startswith("slot_"):
|
||||||
continue
|
continue
|
||||||
parts = key.split("_", 2)
|
parts = key.split("_", 2)
|
||||||
@@ -200,13 +207,11 @@ def list_filament_profiles() -> dict[int, dict]:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
field = parts[2]
|
field = parts[2]
|
||||||
if field not in ("id", "vendor"):
|
if field not in ("id", "vendor", "name"):
|
||||||
continue
|
continue
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
continue
|
continue
|
||||||
result.setdefault(slot_idx, {})[field] = value.strip()
|
result.setdefault(slot_idx, {})[field] = value.strip()
|
||||||
# Leere Einträge (nur vendor ohne id oder umgekehrt) trotzdem behalten —
|
|
||||||
# der Aufrufer prüft selbst was er nutzt.
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -214,24 +219,26 @@ def save_filament_profiles(profiles: dict[int, dict]) -> bool:
|
|||||||
"""Schreibt die übergebenen Slot-Profile in die [filament_profiles]-
|
"""Schreibt die übergebenen Slot-Profile in die [filament_profiles]-
|
||||||
Sektion der config.ini. Existierende Einträge werden komplett ersetzt.
|
Sektion der config.ini. Existierende Einträge werden komplett ersetzt.
|
||||||
|
|
||||||
profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker"}}
|
profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker", "name": "PolyTerra PLA"}}
|
||||||
|
Mindestens vendor+name müssen gesetzt sein; id ist optional (Hint).
|
||||||
"""
|
"""
|
||||||
path = _find_config_file()
|
path = _find_config_file()
|
||||||
if not path:
|
if not path:
|
||||||
return False
|
return False
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser()
|
||||||
cfg.read(path, encoding="utf-8")
|
cfg.read(path, encoding="utf-8")
|
||||||
# Sektion neu aufbauen — entfernt damit auch alte/verwaiste Slots
|
|
||||||
if cfg.has_section("filament_profiles"):
|
if cfg.has_section("filament_profiles"):
|
||||||
cfg.remove_section("filament_profiles")
|
cfg.remove_section("filament_profiles")
|
||||||
if profiles:
|
if profiles:
|
||||||
cfg["filament_profiles"] = {}
|
cfg["filament_profiles"] = {}
|
||||||
for slot_idx in sorted(profiles.keys()):
|
for slot_idx in sorted(profiles.keys()):
|
||||||
entry = profiles[slot_idx] or {}
|
entry = profiles[slot_idx] or {}
|
||||||
if entry.get("id"):
|
|
||||||
cfg["filament_profiles"][f"slot_{slot_idx}_id"] = entry["id"]
|
|
||||||
if entry.get("vendor"):
|
if entry.get("vendor"):
|
||||||
cfg["filament_profiles"][f"slot_{slot_idx}_vendor"] = entry["vendor"]
|
cfg["filament_profiles"][f"slot_{slot_idx}_vendor"] = entry["vendor"]
|
||||||
|
if entry.get("name"):
|
||||||
|
cfg["filament_profiles"][f"slot_{slot_idx}_name"] = entry["name"]
|
||||||
|
if entry.get("id"):
|
||||||
|
cfg["filament_profiles"][f"slot_{slot_idx}_id"] = entry["id"]
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
cfg.write(f)
|
cfg.write(f)
|
||||||
return True
|
return True
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -179,10 +179,24 @@ class KobraXClient:
|
|||||||
def connect(self):
|
def connect(self):
|
||||||
self._do_connect()
|
self._do_connect()
|
||||||
self._running = True
|
self._running = True
|
||||||
t = threading.Thread(target=self._read_loop, daemon=True)
|
self._ensure_reader()
|
||||||
t.start()
|
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
def _ensure_reader(self):
|
||||||
|
"""Stellt sicher dass der Reader-Thread lebt. Wenn der Reader nach einer
|
||||||
|
früheren disconnect/reconnect-Sequenz oder einem unbehandelten Fehler
|
||||||
|
gestorben ist, würden empfangene Replies sonst nie ankommen — publish()
|
||||||
|
würde dann zwar senden, aber auf Antworten ewig warten."""
|
||||||
|
if not self._running:
|
||||||
|
return # gewollter disconnect
|
||||||
|
t = getattr(self, "_reader_thread", None)
|
||||||
|
if t is not None and t.is_alive():
|
||||||
|
return
|
||||||
|
self._reader_thread = threading.Thread(
|
||||||
|
target=self._read_loop, daemon=True, name="kobrax-mqtt-reader",
|
||||||
|
)
|
||||||
|
self._reader_thread.start()
|
||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
self._running = False
|
self._running = False
|
||||||
try:
|
try:
|
||||||
@@ -191,20 +205,34 @@ class KobraXClient:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _reconnect(self):
|
def _reconnect(self):
|
||||||
|
"""Persistenter Reconnect: versucht endlos weiter bis der Drucker wieder
|
||||||
|
antwortet oder disconnect() gerufen wurde. Backoff cappt bei 60 s. Die
|
||||||
|
ersten 5 Versuche loggen als WARNING (akute Verbindungsstörung), danach
|
||||||
|
nur DEBUG um Log-Spam bei langem Drucker-Ausfall (z.B. über Nacht
|
||||||
|
ausgeschaltet) zu vermeiden."""
|
||||||
log.warning("Verbindung verloren – reconnect…")
|
log.warning("Verbindung verloren – reconnect…")
|
||||||
try:
|
try:
|
||||||
self._sock.close()
|
self._sock.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
for delay in [2, 4, 8, 15, 30]:
|
delays = [2, 4, 8, 15, 30, 60]
|
||||||
|
attempt = 0
|
||||||
|
while self._running:
|
||||||
|
delay = delays[min(attempt, len(delays) - 1)]
|
||||||
try:
|
try:
|
||||||
self._do_connect()
|
self._do_connect()
|
||||||
log.info("Reconnect erfolgreich")
|
log.info("Reconnect erfolgreich (nach %d Versuchen)", attempt + 1)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Reconnect fehlgeschlagen (%s), warte %ss…", e, delay)
|
attempt += 1
|
||||||
time.sleep(delay)
|
lvl = log.warning if attempt <= 5 else log.debug
|
||||||
return False
|
lvl("Reconnect fehlgeschlagen (%s, Versuch %d), warte %ss…", e, attempt, delay)
|
||||||
|
# Geteiltes Sleep damit disconnect() den Loop schneller bricht.
|
||||||
|
slept = 0.0
|
||||||
|
while slept < delay and self._running:
|
||||||
|
time.sleep(min(0.5, delay - slept))
|
||||||
|
slept += 0.5
|
||||||
|
return False # nur wenn disconnect() gerufen wurde
|
||||||
|
|
||||||
def _subscribe(self, topic: str):
|
def _subscribe(self, topic: str):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -348,6 +376,9 @@ class KobraXClient:
|
|||||||
# -- Publish + request/response ------------------------------------------
|
# -- Publish + request/response ------------------------------------------
|
||||||
|
|
||||||
def publish(self, msg_type: str, action: str, data=None, timeout: float = 5.0) -> dict | None:
|
def publish(self, msg_type: str, action: str, data=None, timeout: float = 5.0) -> dict | None:
|
||||||
|
# Falls Reader-Thread aus historischen Gründen tot ist, wiederbeleben —
|
||||||
|
# sonst würden Replies nie ankommen und event.wait() läuft ins Timeout.
|
||||||
|
self._ensure_reader()
|
||||||
msgid = str(uuid.uuid4())
|
msgid = str(uuid.uuid4())
|
||||||
payload = json.dumps({
|
payload = json.dumps({
|
||||||
"type": msg_type,
|
"type": msg_type,
|
||||||
@@ -413,6 +444,7 @@ class KobraXClient:
|
|||||||
|
|
||||||
def publish_web(self, msg_type: str, action: str, data=None) -> None:
|
def publish_web(self, msg_type: str, action: str, data=None) -> None:
|
||||||
"""Fire-and-forget publish on the web/printer topic (used for runtime updates during print)."""
|
"""Fire-and-forget publish on the web/printer topic (used for runtime updates during print)."""
|
||||||
|
self._ensure_reader()
|
||||||
msgid = str(uuid.uuid4())
|
msgid = str(uuid.uuid4())
|
||||||
payload = json.dumps({
|
payload = json.dumps({
|
||||||
"type": msg_type,
|
"type": msg_type,
|
||||||
@@ -429,7 +461,14 @@ class KobraXClient:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._sock.sendall(_build_publish(topic, payload))
|
self._sock.sendall(_build_publish(topic, payload))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("web send error: %s", e)
|
log.error("web send error: %s, reconnecting…", e)
|
||||||
|
# Reconnect triggern (analog zu publish()); ohne Retry weil
|
||||||
|
# fire-and-forget — der nächste Aufruf wird auf den frischen Socket
|
||||||
|
# treffen.
|
||||||
|
try:
|
||||||
|
self._reconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# -- High-level commands -------------------------------------------------
|
# -- High-level commands -------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,35 @@ def _parse_gcode_estimated_time(data: bytes) -> int:
|
|||||||
return secs
|
return secs
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_gcode_layer_heights(data: bytes) -> tuple[float, float]:
|
||||||
|
"""Liest (layer_height, initial_layer_height) aus dem OrcaSlicer-/PrusaSlicer-
|
||||||
|
GCode-Header. Beide sind als Konfigblock am Ende des GCode hinterlegt.
|
||||||
|
|
||||||
|
Beispiel-Zeilen:
|
||||||
|
; layer_height = 0.2
|
||||||
|
; initial_layer_print_height = 0.2
|
||||||
|
|
||||||
|
Liefert (0.0, 0.0) wenn nicht gefunden — Aufrufer entscheidet was er macht
|
||||||
|
(typisch: keinen Z-Wert anzeigen)."""
|
||||||
|
import re
|
||||||
|
head = data[:16384].decode("utf-8", errors="ignore")
|
||||||
|
tail = data[-65536:].decode("utf-8", errors="ignore")
|
||||||
|
search = head + "\n" + tail
|
||||||
|
def _grab(pat):
|
||||||
|
m = re.search(pat, search)
|
||||||
|
if not m:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
return float(m.group(1))
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
layer_h = _grab(r";\s*layer_height\s*=\s*([0-9.]+)")
|
||||||
|
first_h = (_grab(r";\s*initial_layer_print_height\s*=\s*([0-9.]+)") or
|
||||||
|
_grab(r";\s*first_layer_height\s*=\s*([0-9.]+)") or
|
||||||
|
layer_h)
|
||||||
|
return layer_h, first_h
|
||||||
|
|
||||||
|
|
||||||
def _extract_thumbnail(data: bytes) -> str:
|
def _extract_thumbnail(data: bytes) -> str:
|
||||||
"""Extrahiert Base64-PNG-Thumbnail aus GCode (OrcaSlicer-Format)."""
|
"""Extrahiert Base64-PNG-Thumbnail aus GCode (OrcaSlicer-Format)."""
|
||||||
try:
|
try:
|
||||||
@@ -386,7 +415,13 @@ class GCodeStore:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Migration: Spalten objects_skip_parts + svg_image (Part-Skip-Feature, v0.9.10)
|
# Migration: Spalten objects_skip_parts + svg_image (Part-Skip-Feature, v0.9.10)
|
||||||
for col, typ in (("objects_skip_parts", "TEXT"), ("svg_image", "TEXT")):
|
# Plus layer_height / first_layer_height (Obico Z-Höhe, v0.9.18)
|
||||||
|
for col, typ in (
|
||||||
|
("objects_skip_parts", "TEXT"),
|
||||||
|
("svg_image", "TEXT"),
|
||||||
|
("layer_height", "REAL"),
|
||||||
|
("first_layer_height", "REAL"),
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
self._conn.execute(f"ALTER TABLE gcode_files ADD COLUMN {col} {typ}")
|
self._conn.execute(f"ALTER TABLE gcode_files ADD COLUMN {col} {typ}")
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
@@ -402,7 +437,9 @@ class GCodeStore:
|
|||||||
def save_file(self, file_id: str, filename: str, data: bytes,
|
def save_file(self, file_id: str, filename: str, data: bytes,
|
||||||
est_time_sec: int = 0, thumbnail_b64: str = "",
|
est_time_sec: int = 0, thumbnail_b64: str = "",
|
||||||
gcode_filaments: list | None = None,
|
gcode_filaments: list | None = None,
|
||||||
web_unverified: bool = False) -> str:
|
web_unverified: bool = False,
|
||||||
|
layer_height: float = 0.0,
|
||||||
|
first_layer_height: float = 0.0) -> str:
|
||||||
"""Speichert GCode-Datei auf Disk und in DB. Gibt Pfad zurück."""
|
"""Speichert GCode-Datei auf Disk und in DB. Gibt Pfad zurück."""
|
||||||
safe_name = os.path.basename(filename)
|
safe_name = os.path.basename(filename)
|
||||||
path = os.path.join(self._gcode_dir, safe_name)
|
path = os.path.join(self._gcode_dir, safe_name)
|
||||||
@@ -413,9 +450,9 @@ class GCodeStore:
|
|||||||
filaments_json = json.dumps(gcode_filaments) if gcode_filaments else None
|
filaments_json = json.dumps(gcode_filaments) if gcode_filaments else None
|
||||||
self._conn.execute(
|
self._conn.execute(
|
||||||
"""INSERT OR REPLACE INTO gcode_files
|
"""INSERT OR REPLACE INTO gcode_files
|
||||||
(id, filename, path, size_bytes, uploaded_at, thumbnail_b64, est_print_time_sec, gcode_filaments, web_unverified)
|
(id, filename, path, size_bytes, uploaded_at, thumbnail_b64, est_print_time_sec, gcode_filaments, web_unverified, layer_height, first_layer_height)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(file_id, filename, path, len(data), now, thumbnail_b64 or None, est_time_sec or None, filaments_json, 1 if web_unverified else 0)
|
(file_id, filename, path, len(data), now, thumbnail_b64 or None, est_time_sec or None, filaments_json, 1 if web_unverified else 0, layer_height or None, first_layer_height or None)
|
||||||
)
|
)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
return path
|
return path
|
||||||
@@ -627,10 +664,17 @@ class CameraCache:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.debug(f"CameraCache: jpeg-loop unterbrochen: {e}")
|
log.debug(f"CameraCache: jpeg-loop unterbrochen: {e}")
|
||||||
finally:
|
finally:
|
||||||
try:
|
# Kill + Wait — sonst bleibt der Child-Prozess als Zombie und
|
||||||
self._proc_jpeg.kill()
|
# asyncio meldet "Unknown child pid …" beim nächsten reaper-Tick.
|
||||||
except Exception:
|
if self._proc_jpeg is not None:
|
||||||
pass
|
try:
|
||||||
|
self._proc_jpeg.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await self._proc_jpeg.wait()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._proc_jpeg = None
|
self._proc_jpeg = None
|
||||||
await asyncio.sleep(2.0) # restart delay
|
await asyncio.sleep(2.0) # restart delay
|
||||||
|
|
||||||
@@ -675,10 +719,15 @@ class CameraCache:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.debug(f"CameraCache: h264-loop unterbrochen: {e}")
|
log.debug(f"CameraCache: h264-loop unterbrochen: {e}")
|
||||||
finally:
|
finally:
|
||||||
try:
|
if self._proc_h264 is not None:
|
||||||
self._proc_h264.kill()
|
try:
|
||||||
except Exception:
|
self._proc_h264.kill()
|
||||||
pass
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await self._proc_h264.wait()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._proc_h264 = None
|
self._proc_h264 = None
|
||||||
await asyncio.sleep(2.0)
|
await asyncio.sleep(2.0)
|
||||||
|
|
||||||
@@ -717,6 +766,12 @@ class KobraXBridge:
|
|||||||
"remain_time": 0,
|
"remain_time": 0,
|
||||||
"curr_layer": 0,
|
"curr_layer": 0,
|
||||||
"total_layers": 0,
|
"total_layers": 0,
|
||||||
|
# Layer-Heights pro aktuell laufender Datei (aus dem GCode-Header
|
||||||
|
# geparst). Wird im Upload-Pfad + beim _fetch_from_store gesetzt.
|
||||||
|
# Obico nutzt currentZ aus gcode_position[2] — die Bridge rechnet
|
||||||
|
# currentZ aus curr_layer + diesen Werten in build_print_payload.
|
||||||
|
"layer_height": 0.0,
|
||||||
|
"first_layer_height": 0.0,
|
||||||
"printer_name": env_loader.get("BRIDGE_PRINTER_NAME", "Anycubic Kobra X"),
|
"printer_name": env_loader.get("BRIDGE_PRINTER_NAME", "Anycubic Kobra X"),
|
||||||
"firmware_version": "unknown",
|
"firmware_version": "unknown",
|
||||||
"upload_url": "",
|
"upload_url": "",
|
||||||
@@ -903,6 +958,8 @@ class KobraXBridge:
|
|||||||
self._state["print_duration"] = 0
|
self._state["print_duration"] = 0
|
||||||
self._state["remain_time"] = 0
|
self._state["remain_time"] = 0
|
||||||
self._state["slicer_time"] = 0
|
self._state["slicer_time"] = 0
|
||||||
|
self._state["layer_height"] = 0.0
|
||||||
|
self._state["first_layer_height"] = 0.0
|
||||||
self._thumbnail_b64 = ""
|
self._thumbnail_b64 = ""
|
||||||
self._state["filename"] = d.get("filename", self._state["filename"])
|
self._state["filename"] = d.get("filename", self._state["filename"])
|
||||||
if "progress" in d:
|
if "progress" in d:
|
||||||
@@ -1437,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:
|
||||||
@@ -1489,9 +1564,23 @@ class KobraXBridge:
|
|||||||
# Vendor wird mitgesendet (tray_sub_brands + filament_vendor),
|
# Vendor wird mitgesendet (tray_sub_brands + filament_vendor),
|
||||||
# damit ein gepatchter OrcaSlicer den Match nach Marke + Type +
|
# damit ein gepatchter OrcaSlicer den Match nach Marke + Type +
|
||||||
# Farbe machen kann (analog SnapmakerPrinterAgent).
|
# Farbe machen kann (analog SnapmakerPrinterAgent).
|
||||||
|
# Zwei-Schicht-Resolution für den Filament-Hint an OrcaSlicer:
|
||||||
|
# 1. User-Wahl (config.ini [filament_profiles]) — exakte Kontrolle
|
||||||
|
# 2. Generic-Fallback (_TRAY_INFO_IDX) pro Material-Typ — kein
|
||||||
|
# Vendor-Hint, OrcaSlicer trifft dann sein eigenes Generic-Preset
|
||||||
user_profile = self._filament_profiles.get(slot_index) or {}
|
user_profile = self._filament_profiles.get(slot_index) or {}
|
||||||
tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99")
|
if user_profile.get("name"):
|
||||||
vendor = user_profile.get("vendor", "")
|
vendor = user_profile.get("vendor", "")
|
||||||
|
fila_name = user_profile.get("name", "")
|
||||||
|
tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99")
|
||||||
|
else:
|
||||||
|
# Default: Library-Generic-Profil (siehe _default_filament_name) —
|
||||||
|
# ist mit allen Druckern kompatibel und garantiert sichtbar.
|
||||||
|
# Der User wählt pro Slot bewusst eine konkrete Marke wenn er
|
||||||
|
# eine will; Default bleibt neutral.
|
||||||
|
fila_name = self._default_filament_name(material)
|
||||||
|
vendor = "Generic" if fila_name.startswith("Generic ") else ""
|
||||||
|
tray_info_idx = self._lookup_filament_id(vendor, fila_name) or self._TRAY_INFO_IDX.get(material, "OGFL99")
|
||||||
tray_array.append({
|
tray_array.append({
|
||||||
"id": str(slot_id),
|
"id": str(slot_id),
|
||||||
"tag_uid": "0000000000000000",
|
"tag_uid": "0000000000000000",
|
||||||
@@ -1499,7 +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,
|
||||||
"filament_vendor": vendor, # OrcaSlicer-Patch-Ready (Snapmaker-Stil)
|
# OrcaSlicer-Empfangs-Patch PR #13719 erwartet `name` +
|
||||||
|
# `vendor_name` pro Lane (Stufen-Matching: Vendor+Name → Name →
|
||||||
|
# filament_id_by_type). Wir senden beide Schreibweisen mit
|
||||||
|
# damit ältere Patch-Varianten + zukünftige Upstream-PRs beide
|
||||||
|
# bedient sind.
|
||||||
|
"name": fila_name,
|
||||||
|
"vendor_name": vendor,
|
||||||
|
# Aliase für ältere Patch-Varianten (Variante 2,
|
||||||
|
# MoonrakerPrinterAgent.cpp): filament_id direkt (exakt),
|
||||||
|
# sonst preset-Name per find_preset() auflösen.
|
||||||
|
"filament_id": tray_info_idx,
|
||||||
|
"filament_vendor": vendor,
|
||||||
|
"filament_name": fila_name,
|
||||||
|
"preset": fila_name,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
tray_array.append({
|
tray_array.append({
|
||||||
@@ -1519,6 +1621,68 @@ class KobraXBridge:
|
|||||||
"tray_exist_bits": format(tray_exist_bits, "X"),
|
"tray_exist_bits": format(tray_exist_bits, "X"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _layer_height_from_filename(fname: str) -> float:
|
||||||
|
"""OrcaSlicer-Default-Filename-Pattern: `<plate>_<material>_<layer>_<dur>.gcode`
|
||||||
|
z.B. `adapter_e27_plate(01)_PLA_0.2_41m1s.gcode` → 0.2.
|
||||||
|
|
||||||
|
Fallback wenn der GCode-Header nicht geparst wurde (z.B. Datei direkt am
|
||||||
|
Slicer gestartet, oder vor v0.9.18 hochgeladen). Liefert 0.0 wenn das
|
||||||
|
Pattern nicht greift."""
|
||||||
|
import re
|
||||||
|
if not fname:
|
||||||
|
return 0.0
|
||||||
|
m = re.search(r"_(0\.\d+)_(\d+[hms])", fname)
|
||||||
|
if not m:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
return float(m.group(1))
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _estimate_current_z(self) -> float:
|
||||||
|
"""Schätzt die aktuelle Z-Höhe aus curr_layer + Layer-Heights.
|
||||||
|
|
||||||
|
Der Drucker liefert keine echte Z-Position via MQTT, aber Obico
|
||||||
|
(moonraker-obico/printer.py:267) liest currentZ aus `gcode_position[2]`.
|
||||||
|
Wir rechnen das mit der layer_height aus dem GCode-Header zurück:
|
||||||
|
z = first_layer_height + (curr_layer - 1) * layer_height
|
||||||
|
|
||||||
|
Werte werden im Upload-Pfad gesetzt und nur bei Druckabbruch/-ende
|
||||||
|
zurückgesetzt (Slot-/Farbänderungen ändern nichts daran). Falls die
|
||||||
|
Werte fehlen (z.B. weil der Druck direkt am Slicer gestartet wurde
|
||||||
|
ohne Upload über die Bridge), wird einmalig aus dem GCode-Store
|
||||||
|
nachgeladen. Liefert 0.0 wenn nichts bekannt — Obico zeigt dann
|
||||||
|
keinen Z-Wert."""
|
||||||
|
s = self._state
|
||||||
|
layer_h = float(s.get("layer_height") or 0.0)
|
||||||
|
first_h = float(s.get("first_layer_height") or 0.0)
|
||||||
|
fname = s.get("filename", "")
|
||||||
|
if not layer_h and fname:
|
||||||
|
try:
|
||||||
|
gf = self._store.get_file_by_name(fname)
|
||||||
|
if gf:
|
||||||
|
layer_h = float(gf.get("layer_height") or 0.0)
|
||||||
|
first_h = float(gf.get("first_layer_height") or layer_h)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not layer_h and fname:
|
||||||
|
# Letzter Fallback: OrcaSlicer-Default-Filename enthält die Layer-Height
|
||||||
|
layer_h = self._layer_height_from_filename(fname)
|
||||||
|
if layer_h and not first_h:
|
||||||
|
first_h = layer_h
|
||||||
|
if layer_h:
|
||||||
|
# cache in state damit nicht jeder Build wieder den Store fragt
|
||||||
|
s["layer_height"] = layer_h
|
||||||
|
s["first_layer_height"] = first_h
|
||||||
|
if not layer_h:
|
||||||
|
return 0.0
|
||||||
|
curr = int(s.get("curr_layer") or 0)
|
||||||
|
if curr <= 0:
|
||||||
|
return 0.0
|
||||||
|
# Layer 1 = first_layer_height, Layer 2 = first + layer_h, …
|
||||||
|
return round(first_h + max(0, curr - 1) * layer_h, 3)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# WebSocket push
|
# WebSocket push
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -1556,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)
|
||||||
@@ -1567,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)
|
||||||
@@ -1578,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 {
|
||||||
@@ -1639,15 +1838,18 @@ class KobraXBridge:
|
|||||||
"state_message": "Printer is ready",
|
"state_message": "Printer is ready",
|
||||||
},
|
},
|
||||||
# speed_factor: 1=silent(0.5) / 2=standard(1.0) / 3=high(1.3) / 4=ultra(1.5)
|
# speed_factor: 1=silent(0.5) / 2=standard(1.0) / 3=high(1.3) / 4=ultra(1.5)
|
||||||
|
# Aktuelle Z-Höhe für Obico aus curr_layer + Layer-Heights schätzen
|
||||||
|
# (Drucker liefert keine echte Z-Position per MQTT). gcode_position[2]
|
||||||
|
# ist der Wert den moonraker-obico in printer.py als currentZ liest.
|
||||||
"gcode_move": {
|
"gcode_move": {
|
||||||
"speed_factor": {1: 0.5, 2: 1.0, 3: 1.3, 4: 1.5}.get(int(s.get("print_speed_mode") or 2), 1.0),
|
"speed_factor": {1: 0.5, 2: 1.0, 3: 1.3, 4: 1.5}.get(int(s.get("print_speed_mode") or 2), 1.0),
|
||||||
"extrude_factor": 1.0,
|
"extrude_factor": 1.0,
|
||||||
"speed": 0,
|
"speed": 0,
|
||||||
"gcode_position": [0, 0, 0, 0],
|
"gcode_position": [0, 0, self._estimate_current_z(), 0],
|
||||||
"absolute_coordinates": True,
|
"absolute_coordinates": True,
|
||||||
"absolute_extrude": True,
|
"absolute_extrude": True,
|
||||||
"homing_origin": [0, 0, 0, 0],
|
"homing_origin": [0, 0, 0, 0],
|
||||||
"position": [0, 0, 0, 0],
|
"position": [0, 0, self._estimate_current_z(), 0],
|
||||||
},
|
},
|
||||||
"fan": {
|
"fan": {
|
||||||
"speed": (int(s.get("fan_speed") or 0)) / 100.0,
|
"speed": (int(s.get("fan_speed") or 0)) / 100.0,
|
||||||
@@ -1780,8 +1982,10 @@ class KobraXBridge:
|
|||||||
"status": "loaded" if s.get("status") == 5 else "empty",
|
"status": "loaded" if s.get("status") == 5 else "empty",
|
||||||
"nozzle_temp": 0,
|
"nozzle_temp": 0,
|
||||||
# Aktueller User-Override aus config.ini [filament_profiles]
|
# Aktueller User-Override aus config.ini [filament_profiles]
|
||||||
|
# — (vendor,name) ist eindeutig, id ist nur Hint.
|
||||||
"filament_id": profile.get("id", ""),
|
"filament_id": profile.get("id", ""),
|
||||||
"filament_vendor": profile.get("vendor", ""),
|
"filament_vendor": profile.get("vendor", ""),
|
||||||
|
"filament_name": profile.get("name", ""),
|
||||||
})
|
})
|
||||||
return self._json_cors({"result": slots})
|
return self._json_cors({"result": slots})
|
||||||
|
|
||||||
@@ -1795,21 +1999,135 @@ class KobraXBridge:
|
|||||||
"""
|
"""
|
||||||
type_filter = request.rel_url.query.get("type", "").upper().strip()
|
type_filter = request.rel_url.query.get("type", "").upper().strip()
|
||||||
vendor_filter = request.rel_url.query.get("vendor", "").strip()
|
vendor_filter = request.rel_url.query.get("vendor", "").strip()
|
||||||
data_path = self._find_orca_filaments_json()
|
profiles = self._load_orca_filaments()
|
||||||
if not data_path or not os.path.isfile(data_path):
|
|
||||||
return self._json_cors({"result": []})
|
|
||||||
try:
|
|
||||||
with open(data_path, encoding="utf-8") as f:
|
|
||||||
profiles = json.load(f)
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f"orca_filaments.json read error: {e}")
|
|
||||||
return self._json_cors({"result": []})
|
|
||||||
if type_filter:
|
if type_filter:
|
||||||
profiles = [p for p in profiles if p.get("type", "").upper() == type_filter]
|
profiles = [p for p in profiles if p.get("type", "").upper() == type_filter]
|
||||||
if vendor_filter:
|
if vendor_filter:
|
||||||
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:
|
||||||
@@ -1837,8 +2155,15 @@ class KobraXBridge:
|
|||||||
"""POST /kx/filament/slots/<idx>/profile — speichert oder löscht
|
"""POST /kx/filament/slots/<idx>/profile — speichert oder löscht
|
||||||
ein User-Override-Mapping für einen einzelnen AMS-Slot.
|
ein User-Override-Mapping für einen einzelnen AMS-Slot.
|
||||||
|
|
||||||
Body: {"id": "OGFL01", "vendor": "Polymaker"}
|
Primärer Selector ist (vendor, name) — die ID ist im Orca-Datenmodell
|
||||||
{"id": ""} → Mapping entfernen → Default-Fallback aktiv
|
nicht eindeutig (136 Profile teilen sich z.B. 'OGFL99'). Die ID wird
|
||||||
|
aus orca_filaments.json beim Speichern nachgeschlagen und als Hint
|
||||||
|
mitgeführt für OrcaSlicer's `tray_info_idx`.
|
||||||
|
|
||||||
|
Body: {"vendor": "Polymaker", "name": "PolyTerra PLA"}
|
||||||
|
{"vendor": "", "name": ""} → Mapping entfernen
|
||||||
|
(Backwards-Kompat: {"id":..., "vendor":...} wird akzeptiert,
|
||||||
|
aber `name` ist seit v0.9.18 der primäre Selector.)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
slot_idx = int(request.match_info.get("idx", "-1"))
|
slot_idx = int(request.match_info.get("idx", "-1"))
|
||||||
@@ -1850,10 +2175,18 @@ class KobraXBridge:
|
|||||||
data = await request.json()
|
data = await request.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
data = {}
|
data = {}
|
||||||
new_id = (data.get("id") or "").strip()
|
|
||||||
new_vendor = (data.get("vendor") or "").strip()
|
new_vendor = (data.get("vendor") or "").strip()
|
||||||
if new_id:
|
new_name = (data.get("name") or "").strip()
|
||||||
self._filament_profiles[slot_idx] = {"id": new_id, "vendor": new_vendor}
|
new_id = (data.get("id") or "").strip() # Backwards-Kompat-Hint
|
||||||
|
if new_vendor and new_name:
|
||||||
|
# ID aus JSON lookup'en (nicht aus dem Request-Body, der könnte
|
||||||
|
# veraltet sein oder ein Generic-Fallback).
|
||||||
|
looked_up_id = self._lookup_filament_id(new_vendor, new_name)
|
||||||
|
self._filament_profiles[slot_idx] = {
|
||||||
|
"vendor": new_vendor,
|
||||||
|
"name": new_name,
|
||||||
|
"id": looked_up_id or new_id,
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
self._filament_profiles.pop(slot_idx, None)
|
self._filament_profiles.pop(slot_idx, None)
|
||||||
# Persistieren in config.ini
|
# Persistieren in config.ini
|
||||||
@@ -1863,10 +2196,60 @@ class KobraXBridge:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"save_filament_profiles failed: {e}")
|
log.warning(f"save_filament_profiles failed: {e}")
|
||||||
return self._json_cors({"error": str(e)}, status=500)
|
return self._json_cors({"error": str(e)}, status=500)
|
||||||
|
entry = self._filament_profiles.get(slot_idx, {})
|
||||||
return self._json_cors({"result": "ok",
|
return self._json_cors({"result": "ok",
|
||||||
"slot_index": slot_idx,
|
"slot_index": slot_idx,
|
||||||
"id": new_id,
|
"vendor": entry.get("vendor", ""),
|
||||||
"vendor": new_vendor})
|
"name": entry.get("name", ""),
|
||||||
|
"id": entry.get("id", "")})
|
||||||
|
|
||||||
|
def _load_orca_filaments(self) -> list[dict]:
|
||||||
|
"""Lädt System- + User-Profile aus dem Cache. System-Profile kommen
|
||||||
|
aus bridge/data/orca_filaments.json (Image-embedded), User-Profile
|
||||||
|
aus <KX_DATA_DIR>/orca_filaments.user.json (Volume-persistent —
|
||||||
|
überlebt Image-Updates). User-Profile bekommen ein `is_user: True`-
|
||||||
|
Flag damit das Frontend sie markieren kann."""
|
||||||
|
if getattr(self, "_orca_filaments_cache", None) is not None:
|
||||||
|
return self._orca_filaments_cache
|
||||||
|
merged: list[dict] = []
|
||||||
|
# System
|
||||||
|
sys_path = self._find_orca_filaments_json()
|
||||||
|
if sys_path and os.path.isfile(sys_path):
|
||||||
|
try:
|
||||||
|
with open(sys_path, encoding="utf-8") as f:
|
||||||
|
merged.extend(json.load(f) or [])
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"orca_filaments.json read error: {e}")
|
||||||
|
# User
|
||||||
|
usr_path = self._orca_filaments_user_path()
|
||||||
|
if usr_path and os.path.isfile(usr_path):
|
||||||
|
try:
|
||||||
|
with open(usr_path, encoding="utf-8") as f:
|
||||||
|
for p in (json.load(f) or []):
|
||||||
|
p["is_user"] = True
|
||||||
|
merged.append(p)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"orca_filaments.user.json read error: {e}")
|
||||||
|
self._orca_filaments_cache = merged
|
||||||
|
return self._orca_filaments_cache
|
||||||
|
|
||||||
|
def _orca_filaments_user_path(self) -> str:
|
||||||
|
"""Pfad zur User-Profile-JSON. Liegt im Volume-Mount (KX_DATA_DIR),
|
||||||
|
damit Image-Updates die Daten nicht zerstören."""
|
||||||
|
data_dir = os.environ.get("KX_DATA_DIR") or os.path.join(_WEB_BASE, "data")
|
||||||
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
|
return os.path.join(data_dir, "orca_filaments.user.json")
|
||||||
|
|
||||||
|
def _invalidate_filaments_cache(self):
|
||||||
|
self._orca_filaments_cache = None
|
||||||
|
|
||||||
|
def _lookup_filament_id(self, vendor: str, name: str) -> str:
|
||||||
|
"""Sucht in orca_filaments.json die filament_id zu einem (vendor,name)-
|
||||||
|
Tupel. Liefert '' wenn nicht gefunden."""
|
||||||
|
for p in self._load_orca_filaments():
|
||||||
|
if p.get("vendor") == vendor and p.get("name") == name:
|
||||||
|
return p.get("id", "")
|
||||||
|
return ""
|
||||||
|
|
||||||
async def handle_kx_history(self, request):
|
async def handle_kx_history(self, request):
|
||||||
limit = int(request.rel_url.query.get("limit", 50))
|
limit = int(request.rel_url.query.get("limit", 50))
|
||||||
@@ -2164,6 +2547,52 @@ class KobraXBridge:
|
|||||||
})
|
})
|
||||||
return web.json_response({"result": files})
|
return web.json_response({"result": files})
|
||||||
|
|
||||||
|
async def handle_files_metadata(self, request):
|
||||||
|
"""Moonraker /server/files/metadata — moonraker-obico-Plugin holt das
|
||||||
|
einmal pro Druck und liest daraus `object_height` (für `currentZ`-
|
||||||
|
Anzeige im Obico-UI: `mmProgress` braucht maxZ), `layer_count`,
|
||||||
|
`layer_height` und `first_layer_height` (für die Layer-Berechnung).
|
||||||
|
|
||||||
|
Quelle: aktueller `_state` + GCode-Store-Eintrag wenn vorhanden.
|
||||||
|
Wenn Layer-Heights weder im State noch im Store sind, Fallback auf die
|
||||||
|
OrcaSlicer-Default-Filename-Heuristik (`_layer_height_from_filename`)."""
|
||||||
|
filename = request.rel_url.query.get("filename", "") or self._state.get("filename", "")
|
||||||
|
if not filename:
|
||||||
|
return web.json_response({"result": {}})
|
||||||
|
s = self._state
|
||||||
|
layer_h = float(s.get("layer_height") or 0.0)
|
||||||
|
first_h = float(s.get("first_layer_height") or 0.0)
|
||||||
|
total_layers = int(s.get("total_layers") or 0)
|
||||||
|
est_time = int(s.get("slicer_time") or 0)
|
||||||
|
size_bytes = 0
|
||||||
|
try:
|
||||||
|
gf = self._store.get_file_by_name(filename) or {}
|
||||||
|
if not layer_h:
|
||||||
|
layer_h = float(gf.get("layer_height") or 0.0)
|
||||||
|
first_h = float(gf.get("first_layer_height") or layer_h)
|
||||||
|
if not total_layers:
|
||||||
|
total_layers = int(gf.get("layer_count") or 0)
|
||||||
|
if not est_time:
|
||||||
|
est_time = int(gf.get("est_print_time_sec") or 0)
|
||||||
|
size_bytes = int(gf.get("size_bytes") or 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not layer_h:
|
||||||
|
layer_h = self._layer_height_from_filename(filename)
|
||||||
|
if layer_h and not first_h:
|
||||||
|
first_h = layer_h
|
||||||
|
object_height = round(first_h + max(0, total_layers - 1) * layer_h, 3) if (layer_h and total_layers) else 0.0
|
||||||
|
return web.json_response({"result": {
|
||||||
|
"filename": filename,
|
||||||
|
"size": size_bytes,
|
||||||
|
"modified": time.time(),
|
||||||
|
"estimated_time": est_time or None,
|
||||||
|
"layer_height": layer_h or None,
|
||||||
|
"first_layer_height": first_h or None,
|
||||||
|
"layer_count": total_layers or None,
|
||||||
|
"object_height": object_height or None,
|
||||||
|
}})
|
||||||
|
|
||||||
# ── Moonraker-Stubs für moonraker-obico ──────────────────────────────────
|
# ── Moonraker-Stubs für moonraker-obico ──────────────────────────────────
|
||||||
async def handle_access_api_key(self, request):
|
async def handle_access_api_key(self, request):
|
||||||
"""Moonraker /access/api_key — wir haben keine Auth, geben einen Dummy zurück.
|
"""Moonraker /access/api_key — wir haben keine Auth, geben einen Dummy zurück.
|
||||||
@@ -2290,6 +2719,9 @@ class KobraXBridge:
|
|||||||
self._state["slicer_time"] = est_time
|
self._state["slicer_time"] = est_time
|
||||||
thumbnail_b64 = _extract_thumbnail(file_data)
|
thumbnail_b64 = _extract_thumbnail(file_data)
|
||||||
gcode_filaments = _extract_filament_info(file_data)
|
gcode_filaments = _extract_filament_info(file_data)
|
||||||
|
layer_h, first_h = _parse_gcode_layer_heights(file_data)
|
||||||
|
self._state["layer_height"] = layer_h
|
||||||
|
self._state["first_layer_height"] = first_h
|
||||||
|
|
||||||
# Datei persistent im GCode-Store ablegen
|
# Datei persistent im GCode-Store ablegen
|
||||||
self._store.save_file(
|
self._store.save_file(
|
||||||
@@ -2300,6 +2732,8 @@ class KobraXBridge:
|
|||||||
thumbnail_b64=thumbnail_b64,
|
thumbnail_b64=thumbnail_b64,
|
||||||
gcode_filaments=gcode_filaments or None,
|
gcode_filaments=gcode_filaments or None,
|
||||||
web_unverified=web_upload,
|
web_unverified=web_upload,
|
||||||
|
layer_height=layer_h,
|
||||||
|
first_layer_height=first_h,
|
||||||
)
|
)
|
||||||
serve_path = os.path.join(self._serve_dir_path, os.path.basename(remote_filename))
|
serve_path = os.path.join(self._serve_dir_path, os.path.basename(remote_filename))
|
||||||
del file_data # RAM freigeben
|
del file_data # RAM freigeben
|
||||||
@@ -2706,22 +3140,25 @@ class KobraXBridge:
|
|||||||
return web.json_response({"error": "color must be [r,g,b]"}, status=400)
|
return web.json_response({"error": "color must be [r,g,b]"}, status=400)
|
||||||
box_id, local_slot = self._global_to_box_slot(index)
|
box_id, local_slot = self._global_to_box_slot(index)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
# setInfo geht über web/printer-Topic (wie tempature/set). Per
|
||||||
|
# Workbench-Vue mqtt_setInfo verifiziert — via slicer/printer/ wurden
|
||||||
|
# die Slot-Änderungen vom Drucker ignoriert und beim nächsten
|
||||||
|
# multiColorBox/report mit dem alten Material überschrieben.
|
||||||
def _send():
|
def _send():
|
||||||
resp = self.client.publish(
|
self.client.publish_web(
|
||||||
"multiColorBox", "setInfo",
|
"multiColorBox", "setInfo",
|
||||||
{"multi_color_box": [{"id": box_id, "slots": [{"index": local_slot, "type": mat, "color": color}]}]},
|
{"multi_color_box": [{"id": box_id, "slots": [{"index": local_slot, "type": mat, "color": color}]}]},
|
||||||
timeout=5
|
|
||||||
)
|
)
|
||||||
log.info(f"setInfo global={index} box={box_id} local_slot={local_slot} type={mat} color={color} → {resp}")
|
log.info(f"setInfo (web) global={index} box={box_id} local_slot={local_slot} type={mat} color={color}")
|
||||||
return resp
|
await loop.run_in_executor(None, _send)
|
||||||
resp = await loop.run_in_executor(None, _send)
|
# Optimistisches Update: cached slot sofort anpassen (Drucker echoed
|
||||||
if resp and resp.get("code") == 200:
|
# gleich via multiColorBox/report — falls er den Befehl ignoriert,
|
||||||
# Update cached slot immediately
|
# überschreibt der Report das wieder).
|
||||||
for s in self._ams_slots:
|
for s in self._ams_slots:
|
||||||
if s.get("global_index") == index:
|
if s.get("global_index") == index:
|
||||||
s["type"] = mat
|
s["type"] = mat
|
||||||
s["color"] = color
|
s["color"] = color
|
||||||
break
|
break
|
||||||
return web.json_response({"result": "ok"})
|
return web.json_response({"result": "ok"})
|
||||||
|
|
||||||
async def handle_api_ams_feed(self, request):
|
async def handle_api_ams_feed(self, request):
|
||||||
@@ -2854,13 +3291,10 @@ class KobraXBridge:
|
|||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
def _send():
|
def _send():
|
||||||
return self.client.publish("multiColorBox", "setDry", payload, timeout=5)
|
return self.client.publish("multiColorBox", "setDry", payload, timeout=0)
|
||||||
|
# Fire-and-forget: setDry ACK arrives via multiColorBox/report callback.
|
||||||
resp = await loop.run_in_executor(None, _send)
|
# Waiting for a response on that busy push topic causes false "code:0" rejections.
|
||||||
if resp is None:
|
await loop.run_in_executor(None, _send)
|
||||||
return web.json_response({"error": "No response from printer"}, status=504)
|
|
||||||
if int(resp.get("code", 200)) != 200:
|
|
||||||
return web.json_response({"error": f"Printer rejected command: {resp}"}, status=502)
|
|
||||||
|
|
||||||
self._state["ace_drying"] = ui_state
|
self._state["ace_drying"] = ui_state
|
||||||
self._state_dirty = True
|
self._state_dirty = True
|
||||||
@@ -3062,6 +3496,10 @@ class KobraXBridge:
|
|||||||
proc.kill()
|
proc.kill()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
await proc.wait()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
@@ -4143,6 +4581,7 @@ def build_app(bridge: KobraXBridge) -> web.Application:
|
|||||||
r.add_get("/printer/objects/subscribe", bridge.handle_objects_subscribe)
|
r.add_get("/printer/objects/subscribe", bridge.handle_objects_subscribe)
|
||||||
r.add_post("/printer/objects/subscribe", bridge.handle_objects_subscribe)
|
r.add_post("/printer/objects/subscribe", bridge.handle_objects_subscribe)
|
||||||
r.add_get("/server/files/list", bridge.handle_files_list)
|
r.add_get("/server/files/list", bridge.handle_files_list)
|
||||||
|
r.add_get("/server/files/metadata", bridge.handle_files_metadata)
|
||||||
r.add_post("/server/files/upload", bridge.handle_file_upload)
|
r.add_post("/server/files/upload", bridge.handle_file_upload)
|
||||||
r.add_post("/printer/print/start", bridge.handle_print_start)
|
r.add_post("/printer/print/start", bridge.handle_print_start)
|
||||||
r.add_post("/printer/print/pause", bridge.handle_print_pause)
|
r.add_post("/printer/print/pause", bridge.handle_print_pause)
|
||||||
@@ -4206,6 +4645,12 @@ 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/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)
|
||||||
|
|||||||
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)
|
||||||
@@ -285,9 +285,9 @@ function applyLang(){
|
|||||||
setText('d-lbl-light',T.lbl_light);
|
setText('d-lbl-light',T.lbl_light);
|
||||||
setText('d-lbl-nozzle',T.label_nozzle);
|
setText('d-lbl-nozzle',T.label_nozzle);
|
||||||
setText('d-lbl-bed',T.label_bed);
|
setText('d-lbl-bed',T.label_bed);
|
||||||
// Dashboard buttons
|
// Dashboard buttons — Pause-Button wird zur Toggle-Action; Resume-Beschriftung
|
||||||
setText('d-btn-pause',T.btn_pause);
|
// wird in updatePauseResumeBtn() je nach Druckerzustand gesetzt.
|
||||||
setText('d-btn-resume',T.btn_resume);
|
updatePauseResumeBtn();
|
||||||
setText('d-btn-cancel',T.btn_cancel);
|
setText('d-btn-cancel',T.btn_cancel);
|
||||||
setText('cam-toggle-btn',camOn?T.btn_cam_stop:T.btn_cam_start);
|
setText('cam-toggle-btn',camOn?T.btn_cam_stop:T.btn_cam_start);
|
||||||
setText('cam-placeholder-txt',T.cam_placeholder);
|
setText('cam-placeholder-txt',T.cam_placeholder);
|
||||||
@@ -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);
|
||||||
@@ -612,11 +623,14 @@ function applyState(){
|
|||||||
}else{frb.style.display='none';}
|
}else{frb.style.display='none';}
|
||||||
}
|
}
|
||||||
// skip-button (mid-print) – nur sichtbar wenn aktuell gedruckt wird
|
// skip-button (mid-print) – nur sichtbar wenn aktuell gedruckt wird
|
||||||
|
var printing=(s.print_state==='printing'||s.print_state==='paused');
|
||||||
var skipBtn=document.getElementById('d-btn-skip');
|
var skipBtn=document.getElementById('d-btn-skip');
|
||||||
if(skipBtn){
|
if(skipBtn) skipBtn.style.display=printing?'':'none';
|
||||||
var printing=(s.print_state==='printing'||s.print_state==='paused');
|
// Pause/Stopp-Buttons nur bei aktivem Druck zeigen (sonst verwirrend wenn
|
||||||
skipBtn.style.display=printing?'':'none';
|
// der Drucker idle ist). Pause-Button rendert sich passend zum State um.
|
||||||
}
|
var ctrlBtns=document.getElementById('d-ctrl-btns');
|
||||||
|
if(ctrlBtns) ctrlBtns.style.display=printing?'':'none';
|
||||||
|
updatePauseResumeBtn();
|
||||||
|
|
||||||
// header
|
// header
|
||||||
var b=document.getElementById('h-badge');
|
var b=document.getElementById('h-badge');
|
||||||
@@ -778,10 +792,17 @@ function applyState(){
|
|||||||
var activity=(slot.activity||'');
|
var activity=(slot.activity||'');
|
||||||
var pct=empty?T.ams_empty:(slot.consumables_percent!=null?slot.consumables_percent+'%':'–');
|
var pct=empty?T.ams_empty:(slot.consumables_percent!=null?slot.consumables_percent+'%':'–');
|
||||||
var slotLabel=T.label_slot+' '+(globalIdx+1);
|
var slotLabel=T.label_slot+' '+(globalIdx+1);
|
||||||
|
var profile=(window._slotProfileMap||{})[globalIdx];
|
||||||
|
var vendorBadge='';
|
||||||
|
if(!empty && profile && profile.vendor){
|
||||||
|
var tt=(profile.name||'')+(profile.id?' ('+profile.id+')':'');
|
||||||
|
vendorBadge='<div class="slot-label" style="font-size:9px;color:var(--accent);font-weight:600;margin-top:1px" title="'+tt+'">'+profile.vendor+'</div>';
|
||||||
|
}
|
||||||
html+='<div class="ams-slot'+(active?' active':'')+(loaded?' loaded':'')+(activity?' '+activity:'')+(empty?' empty':'')
|
html+='<div class="ams-slot'+(active?' active':'')+(loaded?' loaded':'')+(activity?' '+activity:'')+(empty?' empty':'')
|
||||||
+'" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+';cursor:pointer" onclick="openSlotEdit('+i+')">'
|
+'" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+';cursor:pointer" onclick="openSlotEdit('+i+')">'
|
||||||
+'<div class="slot-circle" style="background:'+col+'"></div>'
|
+'<div class="slot-circle" style="background:'+col+'"></div>'
|
||||||
+'<div class="slot-material">'+(empty?'–':(slot.type||slot.material_type||'–'))+'</div>'
|
+'<div class="slot-material">'+(empty?'–':(slot.type||slot.material_type||'–'))+'</div>'
|
||||||
|
+vendorBadge
|
||||||
+'<div class="slot-label">'+slotLabel+'</div>'
|
+'<div class="slot-label">'+slotLabel+'</div>'
|
||||||
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>'
|
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>'
|
||||||
+'<div style="font-size:9px;color:var(--txt2);margin-top:2px">✏</div>'
|
+'<div style="font-size:9px;color:var(--txt2);margin-top:2px">✏</div>'
|
||||||
@@ -893,11 +914,110 @@ 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Custom Filament Profile Import (Issue #41) ──
|
||||||
|
function refreshUserProfileList(){
|
||||||
|
var listEl=document.getElementById('orca-profiles-list');
|
||||||
|
if(!listEl) return;
|
||||||
|
fetch(_apiUrl('/kx/filament/profiles/user')).then(function(r){return r.json();}).then(function(d){
|
||||||
|
var profs=(d && d.result)||[];
|
||||||
|
if(!profs.length){
|
||||||
|
listEl.innerHTML='<i style="color:var(--txt2)">'+(tr('orca_profile_user_empty')||'– keine –')+'</i>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listEl.innerHTML=profs.map(function(p){
|
||||||
|
var label=p.vendor+' / '+p.name+' ('+p.type+')';
|
||||||
|
return '<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;border-bottom:1px solid var(--border)">'
|
||||||
|
+'<span>★ '+label+'</span>'
|
||||||
|
+'<button onclick="deleteUserProfile(\''+encodeURIComponent(p.vendor)+'\',\''+encodeURIComponent(p.name)+'\')" '
|
||||||
|
+'style="background:none;border:none;color:var(--err);cursor:pointer;font-size:14px" title="löschen">🗑</button>'
|
||||||
|
+'</div>';
|
||||||
|
}).join('');
|
||||||
|
}).catch(function(){});
|
||||||
|
}
|
||||||
|
function deleteUserProfile(vendor, name){
|
||||||
|
fetch(_apiUrl('/kx/filament/profiles/user?vendor='+vendor+'&name='+name), {method:'DELETE'})
|
||||||
|
.then(function(r){return r.json();})
|
||||||
|
.then(function(){
|
||||||
|
_orcaFilamentCache=null;
|
||||||
|
refreshUserProfileList();
|
||||||
|
// Falls Import-Dialog offen ist, dort auch refreshen
|
||||||
|
refreshImportDialogList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function openProfileImport(){
|
||||||
|
document.getElementById('profile-import-status').textContent='';
|
||||||
|
refreshImportDialogList();
|
||||||
|
document.getElementById('profile-import-modal').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeProfileImport(){
|
||||||
|
document.getElementById('profile-import-modal').classList.remove('open');
|
||||||
|
}
|
||||||
|
function refreshImportDialogList(){
|
||||||
|
var el=document.getElementById('profile-import-list');
|
||||||
|
if(!el) return;
|
||||||
|
fetch(_apiUrl('/kx/filament/profiles/user')).then(function(r){return r.json();}).then(function(d){
|
||||||
|
var profs=(d && d.result)||[];
|
||||||
|
if(!profs.length){
|
||||||
|
el.innerHTML='<i style="color:var(--txt2)">'+(tr('orca_profile_user_empty')||'– keine –')+'</i>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML=profs.map(function(p){
|
||||||
|
var label=p.vendor+' / '+p.name+' ('+p.type+')';
|
||||||
|
return '<div style="display:flex;justify-content:space-between;align-items:center;padding:4px 6px;border-bottom:1px solid var(--border)">'
|
||||||
|
+'<span>★ '+label+'</span>'
|
||||||
|
+'<button onclick="deleteUserProfile(\''+encodeURIComponent(p.vendor)+'\',\''+encodeURIComponent(p.name)+'\')" '
|
||||||
|
+'style="background:none;border:none;color:var(--err);cursor:pointer;font-size:14px" title="löschen">🗑</button>'
|
||||||
|
+'</div>';
|
||||||
|
}).join('');
|
||||||
|
}).catch(function(){});
|
||||||
|
}
|
||||||
|
function doProfileImportUpload(files){
|
||||||
|
if(!files || !files.length) return;
|
||||||
|
var status=document.getElementById('profile-import-status');
|
||||||
|
status.textContent=(tr('orca_profile_uploading')||'Lade hoch…');
|
||||||
|
status.style.color='var(--txt2)';
|
||||||
|
var done=0, totalAdded=0, totalSkipped=0;
|
||||||
|
function _one(idx){
|
||||||
|
if(idx>=files.length){
|
||||||
|
status.textContent=(tr('orca_profile_done')||'Importiert')+': '+totalAdded
|
||||||
|
+(totalSkipped?' / '+totalSkipped+' '+(tr('orca_profile_skipped')||'übersprungen'):'');
|
||||||
|
status.style.color='var(--ok)';
|
||||||
|
_orcaFilamentCache=null;
|
||||||
|
refreshImportDialogList();
|
||||||
|
refreshUserProfileList();
|
||||||
|
// Wenn Slot-Edit offen ist, Dropdown gleich neu befüllen
|
||||||
|
var mat=document.getElementById('slot-edit-mat');
|
||||||
|
if(mat && document.getElementById('slot-edit-modal').classList.contains('open')){
|
||||||
|
_fillSlotProfileDropdown(mat.value, '', '');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var fd=new FormData();
|
||||||
|
fd.append('file', files[idx]);
|
||||||
|
fetch(_apiUrl('/kx/filament/profiles/user'), {method:'POST', body:fd})
|
||||||
|
.then(function(r){return r.json();})
|
||||||
|
.then(function(d){
|
||||||
|
totalAdded += (d.added||0);
|
||||||
|
totalSkipped += (d.skipped||0);
|
||||||
|
done++;
|
||||||
|
_one(idx+1);
|
||||||
|
})
|
||||||
|
.catch(function(e){
|
||||||
|
status.textContent='Fehler: '+e;
|
||||||
|
status.style.color='var(--err)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_one(0);
|
||||||
|
}
|
||||||
|
|
||||||
// ── AMS Slot Edit ──
|
// ── AMS Slot Edit ──
|
||||||
var _slotEditIndex=-1;
|
var _slotEditIndex=-1;
|
||||||
var _slotEditLoaded=false;
|
var _slotEditLoaded=false;
|
||||||
@@ -920,9 +1040,16 @@ function _loadOrcaFilaments(cb){
|
|||||||
cb(_orcaFilamentCache);
|
cb(_orcaFilamentCache);
|
||||||
}).catch(function(){ cb([]); });
|
}).catch(function(){ cb([]); });
|
||||||
}
|
}
|
||||||
function _fillSlotProfileDropdown(material, currentId){
|
function _profileKey(vendor, name){
|
||||||
|
// Eindeutiger Selector: (vendor, name). IDs aus orca_filaments.json sind
|
||||||
|
// NICHT eindeutig (z.B. 136 Profile mit OGFL99). Wir kodieren beide in den
|
||||||
|
// <option>-Value-String mit | als Trenner.
|
||||||
|
return (vendor||'')+'|'+(name||'');
|
||||||
|
}
|
||||||
|
function _fillSlotProfileDropdown(material, currentVendor, currentName){
|
||||||
var sel=document.getElementById('slot-edit-profile');
|
var sel=document.getElementById('slot-edit-profile');
|
||||||
if(!sel) return;
|
if(!sel) return;
|
||||||
|
var wantKey=_profileKey(currentVendor, currentName);
|
||||||
_loadOrcaFilaments(function(profiles){
|
_loadOrcaFilaments(function(profiles){
|
||||||
// Type-Filter: nur Profile vom passenden material zeigen (z.B. PLA → alle PLA-Varianten)
|
// Type-Filter: nur Profile vom passenden material zeigen (z.B. PLA → alle PLA-Varianten)
|
||||||
var matU=(material||'').toUpperCase().trim();
|
var matU=(material||'').toUpperCase().trim();
|
||||||
@@ -932,18 +1059,31 @@ function _fillSlotProfileDropdown(material, currentId){
|
|||||||
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=p.id; o.dataset.vendor=p.vendor;
|
|
||||||
o.textContent=p.name;
|
|
||||||
if(p.id===currentId) o.selected=true;
|
|
||||||
g.appendChild(o);
|
|
||||||
});
|
|
||||||
sel.appendChild(g);
|
sel.appendChild(g);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -968,15 +1108,18 @@ function openSlotEdit(i){
|
|||||||
+(m===mat?'background:var(--accent);color:#fff':'background:var(--raised);color:var(--txt2)')+'">'+m+'</button>';
|
+(m===mat?'background:var(--accent);color:#fff':'background:var(--raised);color:var(--txt2)')+'">'+m+'</button>';
|
||||||
}).join('');
|
}).join('');
|
||||||
// OrcaSlicer-Profil-Dropdown: aktuellen User-Override für diesen Slot
|
// OrcaSlicer-Profil-Dropdown: aktuellen User-Override für diesen Slot
|
||||||
// aus /kx/filament/slots holen (enthält filament_id+filament_vendor).
|
// aus /kx/filament/slots holen (enthält vendor+name+id).
|
||||||
// Mit dem material-Filter (PLA→PLA*) wird die Liste auf passende Profile reduziert.
|
|
||||||
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json();}).then(function(d){
|
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json();}).then(function(d){
|
||||||
var arr=d.result||[];
|
var arr=d.result||[];
|
||||||
var entry=arr.find(function(x){return x.slot_index===globalIdx;})||{};
|
var entry=arr.find(function(x){return x.slot_index===globalIdx;})||{};
|
||||||
window._slotProfileMap=window._slotProfileMap||{};
|
window._slotProfileMap=window._slotProfileMap||{};
|
||||||
window._slotProfileMap[globalIdx]={id:entry.filament_id||'',vendor:entry.filament_vendor||''};
|
window._slotProfileMap[globalIdx]={
|
||||||
_fillSlotProfileDropdown(mat, entry.filament_id||'');
|
id: entry.filament_id||'',
|
||||||
}).catch(function(){ _fillSlotProfileDropdown(mat,''); });
|
vendor:entry.filament_vendor||'',
|
||||||
|
name: entry.filament_name||'',
|
||||||
|
};
|
||||||
|
_fillSlotProfileDropdown(mat, entry.filament_vendor||'', entry.filament_name||'');
|
||||||
|
}).catch(function(){ _fillSlotProfileDropdown(mat,'',''); });
|
||||||
updateSlotEditFeedButton();
|
updateSlotEditFeedButton();
|
||||||
document.getElementById('slot-edit-modal').classList.add('open');
|
document.getElementById('slot-edit-modal').classList.add('open');
|
||||||
}
|
}
|
||||||
@@ -1023,7 +1166,7 @@ function selectMatPreset(m){
|
|||||||
highlightMatBtn(m);
|
highlightMatBtn(m);
|
||||||
// Filament-Profile-Dropdown an neues Material anpassen
|
// Filament-Profile-Dropdown an neues Material anpassen
|
||||||
// (vorherige Selektion zurücksetzen — andere Material-Profile passen nicht)
|
// (vorherige Selektion zurücksetzen — andere Material-Profile passen nicht)
|
||||||
_fillSlotProfileDropdown(m, '');
|
_fillSlotProfileDropdown(m, '', '');
|
||||||
}
|
}
|
||||||
function highlightMatBtn(val){
|
function highlightMatBtn(val){
|
||||||
document.querySelectorAll('.mat-preset-btn').forEach(function(b){
|
document.querySelectorAll('.mat-preset-btn').forEach(function(b){
|
||||||
@@ -1032,7 +1175,7 @@ function highlightMatBtn(val){
|
|||||||
b.style.color=on?'#fff':'var(--txt2)';
|
b.style.color=on?'#fff':'var(--txt2)';
|
||||||
});
|
});
|
||||||
// Auch bei manueller Eingabe ins Material-Textfeld: Dropdown refreshen.
|
// Auch bei manueller Eingabe ins Material-Textfeld: Dropdown refreshen.
|
||||||
if(val) _fillSlotProfileDropdown(val, '');
|
if(val) _fillSlotProfileDropdown(val, '', '');
|
||||||
}
|
}
|
||||||
function hexToRgb(hex){
|
function hexToRgb(hex){
|
||||||
var r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
|
var r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
|
||||||
@@ -1043,31 +1186,59 @@ function saveSlotEdit(){
|
|||||||
var mat=document.getElementById('slot-edit-mat').value.trim().toUpperCase()||'PLA';
|
var mat=document.getElementById('slot-edit-mat').value.trim().toUpperCase()||'PLA';
|
||||||
var color=hexToRgb(hex);
|
var color=hexToRgb(hex);
|
||||||
var slotIdx=_slotEditIndex;
|
var slotIdx=_slotEditIndex;
|
||||||
// OrcaSlicer-Profil-Override: parallel persistieren (Profile bleiben auch
|
|
||||||
// erhalten wenn der User nur Farbe/Material ändert)
|
|
||||||
var profSel=document.getElementById('slot-edit-profile');
|
var profSel=document.getElementById('slot-edit-profile');
|
||||||
var newProfId=profSel?profSel.value:'';
|
var sel=profSel && profSel.selectedOptions && profSel.selectedOptions[0];
|
||||||
var newProfVendor='';
|
// Primärer Selector: (vendor, name). id ist nur Hint (aus dem JSON-data-attr
|
||||||
if(profSel && profSel.selectedOptions && profSel.selectedOptions[0]){
|
// mitgegeben — Backend lookt sie nochmal selber nach um veraltete Hints zu
|
||||||
newProfVendor=profSel.selectedOptions[0].dataset.vendor||'';
|
// korrigieren).
|
||||||
}
|
var newProfVendor=sel?(sel.dataset.vendor||''):'';
|
||||||
|
var newProfName =sel?(sel.dataset.name ||''):'';
|
||||||
|
var newProfId =sel?(sel.dataset.id ||''):'';
|
||||||
|
// Sequenziell speichern: erst Profil-Override (config.ini), dann Material/Farbe
|
||||||
|
// (MQTT zum Drucker). Sonst können beide Pfade sich überholen und der Slot-State
|
||||||
|
// ist beim nächsten Re-Open inkonsistent.
|
||||||
fetch(_apiUrl('/kx/filament/slots/'+slotIdx+'/profile'),{
|
fetch(_apiUrl('/kx/filament/slots/'+slotIdx+'/profile'),{
|
||||||
method:'POST',
|
method:'POST',
|
||||||
headers:{'Content-Type':'application/json'},
|
headers:{'Content-Type':'application/json'},
|
||||||
body:JSON.stringify({id:newProfId,vendor:newProfVendor})
|
body:JSON.stringify({vendor:newProfVendor, name:newProfName, id:newProfId})
|
||||||
}).then(function(r){return r.json();}).then(function(){
|
})
|
||||||
|
.then(function(r){return r.json();})
|
||||||
|
.then(function(){
|
||||||
window._slotProfileMap=window._slotProfileMap||{};
|
window._slotProfileMap=window._slotProfileMap||{};
|
||||||
if(newProfId){ window._slotProfileMap[slotIdx]={id:newProfId,vendor:newProfVendor}; }
|
if(newProfVendor && newProfName){
|
||||||
else delete window._slotProfileMap[slotIdx];
|
window._slotProfileMap[slotIdx]={id:newProfId, vendor:newProfVendor, name:newProfName};
|
||||||
}).catch(function(e){clog('Profil-Speichern fehlgeschlagen: '+e,'msg-err');});
|
} else {
|
||||||
post('/api/ams/set_slot',{index:slotIdx,type:mat,color:color})
|
delete window._slotProfileMap[slotIdx];
|
||||||
.then(function(r){return r.json();})
|
}
|
||||||
.then(function(r){
|
return post('/api/ams/set_slot',{index:slotIdx,type:mat,color:color});
|
||||||
closeSlotEdit();
|
})
|
||||||
var profSuffix=newProfId?(' ['+newProfId+']'):'';
|
.then(function(r){return r?r.json():null;})
|
||||||
clog(tr('slot_edit_ok')+' '+(slotIdx+1)+': '+mat+' '+hex+profSuffix,'msg-ok');
|
.then(function(){
|
||||||
})
|
// Slot-Map refreshen damit die Karte sofort den Vendor zeigt.
|
||||||
.catch(function(e){clog('Fehler: '+e,'msg-err');});
|
return fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json();});
|
||||||
|
})
|
||||||
|
.then(function(d){
|
||||||
|
var arr=(d && d.result)||[];
|
||||||
|
window._slotProfileMap={};
|
||||||
|
arr.forEach(function(e){
|
||||||
|
if(e.filament_vendor && e.filament_name){
|
||||||
|
window._slotProfileMap[e.slot_index]={
|
||||||
|
id: e.filament_id||'',
|
||||||
|
vendor:e.filament_vendor,
|
||||||
|
name: e.filament_name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
closeSlotEdit();
|
||||||
|
var profSuffix=newProfName?(' ['+newProfVendor+' '+newProfName+']'):'';
|
||||||
|
clog(tr('slot_edit_ok')+' '+(slotIdx+1)+': '+mat+' '+hex+profSuffix,'msg-ok');
|
||||||
|
// Sofortiges Re-Render mit aktuellem _slotProfileMap (poll() ist async
|
||||||
|
// und re-rendert beim nächsten Tick — wir wollen aber dass die Vendor-
|
||||||
|
// Badge JETZT direkt sichtbar wird).
|
||||||
|
if(typeof applyState==='function') applyState();
|
||||||
|
if(typeof poll==='function') poll();
|
||||||
|
})
|
||||||
|
.catch(function(e){clog('Fehler: '+e,'msg-err');});
|
||||||
}
|
}
|
||||||
document.addEventListener('DOMContentLoaded',function(){
|
document.addEventListener('DOMContentLoaded',function(){
|
||||||
document.getElementById('s-printer-ip').addEventListener('input',function(){
|
document.getElementById('s-printer-ip').addEventListener('input',function(){
|
||||||
@@ -1164,6 +1335,21 @@ var pollTimer;
|
|||||||
(function(){
|
(function(){
|
||||||
var ms=parseInt(localStorage.getItem('pollInterval')||'2000');
|
var ms=parseInt(localStorage.getItem('pollInterval')||'2000');
|
||||||
initPrinters();
|
initPrinters();
|
||||||
|
// Slot-Profile-Map initial laden, sonst zeigen die Karten beim ersten
|
||||||
|
// Render keine Vendor-Badge obwohl in der config.ini ein Override steht.
|
||||||
|
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json();}).then(function(d){
|
||||||
|
var arr=(d && d.result)||[];
|
||||||
|
window._slotProfileMap={};
|
||||||
|
arr.forEach(function(e){
|
||||||
|
if(e.filament_vendor && e.filament_name){
|
||||||
|
window._slotProfileMap[e.slot_index]={
|
||||||
|
id: e.filament_id||'',
|
||||||
|
vendor:e.filament_vendor,
|
||||||
|
name: e.filament_name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catch(function(){});
|
||||||
poll();pollTimer=setInterval(poll,ms);
|
poll();pollTimer=setInterval(poll,ms);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -1172,7 +1358,28 @@ function printAction(a){
|
|||||||
post('/printer/print/'+a,{}).then(function(){clog('Druck: '+a,'msg-ok');poll()})
|
post('/printer/print/'+a,{}).then(function(){clog('Druck: '+a,'msg-ok');poll()})
|
||||||
.catch(function(e){clog('Fehler: '+e,'msg-err')});
|
.catch(function(e){clog('Fehler: '+e,'msg-err')});
|
||||||
}
|
}
|
||||||
function confirmCancel(){if(confirm('Druck wirklich abbrechen?'))printAction('cancel')}
|
function togglePauseResume(){
|
||||||
|
// Druckt → pause; Pausiert → resume. Status kommt aus dem zuletzt gepollten
|
||||||
|
// print_state in S; bei Unklarheit (kein State) Pause als Default.
|
||||||
|
var state=(S && S.print_state)||'';
|
||||||
|
if(state==='paused') printAction('resume');
|
||||||
|
else printAction('pause');
|
||||||
|
}
|
||||||
|
function updatePauseResumeBtn(){
|
||||||
|
var btn=document.getElementById('d-btn-pause');
|
||||||
|
if(!btn) return;
|
||||||
|
var state=(S && S.print_state)||'';
|
||||||
|
if(state==='paused'){
|
||||||
|
btn.textContent=T.btn_resume||'▶ Weiter';
|
||||||
|
btn.classList.add('btn-resume');
|
||||||
|
btn.classList.remove('btn-pause');
|
||||||
|
} else {
|
||||||
|
btn.textContent=T.btn_pause||'⏸ Pause';
|
||||||
|
btn.classList.add('btn-pause');
|
||||||
|
btn.classList.remove('btn-resume');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function confirmCancel(){if(confirm(T.confirm_cancel||'Druck wirklich abbrechen?'))printAction('cancel')}
|
||||||
|
|
||||||
// ── Axis motion ──
|
// ── Axis motion ──
|
||||||
// axis codes: 0=X, 1=Y, 2=Z
|
// axis codes: 0=X, 1=Y, 2=Z
|
||||||
|
|||||||
@@ -121,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">
|
||||||
@@ -170,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">
|
||||||
@@ -243,9 +286,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fname" id="d-fname" title="" style="margin-top:6px">–</div>
|
<div class="fname" id="d-fname" title="" style="margin-top:6px">–</div>
|
||||||
<div class="ctrl-btns" id="d-ctrl-btns" style="margin-top:12px">
|
<div class="ctrl-btns" id="d-ctrl-btns" style="margin-top:12px;display:none">
|
||||||
<button class="btn btn-pause btn-sm" id="d-btn-pause" onclick="printAction('pause')">⏸ Pause</button>
|
<button class="btn btn-pause btn-sm" id="d-btn-pause" onclick="togglePauseResume()">⏸ Pause</button>
|
||||||
<button class="btn btn-resume btn-sm" id="d-btn-resume" onclick="printAction('resume')">▶ Weiter</button>
|
|
||||||
<button class="btn btn-skip btn-sm" id="d-btn-skip" onclick="openSkipDialog()" style="display:none">✂ <span id="d-btn-skip-label">Objekte</span></button>
|
<button class="btn btn-skip btn-sm" id="d-btn-skip" onclick="openSkipDialog()" style="display:none">✂ <span id="d-btn-skip-label">Objekte</span></button>
|
||||||
<button class="btn btn-cancel btn-sm" id="d-btn-cancel" onclick="confirmCancel()">✕ Stopp</button>
|
<button class="btn btn-cancel btn-sm" id="d-btn-cancel" onclick="confirmCancel()">✕ Stopp</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -164,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",
|
||||||
|
|||||||
@@ -164,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",
|
||||||
|
|||||||
@@ -20,9 +20,9 @@
|
|||||||
"kobra_finished": "Finalizado",
|
"kobra_finished": "Finalizado",
|
||||||
"kobra_failed": "Error",
|
"kobra_failed": "Error",
|
||||||
"kobra_canceled": "Cancelado",
|
"kobra_canceled": "Cancelado",
|
||||||
"kobra_offline": "Offline",
|
"kobra_offline": "Desconectada",
|
||||||
"nav_dashboard": "Panel",
|
"nav_dashboard": "Panel",
|
||||||
"nav_print": "Impresion",
|
"nav_print": "Impresión",
|
||||||
"nav_temps": "Temperaturas",
|
"nav_temps": "Temperaturas",
|
||||||
"nav_motion": "Movimiento",
|
"nav_motion": "Movimiento",
|
||||||
"nav_ams": "AMS",
|
"nav_ams": "AMS",
|
||||||
@@ -31,12 +31,12 @@
|
|||||||
"card_progress": "Progreso",
|
"card_progress": "Progreso",
|
||||||
"card_temps": "Temperaturas",
|
"card_temps": "Temperaturas",
|
||||||
"card_light_fan": "Ventilador",
|
"card_light_fan": "Ventilador",
|
||||||
"card_speed": "Velocidad de impresion",
|
"card_speed": "Velocidad de impresión",
|
||||||
"card_cam": "Camara",
|
"card_cam": "Cámara",
|
||||||
"lbl_elapsed": "Transcurrido:",
|
"lbl_elapsed": "Transcurrido:",
|
||||||
"lbl_remaining": "Restante:",
|
"lbl_remaining": "Restante:",
|
||||||
"lbl_slicer_time": "Estimacion del slicer:",
|
"lbl_slicer_time": "Estimación del slicer:",
|
||||||
"lbl_layers": "Layer",
|
"lbl_layers": "Capa",
|
||||||
"speed_silent": "🐢 Silencioso",
|
"speed_silent": "🐢 Silencioso",
|
||||||
"speed_normal": "⚡ Normal",
|
"speed_normal": "⚡ Normal",
|
||||||
"speed_sport": "🚀 Sport",
|
"speed_sport": "🚀 Sport",
|
||||||
@@ -52,14 +52,14 @@
|
|||||||
"ace_dry_current_temp": "Temperatura",
|
"ace_dry_current_temp": "Temperatura",
|
||||||
"ace_dry_chart": "Historial (Temp/Humedad)",
|
"ace_dry_chart": "Historial (Temp/Humedad)",
|
||||||
"ace_dry_temp": "Temperatura (°C)",
|
"ace_dry_temp": "Temperatura (°C)",
|
||||||
"ace_dry_duration": "Duracion (min)",
|
"ace_dry_duration": "Duración (min)",
|
||||||
"ace_dry_start": "▶ Start",
|
"ace_dry_start": "▶ Iniciar",
|
||||||
"ace_dry_stop": "■ Stop",
|
"ace_dry_stop": "■ Parar",
|
||||||
"ace_dry_auto_refill": "Relleno automatico",
|
"ace_dry_auto_refill": "Relleno automático",
|
||||||
"ace_dry_enable": "Activar secado",
|
"ace_dry_enable": "Activar secado",
|
||||||
"ace_dry_temp_line": "Temperatura de secado",
|
"ace_dry_temp_line": "Temperatura de secado",
|
||||||
"ace_dry_time_line": "Tiempo de secado",
|
"ace_dry_time_line": "Tiempo de secado",
|
||||||
"ace_dry_ui_pending": "(solo UI, backend despues)",
|
"ace_dry_ui_pending": "(solo UI, backend después)",
|
||||||
"ace_dry_dialog_title": "Ajustes de temp/tiempo del secador",
|
"ace_dry_dialog_title": "Ajustes de temp/tiempo del secador",
|
||||||
"ace_dry_dialog_temp": "Temperatura (30-80°C)",
|
"ace_dry_dialog_temp": "Temperatura (30-80°C)",
|
||||||
"ace_dry_dialog_time": "Tiempo restante (h:m:s)",
|
"ace_dry_dialog_time": "Tiempo restante (h:m:s)",
|
||||||
@@ -67,12 +67,12 @@
|
|||||||
"ace_dry_dialog_cancel": "Cancelar",
|
"ace_dry_dialog_cancel": "Cancelar",
|
||||||
"ace_dry_dialog_save_restart": "Guardar y reiniciar",
|
"ace_dry_dialog_save_restart": "Guardar y reiniciar",
|
||||||
"ace_dry_dialog_custom_name": "Nombre personalizado",
|
"ace_dry_dialog_custom_name": "Nombre personalizado",
|
||||||
"ace_dry_dialog_reset_default": "Restablecer por defecto",
|
"ace_dry_dialog_reset_default": "Restablecer valores predeterminados",
|
||||||
"cam_placeholder": "📷 Camara no iniciada",
|
"cam_placeholder": "📷 Cámara no iniciada",
|
||||||
"cam_stream_unavailable": "Stream no disponible",
|
"cam_stream_unavailable": "Stream no disponible",
|
||||||
"btn_cam_start": "▶ Camara",
|
"btn_cam_start": "▶ Cámara",
|
||||||
"btn_cam_stop": "◼ Camara",
|
"btn_cam_stop": "◼ Cámara",
|
||||||
"btn_pause": "⏸ Pause",
|
"btn_pause": "⏸ Pausa",
|
||||||
"btn_resume": "▶ Reanudar",
|
"btn_resume": "▶ Reanudar",
|
||||||
"btn_cancel": "✕ Detener",
|
"btn_cancel": "✕ Detener",
|
||||||
"label_nozzle": "Boquilla",
|
"label_nozzle": "Boquilla",
|
||||||
@@ -81,20 +81,20 @@
|
|||||||
"label_light": "💡 Luz",
|
"label_light": "💡 Luz",
|
||||||
"label_on_off": "Encendido / Apagado",
|
"label_on_off": "Encendido / Apagado",
|
||||||
"label_speed": "Velocidad",
|
"label_speed": "Velocidad",
|
||||||
"panel_print_title": "Control de impresion",
|
"panel_print_title": "Control de impresión",
|
||||||
"panel_print_btn_pause": "⏸ Pause",
|
"panel_print_btn_pause": "⏸ Pausa",
|
||||||
"panel_print_btn_resume": "▶ Reanudar",
|
"panel_print_btn_resume": "▶ Reanudar",
|
||||||
"panel_print_btn_cancel": "✕ Cancelar",
|
"panel_print_btn_cancel": "✕ Cancelar",
|
||||||
"panel_print_temps_live": "Temperaturas (en vivo)",
|
"panel_print_temps_live": "Temperaturas (en vivo)",
|
||||||
"label_set": "Set",
|
"label_set": "Set",
|
||||||
"label_off": "Off",
|
"label_off": "Apagado",
|
||||||
"panel_temps_nozzle": "Boquilla",
|
"panel_temps_nozzle": "Boquilla",
|
||||||
"panel_temps_bed": "Cama caliente",
|
"panel_temps_bed": "Cama caliente",
|
||||||
"panel_temps_chart": "Historial (ultimas 60 lecturas)",
|
"panel_temps_chart": "Historial (últimas 60 lecturas)",
|
||||||
"label_target_c": "Objetivo:",
|
"label_target_c": "Objetivo:",
|
||||||
"panel_motion_xy": "Ejes XY",
|
"panel_motion_xy": "Ejes XY",
|
||||||
"panel_motion_z": "Eje Z",
|
"panel_motion_z": "Eje Z",
|
||||||
"label_step": "Tamano del paso:",
|
"label_step": "Tamaño del paso:",
|
||||||
"btn_home_z": "Home Z",
|
"btn_home_z": "Home Z",
|
||||||
"btn_home_xy": "Home XY",
|
"btn_home_xy": "Home XY",
|
||||||
"btn_home_all": "Home All",
|
"btn_home_all": "Home All",
|
||||||
@@ -103,11 +103,11 @@
|
|||||||
"card_ams": "Filamento",
|
"card_ams": "Filamento",
|
||||||
"ams_no_data": "No se recibieron datos de AMS",
|
"ams_no_data": "No se recibieron datos de AMS",
|
||||||
"label_slot": "Ranura",
|
"label_slot": "Ranura",
|
||||||
"ams_empty": "Vacio",
|
"ams_empty": "Vacío",
|
||||||
"panel_extras_light": "Luz",
|
"panel_extras_light": "Luz",
|
||||||
"panel_extras_fan": "Ventilador",
|
"panel_extras_fan": "Ventilador",
|
||||||
"panel_extras_camera": "Camara",
|
"panel_extras_camera": "Cámara",
|
||||||
"btn_cam_start2": "▶ Start",
|
"btn_cam_start2": "▶ Iniciar",
|
||||||
"btn_cam_stop2": "◼ Detener",
|
"btn_cam_stop2": "◼ Detener",
|
||||||
"panel_console_title": "Registro de eventos",
|
"panel_console_title": "Registro de eventos",
|
||||||
"log_light_on": "Luz encendida",
|
"log_light_on": "Luz encendida",
|
||||||
@@ -118,29 +118,29 @@
|
|||||||
"log_axis": "Eje",
|
"log_axis": "Eje",
|
||||||
"log_home": "Home",
|
"log_home": "Home",
|
||||||
"log_home_all": "Home All",
|
"log_home_all": "Home All",
|
||||||
"log_cam_start": "Camara iniciada:",
|
"log_cam_start": "Cámara iniciada:",
|
||||||
"log_cam_stop": "Camara detenida",
|
"log_cam_stop": "Cámara detenida",
|
||||||
"log_poll_error": "Error de sondeo:",
|
"log_poll_error": "Error de sondeo:",
|
||||||
"log_error": "Error:",
|
"log_error": "Error:",
|
||||||
"confirm_cancel": "Realmente cancelar la impresion?",
|
"confirm_cancel": "¿Realmente cancelar la impresión?",
|
||||||
"settings_title": "Configuracion",
|
"settings_title": "Configuración",
|
||||||
"settings_connection": "Conexion",
|
"settings_connection": "Conexión",
|
||||||
"settings_print": "Ajustes de impresion",
|
"settings_print": "Ajustes de impresión",
|
||||||
"settings_poll": "Intervalo de sondeo",
|
"settings_poll": "Intervalo de sondeo",
|
||||||
"settings_version": "Version",
|
"settings_version": "Versión",
|
||||||
"settings_save": "Guardar y reiniciar",
|
"settings_save": "Guardar y reiniciar",
|
||||||
"settings_printer_name": "Nombre de impresora",
|
"settings_printer_name": "Nombre de impresora",
|
||||||
"settings_printer_ip": "IP de impresora",
|
"settings_printer_ip": "IP de impresora",
|
||||||
"settings_mqtt_port": "MQTT Port",
|
"settings_mqtt_port": "MQTT Port",
|
||||||
"settings_username": "Usuario MQTT",
|
"settings_username": "Usuario MQTT",
|
||||||
"settings_password": "Contrasena MQTT",
|
"settings_password": "Contraseña MQTT",
|
||||||
"settings_device_id": "ID del dispositivo",
|
"settings_device_id": "ID del dispositivo",
|
||||||
"settings_mode_id": "Mode ID",
|
"settings_mode_id": "ID de modo",
|
||||||
"hint_ip_no_port": "Solo direccion IP, sin puerto (p. ej. 192.168.1.102)",
|
"hint_ip_no_port": "Solo dirección IP, sin puerto (p. ej. 192.168.1.102)",
|
||||||
"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_camera_on_print": "Encender camara al iniciar impresion",
|
"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",
|
||||||
"update_check": "Buscar actualizaciones",
|
"update_check": "Buscar actualizaciones",
|
||||||
"update_checking": "Comprobando...",
|
"update_checking": "Comprobando...",
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
"update_error": "Error",
|
"update_error": "Error",
|
||||||
"btn_connect": "⚡ Conectar",
|
"btn_connect": "⚡ Conectar",
|
||||||
"btn_disconnect": "✕ Desconectar",
|
"btn_disconnect": "✕ Desconectar",
|
||||||
"lbl_conn_error": "Error de conexion:",
|
"lbl_conn_error": "Error de conexión:",
|
||||||
"slot_edit_title": "Editar slot",
|
"slot_edit_title": "Editar slot",
|
||||||
"slot_edit_color": "Color",
|
"slot_edit_color": "Color",
|
||||||
"slot_edit_material": "Material",
|
"slot_edit_material": "Material",
|
||||||
@@ -164,62 +164,75 @@
|
|||||||
"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": "Level:",
|
"log_lvl_label": "Nivel:",
|
||||||
"file_ready_btn": "▶ Iniciar impresion",
|
"file_ready_btn": "▶ Iniciar impresión",
|
||||||
"file_slots_btn": "🎨 Seleccionar ranuras",
|
"file_slots_btn": "🎨 Seleccionar ranuras",
|
||||||
"file_cancel_btn": "✕ Cancelar",
|
"file_cancel_btn": "✕ Cancelar",
|
||||||
"nav_printers": "Impresoras",
|
"nav_printers": "Impresoras",
|
||||||
"skip_title": "✂ Omitir objetos",
|
"skip_title": "✂ Omitir objetos",
|
||||||
"skip_hint": "Desmarca objetos que ya no quieras imprimir:",
|
"skip_hint": "Deselecciona los objetos que ya no quieras imprimir:",
|
||||||
"skip_btn_label": "Objetos",
|
"skip_btn_label": "Objetos",
|
||||||
"skip_no_objects": "No hay objetos en esta impresion.",
|
"skip_no_objects": "No hay objetos en esta impresión.",
|
||||||
"skip_already": "omitido",
|
"skip_already": "omitido",
|
||||||
"skip_select_at_least_one": "Elige al menos un objeto.",
|
"skip_select_at_least_one": "Selecciona al menos un objeto.",
|
||||||
"skip_sending": "Enviando …",
|
"skip_sending": "Enviando …",
|
||||||
"skip_success": "Se omitiran los objetos.",
|
"skip_success": "Se omitirán los objetos.",
|
||||||
"fd_objects_hint": "Omitir objetos (opcional):",
|
"fd_objects_hint": "Omitir objetos (opcional):",
|
||||||
"fd_slots_hint": "Asignar canal GCode a la ranura AMS:",
|
"fd_slots_hint": "Asignar canal GCode a la ranura AMS:",
|
||||||
"fd_cancel": "Cancelar",
|
"fd_cancel": "Cancelar",
|
||||||
"fd_print": "▶ Imprimir",
|
"fd_print": "▶ Imprimir",
|
||||||
"fd_no_slots_msg": "No hay slots AMS cargados.{br}Iniciar impresion de todos modos?",
|
"fd_no_slots_msg": "No hay slots AMS cargados.{br}¿Iniciar impresión de todos modos?",
|
||||||
"fd_slot": "Ranura",
|
"fd_slot": "Ranura",
|
||||||
"fd_no_matching_material": "No hay material compatible",
|
"fd_no_matching_material": "No hay material compatible",
|
||||||
"fd_used": "USADO",
|
"fd_used": "USADO",
|
||||||
"add_printer": "Agregar impresora",
|
"add_printer": "Añadir impresora",
|
||||||
"apd_lbl_ip": "IP de impresora",
|
"apd_lbl_ip": "IP de impresora",
|
||||||
"apd_lbl_name": "Nombre (opcional)",
|
"apd_lbl_name": "Nombre (opcional)",
|
||||||
"apd_placeholder_name": "p. ej. Kobra X Sala",
|
"apd_placeholder_name": "p. ej. Kobra X Sala",
|
||||||
"apd_cancel": "Cancelar",
|
"apd_cancel": "Cancelar",
|
||||||
"apd_confirm": "Agregar",
|
"apd_confirm": "Añadir",
|
||||||
"apd_fetching": "Obteniendo datos de la impresora…",
|
"apd_fetching": "Obteniendo datos de la impresora…",
|
||||||
"apd_success": "Impresora agregada, reiniciando bridge…",
|
"apd_success": "Impresora añadida, reiniciando bridge…",
|
||||||
"apd_err_ip": "Introduce una direccion IP",
|
"apd_err_ip": "Introduce una dirección IP",
|
||||||
"printers_remove": "Eliminar impresora",
|
"printers_remove": "Eliminar impresora",
|
||||||
"printers_remove_confirm": "Eliminar impresora \"{name}\"? El bridge se reiniciara.",
|
"printers_remove_confirm": "¿Eliminar impresora \"{name}\"? El bridge se reiniciará.",
|
||||||
"printers_active": "● activa",
|
"printers_active": "● activa",
|
||||||
"printers_switch": "Cambiar →",
|
"printers_switch": "Cambiar →",
|
||||||
"printers_current": "Impresora actual",
|
"printers_current": "Impresora actual",
|
||||||
"printers_loading": "Cargando…",
|
"printers_loading": "Cargando…",
|
||||||
"printers_none": "No hay impresoras configuradas.",
|
"printers_none": "No hay impresoras configuradas.",
|
||||||
"printers_empty_hint": "Aun no hay impresora configurada.",
|
"printers_empty_hint": "Aún no hay impresora configurada.",
|
||||||
"nav_browser": "Explorador",
|
"nav_browser": "Explorador",
|
||||||
"panel_browser_title": "Explorador de archivos",
|
"panel_browser_title": "Explorador de archivos",
|
||||||
"store_search_placeholder": "🔍 Buscar…",
|
"store_search_placeholder": "🔍 Buscar…",
|
||||||
"store_empty": "Aun no hay archivos subidos.",
|
"store_empty": "Aún no hay archivos subidos.",
|
||||||
"store_refresh": "↻ Actualizar",
|
"store_refresh": "↻ Actualizar",
|
||||||
"store_print": "▶ Imprimir",
|
"store_print": "▶ Imprimir",
|
||||||
"store_download": "⬇ Descargar",
|
"store_download": "⬇ Descargar",
|
||||||
"store_delete_confirm": "Eliminar archivo?",
|
"store_delete_confirm": "¿Eliminar archivo?",
|
||||||
"store_print_confirm": "Imprimir archivo?",
|
"store_print_confirm": "¿Imprimir archivo?",
|
||||||
"store_web_verify_title": "Verificar archivo",
|
"store_web_verify_title": "Verificar archivo",
|
||||||
"store_web_verify_msg": "Verifica que este archivo fue creado para Anycubic Kobra X.",
|
"store_web_verify_msg": "Verifica que este archivo fue creado para Anycubic Kobra X.",
|
||||||
"store_web_verify_confirm": "Confirmar",
|
"store_web_verify_confirm": "Confirmar",
|
||||||
"store_web_verify_abort": "Abortar",
|
"store_web_verify_abort": "Abortar",
|
||||||
"store_no_results": "No se encontraron archivos.",
|
"store_no_results": "No se encontraron archivos.",
|
||||||
"store_never": "nunca impreso",
|
"store_never": "nunca impreso",
|
||||||
"store_estimate": "Estimacion",
|
"store_estimate": "Estimación",
|
||||||
"store_upload_label_prefix": "Arrastra GCode aqui o ",
|
"store_upload_label_prefix": "Arrastra el GCode aquí o ",
|
||||||
"store_upload_label_browse": "buscar",
|
"store_upload_label_browse": "buscar",
|
||||||
"store_upload_busy": "⏳ Subiendo…",
|
"store_upload_busy": "⏳ Subiendo…",
|
||||||
"store_upload_success": "✓ {file}",
|
"store_upload_success": "✓ {file}",
|
||||||
@@ -230,5 +243,5 @@
|
|||||||
"sf_new": "Nuevo",
|
"sf_new": "Nuevo",
|
||||||
"ss_date": "↓ Fecha",
|
"ss_date": "↓ Fecha",
|
||||||
"ss_name": "A–Z Nombre",
|
"ss_name": "A–Z Nombre",
|
||||||
"ss_dur": "⏱ Tiempo de impresion"
|
"ss_dur": "⏱ Tiempo de impresión"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,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