Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b39577ad4d | ||
|
|
cfe46b4cad | ||
|
|
250e89f18a | ||
| 031e34d8ea | |||
|
|
fc89dfffa5 | ||
| ac695ecf36 | |||
| 23b8a69065 | |||
| 22dc58258c | |||
| e4b4d091f3 |
@@ -1,5 +1,74 @@
|
||||
# Changelog
|
||||
|
||||
## [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 aktiv geladene Slot kann
|
||||
während des Drucks nicht umgeschrieben werden — vorher ausziehen.
|
||||
- **Mehrsprachiges UI – spanische Übersetzung von Muttersprachler
|
||||
überarbeitet (PR #40 von @pezfisk):** fehlende Akzente
|
||||
(impresión, cámara, después, animación, …), Begriffe vereinheitlicht
|
||||
(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
|
||||
|
||||
### Neu
|
||||
|
||||
69
CHANGELOG.md
69
CHANGELOG.md
@@ -1,5 +1,74 @@
|
||||
# Changelog
|
||||
|
||||
## [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 currently loaded slot can not
|
||||
be overwritten during a print — unload it first.
|
||||
- **Spanish translation reviewed by a native speaker (PR #40 by
|
||||
@pezfisk):** missing accents (impresión, cámara, después,
|
||||
animación, …) and term consistency (Pause → Pausa, Start →
|
||||
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
|
||||
|
||||
### New
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
Eine Moonraker-kompatible Bridge, die direkt mit dem Drucker spricht.
|
||||
|
||||
<sub>🇬🇧 <a href="README.md">English version</a></sub>
|
||||
<sub>🇬🇧 <a href="README.md">English version</a> · 🇪🇸 <a href="README.es.md">Versión española</a></sub>
|
||||
|
||||
<br>
|
||||
|
||||
|
||||
224
README.es.md
Normal file
224
README.es.md
Normal file
@@ -0,0 +1,224 @@
|
||||
<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>🇬🇧 <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 de filamento, reasignación por canal, emulación MMU para sincronización de filamento en OrcaSlicer |
|
||||
| 🗂️ | **Explorador de GCode** — archivos subidos con vistas previas, historial de impresión, búsqueda y filtros |
|
||||
| 🧩 | **Multi-impresora** — múltiples impresoras en **una** instancia del puente, cambia mediante un menú desplegable |
|
||||
| ➕ | **Añade una impresora con un clic** — solo introduce la IP, las credenciales se importan automáticamente |
|
||||
| 🔄 | **Actualización automática** — instala nuevas versiones directamente desde el navegador |
|
||||
| 🌐 | **OrcaSlicer** — protocolo Moonraker completo (HTTP + WebSocket), interfaz EN/ES |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 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 la mejor experiencia con KX-Bridge ofrecemos una **versión modificada de OrcaSlicer** que
|
||||
incluye tres PRs abiertos de SoftFever/OrcaSlicer: el perfil de la impresora Anycubic Kobra
|
||||
X, una corrección de GCode multicolor y — lo más importante — una corrección de sincronización de
|
||||
filamento Moonraker/Happy-Hare que mantiene las posiciones de las ranuras AMS intactas incluso con una ranura vacía.
|
||||
|
||||
→ **[Lanzamientos de OrcaSlicer-KX](https://gitea.it-drui.de/viewit/OrcaSlicer-KX/releases/latest)** (Linux AppImage + Windows ZIP)
|
||||
|
||||
OrcaSlicer estándar también funciona; la versión modificada mejora principalmente el manejo de AMS.
|
||||
Es una versión basada en [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0);
|
||||
el código fuente está disponible a través de los PRs enlazados.
|
||||
|
||||
---
|
||||
|
||||
## 🏠 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.
|
||||
|
||||
> 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>
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
A Moonraker-compatible bridge that talks directly to the printer.
|
||||
|
||||
<sub>🇩🇪 <a href="README.de.md">Deutsche Version</a></sub>
|
||||
<sub>🇩🇪 <a href="README.de.md">Deutsche Version</a> · 🇪🇸 <a href="README.es.md">Versión española</a></sub>
|
||||
|
||||
<br>
|
||||
|
||||
|
||||
@@ -57,10 +57,15 @@ def _load_config_file(path: pathlib.Path):
|
||||
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
|
||||
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
|
||||
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
|
||||
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
||||
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
||||
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
|
||||
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
|
||||
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
||||
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
||||
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
|
||||
"FLOW_CALIBRATION": (CONFIG_SECTION_PRINT, "flow_calibration"),
|
||||
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
|
||||
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
|
||||
"TIMELAPSE_LOCAL": (CONFIG_SECTION_PRINT, "timelapse_local"),
|
||||
"TIMELAPSE_INTERVAL_SEC": (CONFIG_SECTION_PRINT, "timelapse_interval_sec"),
|
||||
"TIMELAPSE_PRINTER": (CONFIG_SECTION_PRINT, "timelapse_printer"),
|
||||
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
|
||||
}
|
||||
for env_key, (section, option) in mapping.items():
|
||||
@@ -95,10 +100,15 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
|
||||
"device_id": env_vals.get("DEVICE_ID", ""),
|
||||
}
|
||||
cfg[CONFIG_SECTION_PRINT] = {
|
||||
"default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"),
|
||||
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
|
||||
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
|
||||
"web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"),
|
||||
"default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"),
|
||||
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
|
||||
"vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"),
|
||||
"flow_calibration": env_vals.get("FLOW_CALIBRATION", "0"),
|
||||
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
|
||||
"web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"),
|
||||
"timelapse_local": env_vals.get("TIMELAPSE_LOCAL", "0"),
|
||||
"timelapse_interval_sec": env_vals.get("TIMELAPSE_INTERVAL_SEC", "4"),
|
||||
"timelapse_printer": env_vals.get("TIMELAPSE_PRINTER", "0"),
|
||||
}
|
||||
cfg[CONFIG_SECTION_BRIDGE] = {
|
||||
"poll_interval": "3",
|
||||
@@ -169,16 +179,23 @@ def list_printers() -> list[dict]:
|
||||
def list_filament_profiles() -> dict[int, dict]:
|
||||
"""Liest die [filament_profiles]-Sektion aus config.ini.
|
||||
|
||||
Format pro AMS-Slot (slot_N_id + slot_N_vendor):
|
||||
[filament_profiles]
|
||||
slot_0_id = OGFL01
|
||||
slot_0_vendor = Polymaker
|
||||
slot_1_id = OGFG23
|
||||
slot_1_vendor = Polymaker
|
||||
Format pro AMS-Slot — primärer Selector ist (vendor, name), die `id` wird
|
||||
aus der orca_filaments.json beim Speichern nachgeschlagen und mitgeführt
|
||||
(als Hint für OrcaSlicer; das Orca-Datenmodell hat ~136 Profile mit
|
||||
derselben filament_id wie 'OGFL99', d.h. die ID ist nicht eindeutig):
|
||||
|
||||
Gibt einen Dict {slot_index: {"id": ..., "vendor": ...}} zurück.
|
||||
Leere/fehlende Slots werden NICHT aufgenommen — das Default-Mapping
|
||||
[filament_profiles]
|
||||
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.
|
||||
|
||||
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()
|
||||
if not path:
|
||||
@@ -189,7 +206,7 @@ def list_filament_profiles() -> dict[int, dict]:
|
||||
return {}
|
||||
result: dict[int, dict] = {}
|
||||
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_"):
|
||||
continue
|
||||
parts = key.split("_", 2)
|
||||
@@ -200,13 +217,11 @@ def list_filament_profiles() -> dict[int, dict]:
|
||||
except ValueError:
|
||||
continue
|
||||
field = parts[2]
|
||||
if field not in ("id", "vendor"):
|
||||
if field not in ("id", "vendor", "name"):
|
||||
continue
|
||||
if not value.strip():
|
||||
continue
|
||||
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
|
||||
|
||||
|
||||
@@ -214,24 +229,26 @@ def save_filament_profiles(profiles: dict[int, dict]) -> bool:
|
||||
"""Schreibt die übergebenen Slot-Profile in die [filament_profiles]-
|
||||
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()
|
||||
if not path:
|
||||
return False
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
# Sektion neu aufbauen — entfernt damit auch alte/verwaiste Slots
|
||||
if cfg.has_section("filament_profiles"):
|
||||
cfg.remove_section("filament_profiles")
|
||||
if profiles:
|
||||
cfg["filament_profiles"] = {}
|
||||
for slot_idx in sorted(profiles.keys()):
|
||||
entry = profiles[slot_idx] or {}
|
||||
if entry.get("id"):
|
||||
cfg["filament_profiles"][f"slot_{slot_idx}_id"] = entry["id"]
|
||||
if entry.get("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:
|
||||
cfg.write(f)
|
||||
return True
|
||||
@@ -249,6 +266,11 @@ PASSWORD = get("MQTT_PASSWORD", "")
|
||||
MODE_ID = get("MODE_ID", "")
|
||||
DEVICE_ID = get("DEVICE_ID", "")
|
||||
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING","1"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0"))
|
||||
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
||||
FLOW_CALIBRATION = int(get("FLOW_CALIBRATION", "0"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
|
||||
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
|
||||
TIMELAPSE_LOCAL = int(get("TIMELAPSE_LOCAL", "0"))
|
||||
TIMELAPSE_INTERVAL_SEC = int(get("TIMELAPSE_INTERVAL_SEC", "4"))
|
||||
TIMELAPSE_PRINTER = int(get("TIMELAPSE_PRINTER", "0"))
|
||||
|
||||
@@ -179,10 +179,24 @@ class KobraXClient:
|
||||
def connect(self):
|
||||
self._do_connect()
|
||||
self._running = True
|
||||
t = threading.Thread(target=self._read_loop, daemon=True)
|
||||
t.start()
|
||||
self._ensure_reader()
|
||||
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):
|
||||
self._running = False
|
||||
try:
|
||||
@@ -191,20 +205,34 @@ class KobraXClient:
|
||||
pass
|
||||
|
||||
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…")
|
||||
try:
|
||||
self._sock.close()
|
||||
except Exception:
|
||||
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:
|
||||
self._do_connect()
|
||||
log.info("Reconnect erfolgreich")
|
||||
log.info("Reconnect erfolgreich (nach %d Versuchen)", attempt + 1)
|
||||
return True
|
||||
except Exception as e:
|
||||
log.warning("Reconnect fehlgeschlagen (%s), warte %ss…", e, delay)
|
||||
time.sleep(delay)
|
||||
return False
|
||||
attempt += 1
|
||||
lvl = log.warning if attempt <= 5 else log.debug
|
||||
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):
|
||||
with self._lock:
|
||||
@@ -348,6 +376,9 @@ class KobraXClient:
|
||||
# -- Publish + request/response ------------------------------------------
|
||||
|
||||
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())
|
||||
payload = json.dumps({
|
||||
"type": msg_type,
|
||||
@@ -413,6 +444,7 @@ class KobraXClient:
|
||||
|
||||
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)."""
|
||||
self._ensure_reader()
|
||||
msgid = str(uuid.uuid4())
|
||||
payload = json.dumps({
|
||||
"type": msg_type,
|
||||
@@ -429,7 +461,14 @@ class KobraXClient:
|
||||
with self._lock:
|
||||
self._sock.sendall(_build_publish(topic, payload))
|
||||
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 -------------------------------------------------
|
||||
|
||||
|
||||
@@ -226,6 +226,35 @@ def _parse_gcode_estimated_time(data: bytes) -> int:
|
||||
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:
|
||||
"""Extrahiert Base64-PNG-Thumbnail aus GCode (OrcaSlicer-Format)."""
|
||||
try:
|
||||
@@ -378,6 +407,18 @@ class GCodeStore:
|
||||
filament_assignments TEXT,
|
||||
abort_reason TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS timelapses (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL,
|
||||
printer_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
frame_count INTEGER DEFAULT 0,
|
||||
fps INTEGER DEFAULT 24,
|
||||
status TEXT NOT NULL,
|
||||
duration_sec INTEGER DEFAULT 0
|
||||
);
|
||||
""")
|
||||
# Migration: Spalte gcode_filaments nachrüsten falls DB älter
|
||||
try:
|
||||
@@ -386,7 +427,13 @@ class GCodeStore:
|
||||
except Exception:
|
||||
pass
|
||||
# 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:
|
||||
self._conn.execute(f"ALTER TABLE gcode_files ADD COLUMN {col} {typ}")
|
||||
self._conn.commit()
|
||||
@@ -402,7 +449,9 @@ class GCodeStore:
|
||||
def save_file(self, file_id: str, filename: str, data: bytes,
|
||||
est_time_sec: int = 0, thumbnail_b64: str = "",
|
||||
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."""
|
||||
safe_name = os.path.basename(filename)
|
||||
path = os.path.join(self._gcode_dir, safe_name)
|
||||
@@ -413,9 +462,9 @@ class GCodeStore:
|
||||
filaments_json = json.dumps(gcode_filaments) if gcode_filaments else None
|
||||
self._conn.execute(
|
||||
"""INSERT OR REPLACE INTO gcode_files
|
||||
(id, filename, path, size_bytes, uploaded_at, thumbnail_b64, est_print_time_sec, gcode_filaments, web_unverified)
|
||||
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)
|
||||
(id, filename, path, size_bytes, uploaded_at, thumbnail_b64, est_print_time_sec, gcode_filaments, web_unverified, layer_height, first_layer_height)
|
||||
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, layer_height or None, first_layer_height or None)
|
||||
)
|
||||
self._conn.commit()
|
||||
return path
|
||||
@@ -533,6 +582,56 @@ class GCodeStore:
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def create_timelapse(self, job_id: str, printer_id: str, path: str, fps: int = 24) -> str:
|
||||
tid = str(uuid.uuid4())
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
filename = os.path.basename(path)
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"""INSERT INTO timelapses (id, job_id, printer_id, created_at, filename, path, fps, status, frame_count, duration_sec)
|
||||
VALUES (?,?,?,?,?,?,?,'recording',0,0)""",
|
||||
(tid, job_id, printer_id, now, filename, path, fps),
|
||||
)
|
||||
self._conn.commit()
|
||||
return tid
|
||||
|
||||
def update_timelapse(self, tid: str, status: str, frame_count: int = 0, duration_sec: int = 0):
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"UPDATE timelapses SET status=?, frame_count=?, duration_sec=? WHERE id=?",
|
||||
(status, frame_count, duration_sec, tid),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def list_timelapses(self) -> list:
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM timelapses ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_timelapse(self, tid: str) -> "dict | None":
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM timelapses WHERE id=?", (tid,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def delete_timelapse(self, tid: str) -> bool:
|
||||
rec = self.get_timelapse(tid)
|
||||
if not rec:
|
||||
return False
|
||||
path = rec.get("path", "")
|
||||
if path and os.path.isfile(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
with self._lock:
|
||||
self._conn.execute("DELETE FROM timelapses WHERE id=?", (tid,))
|
||||
self._conn.commit()
|
||||
return True
|
||||
|
||||
|
||||
class CameraCache:
|
||||
"""Zentraler Kamera-Demuxer.
|
||||
@@ -627,10 +726,17 @@ class CameraCache:
|
||||
except Exception as e:
|
||||
log.debug(f"CameraCache: jpeg-loop unterbrochen: {e}")
|
||||
finally:
|
||||
try:
|
||||
self._proc_jpeg.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Kill + Wait — sonst bleibt der Child-Prozess als Zombie und
|
||||
# asyncio meldet "Unknown child pid …" beim nächsten reaper-Tick.
|
||||
if self._proc_jpeg is not None:
|
||||
try:
|
||||
self._proc_jpeg.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await self._proc_jpeg.wait()
|
||||
except Exception:
|
||||
pass
|
||||
self._proc_jpeg = None
|
||||
await asyncio.sleep(2.0) # restart delay
|
||||
|
||||
@@ -675,14 +781,109 @@ class CameraCache:
|
||||
except Exception as e:
|
||||
log.debug(f"CameraCache: h264-loop unterbrochen: {e}")
|
||||
finally:
|
||||
try:
|
||||
self._proc_h264.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if self._proc_h264 is not None:
|
||||
try:
|
||||
self._proc_h264.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await self._proc_h264.wait()
|
||||
except Exception:
|
||||
pass
|
||||
self._proc_h264 = None
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
|
||||
class TimelapseSpool:
|
||||
"""Erfasst JPEG-Frames von CameraCache während eines Drucks und kodiert sie
|
||||
nach Druckende zu einer MP4-Datei mit ffmpeg."""
|
||||
|
||||
def __init__(self, camera: CameraCache, timelapse_dir: str):
|
||||
self._camera = camera
|
||||
self._dir = timelapse_dir
|
||||
self._task: "asyncio.Task | None" = None
|
||||
self._frame_dir: str = ""
|
||||
self._frame_count: int = 0
|
||||
self._last_ts: float = 0.0
|
||||
|
||||
async def start(self, job_id: str, interval_sec: float = 4.0):
|
||||
safe_id = job_id.replace("/", "_").replace("\\", "_")
|
||||
self._frame_dir = os.path.join(self._dir, safe_id, "frames")
|
||||
os.makedirs(self._frame_dir, exist_ok=True)
|
||||
self._frame_count = 0
|
||||
self._last_ts = 0.0
|
||||
self._task = asyncio.create_task(self._capture_loop(interval_sec))
|
||||
log.info(f"Timelapse-Aufnahme gestartet: {self._frame_dir} @ {interval_sec}s Intervall")
|
||||
|
||||
async def stop(self, output_fps: int = 24) -> "str | None":
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
if self._frame_count == 0:
|
||||
log.warning("Timelapse: keine Frames aufgenommen")
|
||||
return None
|
||||
|
||||
job_dir = os.path.dirname(self._frame_dir)
|
||||
output_path = job_dir.rstrip("/\\") + ".mp4"
|
||||
ok = await self._encode(output_path, output_fps)
|
||||
|
||||
import shutil
|
||||
try:
|
||||
shutil.rmtree(job_dir)
|
||||
except Exception as e:
|
||||
log.warning(f"Timelapse: Frames-Verzeichnis löschen fehlgeschlagen: {e}")
|
||||
|
||||
return output_path if ok else None
|
||||
|
||||
async def _capture_loop(self, interval_sec: float):
|
||||
while True:
|
||||
try:
|
||||
ts = self._camera.latest_jpeg_ts
|
||||
frame = self._camera.latest_jpeg
|
||||
if ts > self._last_ts and frame:
|
||||
path = os.path.join(self._frame_dir, f"frame_{self._frame_count:05d}.jpg")
|
||||
with open(path, "wb") as f:
|
||||
f.write(frame)
|
||||
self._frame_count += 1
|
||||
self._last_ts = ts
|
||||
except Exception as e:
|
||||
log.warning(f"Timelapse capture-Fehler: {e}")
|
||||
await asyncio.sleep(interval_sec)
|
||||
|
||||
async def _encode(self, output_path: str, fps: int) -> bool:
|
||||
try:
|
||||
ffmpeg = _find_ffmpeg()
|
||||
pattern = os.path.join(self._frame_dir, "frame_%05d.jpg")
|
||||
cmd = [
|
||||
ffmpeg, "-y", "-loglevel", "error",
|
||||
"-framerate", str(fps),
|
||||
"-i", pattern,
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart",
|
||||
output_path,
|
||||
]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
log.error(f"Timelapse encode fehlgeschlagen (rc={proc.returncode}): "
|
||||
f"{stderr.decode(errors='replace')[:300]}")
|
||||
return False
|
||||
log.info(f"Timelapse kodiert: {output_path} ({self._frame_count} Frames @ {fps}fps)")
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error(f"Timelapse encode Ausnahme: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class KobraXBridge:
|
||||
def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None):
|
||||
self.client = client
|
||||
@@ -717,6 +918,12 @@ class KobraXBridge:
|
||||
"remain_time": 0,
|
||||
"curr_layer": 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"),
|
||||
"firmware_version": "unknown",
|
||||
"upload_url": "",
|
||||
@@ -741,9 +948,15 @@ class KobraXBridge:
|
||||
self._last_uploaded_file: str = ""
|
||||
self._store = store if store is not None else GCodeStore(args.data_dir)
|
||||
self._serve_dir_path: str = self._store._gcode_dir
|
||||
self._timelapse_dir: str = os.path.join(args.data_dir, "timelapses")
|
||||
os.makedirs(self._timelapse_dir, exist_ok=True)
|
||||
self._current_job_id: str = ""
|
||||
self._camera_autostarted: bool = False
|
||||
self._timelapse_spool: "TimelapseSpool | None" = None
|
||||
self._current_timelapse_id: str = ""
|
||||
self._timelapse_autostarted: bool = False
|
||||
self.camera_cache: CameraCache = CameraCache()
|
||||
self._loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
|
||||
|
||||
self._thumbnail_b64: str = ""
|
||||
self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config()
|
||||
@@ -869,6 +1082,18 @@ class KobraXBridge:
|
||||
elif kobra_state in ("free", "finished", "stoped", "canceled"):
|
||||
self._camera_autostarted = False
|
||||
|
||||
# Timelapse: Druckstart
|
||||
if kobra_state == "printing" and not self._timelapse_autostarted:
|
||||
if getattr(self._args, "timelapse_local", 0):
|
||||
self._timelapse_autostarted = True
|
||||
asyncio.run_coroutine_threadsafe(self._start_timelapse(), self._loop)
|
||||
elif kobra_state in ("finished", "stoped", "canceled"):
|
||||
self._timelapse_autostarted = False
|
||||
if self._timelapse_spool:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._stop_timelapse(success=(kobra_state == "finished")), self._loop
|
||||
)
|
||||
|
||||
# Job-History: Druckstart erkennen
|
||||
if kobra_state == "printing" and not self._current_job_id:
|
||||
filename = d.get("filename", self._state.get("filename", ""))
|
||||
@@ -903,6 +1128,8 @@ class KobraXBridge:
|
||||
self._state["print_duration"] = 0
|
||||
self._state["remain_time"] = 0
|
||||
self._state["slicer_time"] = 0
|
||||
self._state["layer_height"] = 0.0
|
||||
self._state["first_layer_height"] = 0.0
|
||||
self._thumbnail_b64 = ""
|
||||
self._state["filename"] = d.get("filename", self._state["filename"])
|
||||
if "progress" in d:
|
||||
@@ -953,6 +1180,13 @@ class KobraXBridge:
|
||||
log.warning(f"Kamera-Autostart fehlgeschlagen: {e}")
|
||||
elif kobra_state in ("free", "finished", "stoped", "canceled"):
|
||||
self._camera_autostarted = False
|
||||
# Timelapse-Autostart auch hier (Guard verhindert Doppel-Start mit _on_print).
|
||||
if kobra_state == "printing" and not self._timelapse_autostarted:
|
||||
if getattr(self._args, "timelapse_local", 0):
|
||||
self._timelapse_autostarted = True
|
||||
asyncio.run_coroutine_threadsafe(self._start_timelapse(), self._loop)
|
||||
elif kobra_state in ("free", "finished", "stoped", "canceled"):
|
||||
self._timelapse_autostarted = False
|
||||
if project:
|
||||
if "filename" in project:
|
||||
self._state["filename"] = project["filename"]
|
||||
@@ -1489,9 +1723,19 @@ class KobraXBridge:
|
||||
# Vendor wird mitgesendet (tray_sub_brands + filament_vendor),
|
||||
# damit ein gepatchter OrcaSlicer den Match nach Marke + Type +
|
||||
# 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 {}
|
||||
tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99")
|
||||
vendor = user_profile.get("vendor", "")
|
||||
if user_profile.get("name"):
|
||||
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:
|
||||
vendor = ""
|
||||
fila_name = ""
|
||||
tray_info_idx = self._TRAY_INFO_IDX.get(material, "OGFL99")
|
||||
tray_array.append({
|
||||
"id": str(slot_id),
|
||||
"tag_uid": "0000000000000000",
|
||||
@@ -1499,7 +1743,17 @@ class KobraXBridge:
|
||||
"tray_type": material,
|
||||
"tray_color": color_hex,
|
||||
"tray_sub_brands": vendor,
|
||||
"filament_vendor": vendor, # OrcaSlicer-Patch-Ready (Snapmaker-Stil)
|
||||
"filament_vendor": vendor,
|
||||
# Für den OrcaSlicer-Empfangs-Patch (Variante 2,
|
||||
# MoonrakerPrinterAgent.cpp): `filament_id` direkt
|
||||
# übernehmen (exakt), sonst `preset`-Name per
|
||||
# find_preset() auflösen. tray_info_idx ist im Orca-
|
||||
# Datenmodell nicht eindeutig (z.B. OGFL99 für 136
|
||||
# Profile), aber der Bare-Name aus orca_filaments.json
|
||||
# ist eindeutig — find_preset() parsed @-Suffixe weg.
|
||||
"filament_id": tray_info_idx,
|
||||
"preset": fila_name,
|
||||
"filament_name": fila_name, # ältere Aliase
|
||||
})
|
||||
else:
|
||||
tray_array.append({
|
||||
@@ -1519,6 +1773,68 @@ class KobraXBridge:
|
||||
"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
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -1639,15 +1955,18 @@ class KobraXBridge:
|
||||
"state_message": "Printer is ready",
|
||||
},
|
||||
# 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": {
|
||||
"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,
|
||||
"speed": 0,
|
||||
"gcode_position": [0, 0, 0, 0],
|
||||
"gcode_position": [0, 0, self._estimate_current_z(), 0],
|
||||
"absolute_coordinates": True,
|
||||
"absolute_extrude": True,
|
||||
"homing_origin": [0, 0, 0, 0],
|
||||
"position": [0, 0, 0, 0],
|
||||
"position": [0, 0, self._estimate_current_z(), 0],
|
||||
},
|
||||
"fan": {
|
||||
"speed": (int(s.get("fan_speed") or 0)) / 100.0,
|
||||
@@ -1780,8 +2099,10 @@ class KobraXBridge:
|
||||
"status": "loaded" if s.get("status") == 5 else "empty",
|
||||
"nozzle_temp": 0,
|
||||
# Aktueller User-Override aus config.ini [filament_profiles]
|
||||
# — (vendor,name) ist eindeutig, id ist nur Hint.
|
||||
"filament_id": profile.get("id", ""),
|
||||
"filament_vendor": profile.get("vendor", ""),
|
||||
"filament_name": profile.get("name", ""),
|
||||
})
|
||||
return self._json_cors({"result": slots})
|
||||
|
||||
@@ -1795,15 +2116,7 @@ class KobraXBridge:
|
||||
"""
|
||||
type_filter = request.rel_url.query.get("type", "").upper().strip()
|
||||
vendor_filter = request.rel_url.query.get("vendor", "").strip()
|
||||
data_path = self._find_orca_filaments_json()
|
||||
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": []})
|
||||
profiles = self._load_orca_filaments()
|
||||
if type_filter:
|
||||
profiles = [p for p in profiles if p.get("type", "").upper() == type_filter]
|
||||
if vendor_filter:
|
||||
@@ -1837,8 +2150,15 @@ class KobraXBridge:
|
||||
"""POST /kx/filament/slots/<idx>/profile — speichert oder löscht
|
||||
ein User-Override-Mapping für einen einzelnen AMS-Slot.
|
||||
|
||||
Body: {"id": "OGFL01", "vendor": "Polymaker"}
|
||||
{"id": ""} → Mapping entfernen → Default-Fallback aktiv
|
||||
Primärer Selector ist (vendor, name) — die ID ist im Orca-Datenmodell
|
||||
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:
|
||||
slot_idx = int(request.match_info.get("idx", "-1"))
|
||||
@@ -1850,10 +2170,18 @@ class KobraXBridge:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
new_id = (data.get("id") or "").strip()
|
||||
new_vendor = (data.get("vendor") or "").strip()
|
||||
if new_id:
|
||||
self._filament_profiles[slot_idx] = {"id": new_id, "vendor": new_vendor}
|
||||
new_name = (data.get("name") or "").strip()
|
||||
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:
|
||||
self._filament_profiles.pop(slot_idx, None)
|
||||
# Persistieren in config.ini
|
||||
@@ -1863,10 +2191,36 @@ class KobraXBridge:
|
||||
except Exception as e:
|
||||
log.warning(f"save_filament_profiles failed: {e}")
|
||||
return self._json_cors({"error": str(e)}, status=500)
|
||||
entry = self._filament_profiles.get(slot_idx, {})
|
||||
return self._json_cors({"result": "ok",
|
||||
"slot_index": slot_idx,
|
||||
"id": new_id,
|
||||
"vendor": new_vendor})
|
||||
"vendor": entry.get("vendor", ""),
|
||||
"name": entry.get("name", ""),
|
||||
"id": entry.get("id", "")})
|
||||
|
||||
def _load_orca_filaments(self) -> list[dict]:
|
||||
"""Lädt orca_filaments.json einmalig in den Cache. Bei wiederholten
|
||||
Aufrufen wird die Liste aus dem RAM geliefert."""
|
||||
if getattr(self, "_orca_filaments_cache", None) is not None:
|
||||
return self._orca_filaments_cache
|
||||
self._orca_filaments_cache = []
|
||||
data_path = self._find_orca_filaments_json()
|
||||
if not data_path or not os.path.isfile(data_path):
|
||||
return self._orca_filaments_cache
|
||||
try:
|
||||
with open(data_path, encoding="utf-8") as f:
|
||||
self._orca_filaments_cache = json.load(f) or []
|
||||
except Exception as e:
|
||||
log.warning(f"orca_filaments.json read error: {e}")
|
||||
return self._orca_filaments_cache
|
||||
|
||||
def _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):
|
||||
limit = int(request.rel_url.query.get("limit", 50))
|
||||
@@ -2020,7 +2374,9 @@ class KobraXBridge:
|
||||
ams_box_mapping = self._build_auto_ams_box_mapping()
|
||||
|
||||
use_ams = len(ams_box_mapping) > 0
|
||||
auto_leveling = getattr(self._args, "auto_leveling", 1)
|
||||
auto_leveling = getattr(self._args, "auto_leveling", 1)
|
||||
vibration_compensation = getattr(self._args, "vibration_compensation", 0)
|
||||
flow_calibration = getattr(self._args, "flow_calibration", 0)
|
||||
filename = gcode_file["filename"]
|
||||
file_path = gcode_file["path"]
|
||||
|
||||
@@ -2042,11 +2398,11 @@ class KobraXBridge:
|
||||
},
|
||||
"task_settings": {
|
||||
"auto_leveling": auto_leveling,
|
||||
"vibration_compensation": 0,
|
||||
"flow_calibration": 0,
|
||||
"vibration_compensation": vibration_compensation,
|
||||
"flow_calibration": flow_calibration,
|
||||
"dry_mode": 0,
|
||||
"ai_settings": {"status": 0, "count": 0, "type": 1},
|
||||
"timelapse": {"status": 0, "count": 0, "type": 64},
|
||||
"timelapse": {"status": getattr(self._args, "timelapse_printer", 0), "count": 0, "type": 64},
|
||||
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
|
||||
"model_objects_skip_parts": excluded_objects,
|
||||
},
|
||||
@@ -2164,6 +2520,52 @@ class KobraXBridge:
|
||||
})
|
||||
return web.json_response({"result": files})
|
||||
|
||||
async def handle_files_metadata(self, request):
|
||||
"""Moonraker /server/files/metadata — moonraker-obico-Plugin holt das
|
||||
einmal pro Druck und liest daraus `object_height` (für `currentZ`-
|
||||
Anzeige im Obico-UI: `mmProgress` braucht maxZ), `layer_count`,
|
||||
`layer_height` und `first_layer_height` (für die Layer-Berechnung).
|
||||
|
||||
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 ──────────────────────────────────
|
||||
async def handle_access_api_key(self, request):
|
||||
"""Moonraker /access/api_key — wir haben keine Auth, geben einen Dummy zurück.
|
||||
@@ -2290,6 +2692,9 @@ class KobraXBridge:
|
||||
self._state["slicer_time"] = est_time
|
||||
thumbnail_b64 = _extract_thumbnail(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
|
||||
self._store.save_file(
|
||||
@@ -2300,6 +2705,8 @@ class KobraXBridge:
|
||||
thumbnail_b64=thumbnail_b64,
|
||||
gcode_filaments=gcode_filaments or None,
|
||||
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))
|
||||
del file_data # RAM freigeben
|
||||
@@ -2391,7 +2798,9 @@ class KobraXBridge:
|
||||
use_ams = len(loaded) > 0
|
||||
ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded)
|
||||
log.debug(f"AMS-Slots: {len(loaded)} gemappt (genutzte Paints: {used_paint_indices}) → {[i for i, _ in loaded]}")
|
||||
auto_leveling = getattr(self._args, "auto_leveling", 1)
|
||||
auto_leveling = getattr(self._args, "auto_leveling", 1)
|
||||
vibration_compensation = getattr(self._args, "vibration_compensation", 0)
|
||||
flow_calibration = getattr(self._args, "flow_calibration", 0)
|
||||
payload = {
|
||||
"taskid": "-1",
|
||||
"url": url,
|
||||
@@ -2407,11 +2816,11 @@ class KobraXBridge:
|
||||
},
|
||||
"task_settings": {
|
||||
"auto_leveling": auto_leveling,
|
||||
"vibration_compensation": 0,
|
||||
"flow_calibration": 0,
|
||||
"vibration_compensation": vibration_compensation,
|
||||
"flow_calibration": flow_calibration,
|
||||
"dry_mode": 0,
|
||||
"ai_settings": {"status": 0, "count": 0, "type": 1},
|
||||
"timelapse": {"status": 0, "count": 0, "type": 64},
|
||||
"timelapse": {"status": getattr(self._args, "timelapse_printer", 0), "count": 0, "type": 64},
|
||||
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
|
||||
"model_objects_skip_parts": [],
|
||||
},
|
||||
@@ -2479,7 +2888,9 @@ class KobraXBridge:
|
||||
ams_box_mapping = self._build_auto_ams_box_mapping()
|
||||
|
||||
use_ams = len(ams_box_mapping) > 0
|
||||
auto_leveling = getattr(self._args, "auto_leveling", 1)
|
||||
auto_leveling = getattr(self._args, "auto_leveling", 1)
|
||||
vibration_compensation = getattr(self._args, "vibration_compensation", 0)
|
||||
flow_calibration = getattr(self._args, "flow_calibration", 0)
|
||||
url = self._state.get("last_upload_url", "")
|
||||
filesize = self._state.get("last_upload_size", 0)
|
||||
md5 = self._state.get("last_upload_md5", "")
|
||||
@@ -2499,11 +2910,11 @@ class KobraXBridge:
|
||||
},
|
||||
"task_settings": {
|
||||
"auto_leveling": auto_leveling,
|
||||
"vibration_compensation": 0,
|
||||
"flow_calibration": 0,
|
||||
"vibration_compensation": vibration_compensation,
|
||||
"flow_calibration": flow_calibration,
|
||||
"dry_mode": 0,
|
||||
"ai_settings": {"status": 0, "count": 0, "type": 0},
|
||||
"timelapse": {"status": 0, "count": 0, "type": 0},
|
||||
"timelapse": {"status": getattr(self._args, "timelapse_printer", 0), "count": 0, "type": 0},
|
||||
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
|
||||
"model_objects_skip_parts": excluded_objects,
|
||||
},
|
||||
@@ -2706,22 +3117,25 @@ class KobraXBridge:
|
||||
return web.json_response({"error": "color must be [r,g,b]"}, status=400)
|
||||
box_id, local_slot = self._global_to_box_slot(index)
|
||||
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():
|
||||
resp = self.client.publish(
|
||||
self.client.publish_web(
|
||||
"multiColorBox", "setInfo",
|
||||
{"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}")
|
||||
return resp
|
||||
resp = await loop.run_in_executor(None, _send)
|
||||
if resp and resp.get("code") == 200:
|
||||
# Update cached slot immediately
|
||||
for s in self._ams_slots:
|
||||
if s.get("global_index") == index:
|
||||
s["type"] = mat
|
||||
s["color"] = color
|
||||
break
|
||||
log.info(f"setInfo (web) global={index} box={box_id} local_slot={local_slot} type={mat} color={color}")
|
||||
await loop.run_in_executor(None, _send)
|
||||
# Optimistisches Update: cached slot sofort anpassen (Drucker echoed
|
||||
# gleich via multiColorBox/report — falls er den Befehl ignoriert,
|
||||
# überschreibt der Report das wieder).
|
||||
for s in self._ams_slots:
|
||||
if s.get("global_index") == index:
|
||||
s["type"] = mat
|
||||
s["color"] = color
|
||||
break
|
||||
return web.json_response({"result": "ok"})
|
||||
|
||||
async def handle_api_ams_feed(self, request):
|
||||
@@ -2854,13 +3268,10 @@ class KobraXBridge:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _send():
|
||||
return self.client.publish("multiColorBox", "setDry", payload, timeout=5)
|
||||
|
||||
resp = await loop.run_in_executor(None, _send)
|
||||
if resp is None:
|
||||
return web.json_response({"error": "No response from printer"}, status=504)
|
||||
if int(resp.get("code", 200)) != 200:
|
||||
return web.json_response({"error": f"Printer rejected command: {resp}"}, status=502)
|
||||
return self.client.publish("multiColorBox", "setDry", payload, timeout=0)
|
||||
# Fire-and-forget: setDry ACK arrives via multiColorBox/report callback.
|
||||
# Waiting for a response on that busy push topic causes false "code:0" rejections.
|
||||
await loop.run_in_executor(None, _send)
|
||||
|
||||
self._state["ace_drying"] = ui_state
|
||||
self._state_dirty = True
|
||||
@@ -3062,6 +3473,10 @@ class KobraXBridge:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return resp
|
||||
|
||||
@@ -3271,6 +3686,97 @@ class KobraXBridge:
|
||||
self._ams_loaded_slot = global_loaded
|
||||
return self._ams_slots
|
||||
|
||||
# ─── Timelapse ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _start_timelapse(self):
|
||||
camera_url = self._state.get("camera_url", "")
|
||||
if not camera_url:
|
||||
log.info("Timelapse: kein Kamera-URL bekannt — übersprungen")
|
||||
return
|
||||
await self.camera_cache.ensure_running()
|
||||
interval = float(getattr(self._args, "timelapse_interval_sec", 4))
|
||||
spool = TimelapseSpool(self.camera_cache, self._timelapse_dir)
|
||||
job_id = self._current_job_id or str(uuid.uuid4())
|
||||
safe_id = job_id.replace("/", "_").replace("\\", "_")
|
||||
mp4_path = os.path.join(self._timelapse_dir, safe_id + ".mp4")
|
||||
tid = self._store.create_timelapse(
|
||||
job_id=job_id,
|
||||
printer_id=self._printer_id,
|
||||
path=mp4_path,
|
||||
fps=24,
|
||||
)
|
||||
self._timelapse_spool = spool
|
||||
self._current_timelapse_id = tid
|
||||
await spool.start(job_id, interval_sec=interval)
|
||||
log.info(f"Timelapse ID={tid} für Job {job_id} gestartet")
|
||||
|
||||
async def _stop_timelapse(self, success: bool = True):
|
||||
spool = self._timelapse_spool
|
||||
if not spool:
|
||||
return
|
||||
self._timelapse_spool = None
|
||||
tid = self._current_timelapse_id
|
||||
self._current_timelapse_id = ""
|
||||
frame_count = spool._frame_count
|
||||
self._store.update_timelapse(tid, "processing", frame_count=frame_count)
|
||||
mp4_path = await spool.stop(output_fps=24)
|
||||
if mp4_path:
|
||||
interval = float(getattr(self._args, "timelapse_interval_sec", 4))
|
||||
duration_sec = int(frame_count * interval)
|
||||
self._store.update_timelapse(tid, "completed", frame_count=frame_count,
|
||||
duration_sec=duration_sec)
|
||||
log.info(f"Timelapse {tid} abgeschlossen: {mp4_path}")
|
||||
else:
|
||||
self._store.update_timelapse(tid, "failed", frame_count=frame_count)
|
||||
log.warning(f"Timelapse {tid} fehlgeschlagen")
|
||||
|
||||
async def handle_kx_timelapses(self, request):
|
||||
rows = self._store.list_timelapses()
|
||||
# Laufende Aufnahme in der DB-Ansicht sichtbar machen
|
||||
if self._current_timelapse_id:
|
||||
spool = self._timelapse_spool
|
||||
for r in rows:
|
||||
if r["id"] == self._current_timelapse_id and spool:
|
||||
r["frame_count"] = spool._frame_count
|
||||
return self._json_cors(rows)
|
||||
|
||||
async def handle_kx_timelapse_download(self, request):
|
||||
tid = request.match_info["tid"]
|
||||
rec = self._store.get_timelapse(tid)
|
||||
if not rec or not os.path.isfile(rec.get("path", "")):
|
||||
return web.Response(status=404)
|
||||
filename = rec.get("filename") or os.path.basename(rec["path"])
|
||||
return web.FileResponse(
|
||||
rec["path"],
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
async def handle_kx_timelapse_thumb(self, request):
|
||||
tid = request.match_info["tid"]
|
||||
rec = self._store.get_timelapse(tid)
|
||||
if not rec:
|
||||
return web.Response(status=404)
|
||||
job_id = rec.get("job_id", "")
|
||||
safe_id = job_id.replace("/", "_").replace("\\", "_")
|
||||
frame_dir = os.path.join(self._timelapse_dir, safe_id, "frames")
|
||||
try:
|
||||
if os.path.isdir(frame_dir):
|
||||
frames = sorted(f for f in os.listdir(frame_dir) if f.endswith(".jpg"))
|
||||
if frames:
|
||||
with open(os.path.join(frame_dir, frames[0]), "rb") as f:
|
||||
data = f.read()
|
||||
return web.Response(body=data, content_type="image/jpeg")
|
||||
except Exception:
|
||||
pass
|
||||
return web.Response(status=404)
|
||||
|
||||
async def handle_kx_timelapse_delete(self, request):
|
||||
tid = request.match_info["tid"]
|
||||
ok = self._store.delete_timelapse(tid)
|
||||
if not ok:
|
||||
return self._json_cors({"error": "not found"}, status=404)
|
||||
return self._json_cors({"status": "deleted"})
|
||||
|
||||
# ─── Settings ────────────────────────────────────────────────────────────
|
||||
|
||||
def _find_config_path(self) -> pathlib.Path:
|
||||
@@ -3294,10 +3800,15 @@ class KobraXBridge:
|
||||
"password": self._args.password,
|
||||
"mode_id": self._args.mode_id,
|
||||
"device_id": self._args.device_id,
|
||||
"default_ams_slot": getattr(self._args, "default_ams_slot", "auto"),
|
||||
"auto_leveling": getattr(self._args, "auto_leveling", 1),
|
||||
"camera_on_print": getattr(self._args, "camera_on_print", 0),
|
||||
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
|
||||
"default_ams_slot": getattr(self._args, "default_ams_slot", "auto"),
|
||||
"auto_leveling": getattr(self._args, "auto_leveling", 1),
|
||||
"vibration_compensation": getattr(self._args, "vibration_compensation", 0),
|
||||
"flow_calibration": getattr(self._args, "flow_calibration", 0),
|
||||
"camera_on_print": getattr(self._args, "camera_on_print", 0),
|
||||
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
|
||||
"timelapse_local": getattr(self._args, "timelapse_local", 0),
|
||||
"timelapse_interval_sec": getattr(self._args, "timelapse_interval_sec", 4),
|
||||
"timelapse_printer": getattr(self._args, "timelapse_printer", 0),
|
||||
"ace_dry_presets": self._ace_dry_presets,
|
||||
})
|
||||
|
||||
@@ -3324,10 +3835,15 @@ class KobraXBridge:
|
||||
cfg.set("connection", "password", str(data.get("password", self._args.password or "")))
|
||||
cfg.set("connection", "mode_id", str(data.get("mode_id", self._args.mode_id or "")))
|
||||
cfg.set("connection", "device_id", str(data.get("device_id", self._args.device_id or "")))
|
||||
cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto"))))
|
||||
cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1))))
|
||||
cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0))))))
|
||||
cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1))))))
|
||||
cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto"))))
|
||||
cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1))))
|
||||
cfg.set("print", "vibration_compensation", str(int(bool(data.get("vibration_compensation", getattr(self._args, "vibration_compensation", 0))))))
|
||||
cfg.set("print", "flow_calibration", str(int(bool(data.get("flow_calibration", getattr(self._args, "flow_calibration", 0))))))
|
||||
cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0))))))
|
||||
cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1))))))
|
||||
cfg.set("print", "timelapse_local", str(int(bool(data.get("timelapse_local", getattr(self._args, "timelapse_local", 0))))))
|
||||
cfg.set("print", "timelapse_interval_sec", str(max(1, int(data.get("timelapse_interval_sec", getattr(self._args, "timelapse_interval_sec", 4))))))
|
||||
cfg.set("print", "timelapse_printer", str(int(bool(data.get("timelapse_printer", getattr(self._args, "timelapse_printer", 0))))))
|
||||
if not cfg.has_option("bridge", "poll_interval"):
|
||||
cfg.set("bridge", "poll_interval", "3")
|
||||
printer_name = str(data.get("printer_name", "")).strip()
|
||||
@@ -4143,6 +4659,7 @@ def build_app(bridge: KobraXBridge) -> web.Application:
|
||||
r.add_get("/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/metadata", bridge.handle_files_metadata)
|
||||
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/pause", bridge.handle_print_pause)
|
||||
@@ -4207,6 +4724,10 @@ def build_app(bridge: KobraXBridge) -> web.Application:
|
||||
r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles)
|
||||
r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile)
|
||||
r.add_get("/kx/history", bridge.handle_kx_history)
|
||||
r.add_get("/kx/timelapses", bridge.handle_kx_timelapses)
|
||||
r.add_get("/kx/timelapse/{tid}/download", bridge.handle_kx_timelapse_download)
|
||||
r.add_get("/kx/timelapse/{tid}/thumb", bridge.handle_kx_timelapse_thumb)
|
||||
r.add_delete("/kx/timelapse/{tid}", bridge.handle_kx_timelapse_delete)
|
||||
r.add_get("/kx/ui/{name:.*}", bridge.handle_kx_ui_asset)
|
||||
r.add_get("/kx/files/{id}/objects", bridge.handle_kx_file_objects)
|
||||
r.add_post("/kx/skip", bridge.handle_kx_skip)
|
||||
@@ -4369,9 +4890,14 @@ def main():
|
||||
parser.add_argument("--mode-id", default=env_loader.MODE_ID)
|
||||
parser.add_argument("--device-id", default=env_loader.DEVICE_ID)
|
||||
parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT)
|
||||
parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING)
|
||||
parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT)
|
||||
parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING)
|
||||
parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING)
|
||||
parser.add_argument("--vibration-compensation", type=int, default=env_loader.VIBRATION_COMPENSATION)
|
||||
parser.add_argument("--flow-calibration", type=int, default=env_loader.FLOW_CALIBRATION)
|
||||
parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT)
|
||||
parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING)
|
||||
parser.add_argument("--timelapse-local", type=int, default=env_loader.TIMELAPSE_LOCAL)
|
||||
parser.add_argument("--timelapse-interval-sec", type=int, default=env_loader.TIMELAPSE_INTERVAL_SEC)
|
||||
parser.add_argument("--timelapse-printer", type=int, default=env_loader.TIMELAPSE_PRINTER)
|
||||
|
||||
parser.add_argument("--host", default="0.0.0.0",
|
||||
help="Bind-Adresse für den Bridge-Server")
|
||||
|
||||
@@ -285,9 +285,9 @@ function applyLang(){
|
||||
setText('d-lbl-light',T.lbl_light);
|
||||
setText('d-lbl-nozzle',T.label_nozzle);
|
||||
setText('d-lbl-bed',T.label_bed);
|
||||
// Dashboard buttons
|
||||
setText('d-btn-pause',T.btn_pause);
|
||||
setText('d-btn-resume',T.btn_resume);
|
||||
// Dashboard buttons — Pause-Button wird zur Toggle-Action; Resume-Beschriftung
|
||||
// wird in updatePauseResumeBtn() je nach Druckerzustand gesetzt.
|
||||
updatePauseResumeBtn();
|
||||
setText('d-btn-cancel',T.btn_cancel);
|
||||
setText('cam-toggle-btn',camOn?T.btn_cam_stop:T.btn_cam_start);
|
||||
setText('cam-placeholder-txt',T.cam_placeholder);
|
||||
@@ -323,8 +323,18 @@ function applyLang(){
|
||||
setText('lbl-default-slot',T.settings_default_slot);
|
||||
setText('opt-slot-auto',T.settings_slot_auto);
|
||||
setText('lbl-auto-leveling',T.settings_auto_leveling);
|
||||
setText('lbl-vibration-compensation',T.settings_vibration_compensation);
|
||||
setText('lbl-flow-calibration',T.settings_flow_calibration);
|
||||
setText('lbl-camera-on-print',T.settings_camera_on_print);
|
||||
setText('lbl-web-upload-warning',T.settings_web_upload_warning);
|
||||
setText('lbl-timelapse-local',T.settings_timelapse_local);
|
||||
setText('lbl-timelapse-interval',T.settings_timelapse_interval);
|
||||
setText('lbl-timelapse-printer',T.settings_timelapse_printer);
|
||||
setText('lbl-layout',T.settings_layout);
|
||||
setText('opt-layout-1col',T.settings_layout_1col);
|
||||
setText('opt-layout-2col',T.settings_layout_2col);
|
||||
setText('store-tab-files-label',T.store_tab_files||'Dateien');
|
||||
setText('store-tab-tl-label',T.timelapse_tab);
|
||||
|
||||
setText('lbl-update-check',T.update_check);
|
||||
setText('lbl-update-apply',T.update_apply);
|
||||
@@ -612,11 +622,14 @@ function applyState(){
|
||||
}else{frb.style.display='none';}
|
||||
}
|
||||
// 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');
|
||||
if(skipBtn){
|
||||
var printing=(s.print_state==='printing'||s.print_state==='paused');
|
||||
skipBtn.style.display=printing?'':'none';
|
||||
}
|
||||
if(skipBtn) skipBtn.style.display=printing?'':'none';
|
||||
// Pause/Stopp-Buttons nur bei aktivem Druck zeigen (sonst verwirrend wenn
|
||||
// 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
|
||||
var b=document.getElementById('h-badge');
|
||||
@@ -778,10 +791,17 @@ function applyState(){
|
||||
var activity=(slot.activity||'');
|
||||
var pct=empty?T.ams_empty:(slot.consumables_percent!=null?slot.consumables_percent+'%':'–');
|
||||
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':'')
|
||||
+'" 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-material">'+(empty?'–':(slot.type||slot.material_type||'–'))+'</div>'
|
||||
+vendorBadge
|
||||
+'<div class="slot-label">'+slotLabel+'</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>'
|
||||
@@ -880,8 +900,14 @@ function openSettings(){
|
||||
document.getElementById('s-mode-id').value=d.mode_id||'';
|
||||
document.getElementById('s-default-slot').value=d.default_ams_slot||'auto';
|
||||
document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling);
|
||||
var vc=document.getElementById('s-vibration-compensation');if(vc)vc.checked=!!d.vibration_compensation;
|
||||
var fc=document.getElementById('s-flow-calibration');if(fc)fc.checked=!!d.flow_calibration;
|
||||
var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print;
|
||||
var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning);
|
||||
var tlLocal=document.getElementById('s-timelapse-local');if(tlLocal){tlLocal.checked=!!d.timelapse_local;_toggleTlInterval();}
|
||||
var tlInt=document.getElementById('s-timelapse-interval');if(tlInt)tlInt.value=d.timelapse_interval_sec||4;
|
||||
var tlPrinter=document.getElementById('s-timelapse-printer');if(tlPrinter)tlPrinter.checked=!!d.timelapse_printer;
|
||||
var lay=document.getElementById('s-layout');if(lay)lay.value=localStorage.getItem('layout')||'1col';
|
||||
});
|
||||
var v=localStorage.getItem('pollInterval')||'2000';
|
||||
document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')});
|
||||
@@ -898,10 +924,90 @@ function closeSettings(){
|
||||
document.getElementById('settings-modal').classList.remove('open');
|
||||
}
|
||||
|
||||
// ── Layout ──
|
||||
function applyLayout(mode){
|
||||
var panel=document.getElementById('panel-dashboard');
|
||||
if(!panel)return;
|
||||
var fullWidth=['d-cam-card','d-progress-card','d-temps-card','d-ams-card'];
|
||||
if(mode==='2col'){
|
||||
panel.classList.add('layout-2col');
|
||||
fullWidth.forEach(function(id){var el=document.getElementById(id);if(el)el.style.gridColumn='auto';});
|
||||
}else{
|
||||
panel.classList.remove('layout-2col');
|
||||
fullWidth.forEach(function(id){var el=document.getElementById(id);if(el)el.style.gridColumn='1/-1';});
|
||||
}
|
||||
localStorage.setItem('layout',mode);
|
||||
}
|
||||
|
||||
function _toggleTlInterval(){
|
||||
var cb=document.getElementById('s-timelapse-local');
|
||||
var row=document.getElementById('timelapse-interval-row');
|
||||
if(row)row.style.display=(cb&&cb.checked)?'':'none';
|
||||
}
|
||||
|
||||
// ── Store tabs ──
|
||||
var _currentStoreTab='files';
|
||||
var _timelapseData=[];
|
||||
|
||||
function switchStoreTab(tab){
|
||||
_currentStoreTab=tab;
|
||||
var filesSection=document.getElementById('store-files-section');
|
||||
var tlSection=document.getElementById('store-timelapses-section');
|
||||
var tabFiles=document.getElementById('store-tab-files');
|
||||
var tabTl=document.getElementById('store-tab-timelapses');
|
||||
if(filesSection)filesSection.style.display=tab==='files'?'':'none';
|
||||
if(tlSection)tlSection.style.display=tab==='timelapses'?'':'none';
|
||||
if(tabFiles){tabFiles.classList.toggle('active',tab==='files');}
|
||||
if(tabTl){tabTl.classList.toggle('active',tab==='timelapses');}
|
||||
if(tab==='timelapses')loadTimelapses();
|
||||
}
|
||||
|
||||
function refreshStoreTab(){
|
||||
if(_currentStoreTab==='timelapses')loadTimelapses();else loadStore();
|
||||
}
|
||||
|
||||
function loadTimelapses(){
|
||||
fetch(_apiUrl('/kx/timelapses')).then(function(r){return r.json();}).then(function(d){
|
||||
_timelapseData=Array.isArray(d)?d:[];
|
||||
renderTimelapses();
|
||||
}).catch(function(e){clog('Timelapse-Fehler: '+e,'msg-err');});
|
||||
}
|
||||
|
||||
function renderTimelapses(){
|
||||
var list=document.getElementById('timelapse-list');
|
||||
if(!list)return;
|
||||
if(!_timelapseData.length){
|
||||
list.innerHTML='<div class="tl-row"><span style="color:var(--txt2);padding:20px">'+(T.timelapse_empty||'Keine Timelapses vorhanden')+'</span></div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML=_timelapseData.map(function(t){
|
||||
var statusCls=t.status==='completed'?'tl-status-ok':t.status==='failed'?'tl-status-err':'tl-status-busy';
|
||||
var statusTxt=t.status==='recording'?(T.timelapse_recording||'Aufnahme…'):t.status==='processing'?(T.timelapse_processing||'Kodierung…'):t.status;
|
||||
var dur=t.duration_sec>0?Math.floor(t.duration_sec/60)+'m ':' ';
|
||||
var dlBtn=t.status==='completed'?'<a href="'+_apiUrl('/kx/timelapse/'+t.id+'/download')+'" style="flex-shrink:0;padding:6px 12px;background:var(--accent);color:#000;border-radius:6px;font-size:12px;font-weight:600;text-decoration:none" download>⬇ Download</a>':'';
|
||||
return '<div class="tl-row">'+
|
||||
'<div class="tl-icon">🎬</div>'+
|
||||
'<div class="tl-info">'+
|
||||
'<div class="tl-name" title="'+t.filename+'">'+t.filename+'</div>'+
|
||||
'<div class="tl-meta '+statusCls+'">'+statusTxt+'</div>'+
|
||||
'<div class="tl-meta">'+t.frame_count+' frames • '+dur+t.created_at.slice(0,10)+'</div>'+
|
||||
'</div>'+
|
||||
dlBtn+
|
||||
'<button onclick="deleteTimelapse(\''+t.id+'\')" style="flex-shrink:0;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer;font-size:12px">✕</button>'+
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function deleteTimelapse(id){
|
||||
if(!confirm(T.confirm_delete_timelapse||'Dieses Timelapse löschen?'))return;
|
||||
fetch(_apiUrl('/kx/timelapse/'+id),{method:'DELETE'}).then(function(){loadTimelapses();});
|
||||
}
|
||||
|
||||
// ── AMS Slot Edit ──
|
||||
var _slotEditIndex=-1;
|
||||
var _slotEditLoaded=false;
|
||||
var _MAT_PRESETS=['PLA','PETG','ABS','ASA','TPU','PA','PC','HIPS'];
|
||||
var _BASE_MATERIAL_TYPES=['PLA','PETG','ABS','ASA','TPU','TPE','PA','PC','HIPS','PEI','PEEK'];
|
||||
function updateSlotEditFeedButton(){
|
||||
var btn=document.getElementById('btn-slot-edit-feed');
|
||||
if(!btn)return;
|
||||
@@ -920,9 +1026,16 @@ function _loadOrcaFilaments(cb){
|
||||
cb(_orcaFilamentCache);
|
||||
}).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');
|
||||
if(!sel) return;
|
||||
var wantKey=_profileKey(currentVendor, currentName);
|
||||
_loadOrcaFilaments(function(profiles){
|
||||
// Type-Filter: nur Profile vom passenden material zeigen (z.B. PLA → alle PLA-Varianten)
|
||||
var matU=(material||'').toUpperCase().trim();
|
||||
@@ -939,9 +1052,12 @@ function _fillSlotProfileDropdown(material, currentId){
|
||||
var g=document.createElement('optgroup'); g.label=v;
|
||||
byVendor[v].forEach(function(p){
|
||||
var o=document.createElement('option');
|
||||
o.value=p.id; o.dataset.vendor=p.vendor;
|
||||
o.value=_profileKey(p.vendor, p.name);
|
||||
o.dataset.vendor=p.vendor;
|
||||
o.dataset.name=p.name;
|
||||
o.dataset.id=p.id || '';
|
||||
o.textContent=p.name;
|
||||
if(p.id===currentId) o.selected=true;
|
||||
if(o.value===wantKey) o.selected=true;
|
||||
g.appendChild(o);
|
||||
});
|
||||
sel.appendChild(g);
|
||||
@@ -968,15 +1084,18 @@ function openSlotEdit(i){
|
||||
+(m===mat?'background:var(--accent);color:#fff':'background:var(--raised);color:var(--txt2)')+'">'+m+'</button>';
|
||||
}).join('');
|
||||
// OrcaSlicer-Profil-Dropdown: aktuellen User-Override für diesen Slot
|
||||
// aus /kx/filament/slots holen (enthält filament_id+filament_vendor).
|
||||
// Mit dem material-Filter (PLA→PLA*) wird die Liste auf passende Profile reduziert.
|
||||
// aus /kx/filament/slots holen (enthält vendor+name+id).
|
||||
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json();}).then(function(d){
|
||||
var arr=d.result||[];
|
||||
var entry=arr.find(function(x){return x.slot_index===globalIdx;})||{};
|
||||
window._slotProfileMap=window._slotProfileMap||{};
|
||||
window._slotProfileMap[globalIdx]={id:entry.filament_id||'',vendor:entry.filament_vendor||''};
|
||||
_fillSlotProfileDropdown(mat, entry.filament_id||'');
|
||||
}).catch(function(){ _fillSlotProfileDropdown(mat,''); });
|
||||
window._slotProfileMap[globalIdx]={
|
||||
id: entry.filament_id||'',
|
||||
vendor:entry.filament_vendor||'',
|
||||
name: entry.filament_name||'',
|
||||
};
|
||||
_fillSlotProfileDropdown(mat, entry.filament_vendor||'', entry.filament_name||'');
|
||||
}).catch(function(){ _fillSlotProfileDropdown(mat,'',''); });
|
||||
updateSlotEditFeedButton();
|
||||
document.getElementById('slot-edit-modal').classList.add('open');
|
||||
}
|
||||
@@ -1023,7 +1142,7 @@ function selectMatPreset(m){
|
||||
highlightMatBtn(m);
|
||||
// Filament-Profile-Dropdown an neues Material anpassen
|
||||
// (vorherige Selektion zurücksetzen — andere Material-Profile passen nicht)
|
||||
_fillSlotProfileDropdown(m, '');
|
||||
_fillSlotProfileDropdown(m, '', '');
|
||||
}
|
||||
function highlightMatBtn(val){
|
||||
document.querySelectorAll('.mat-preset-btn').forEach(function(b){
|
||||
@@ -1032,7 +1151,7 @@ function highlightMatBtn(val){
|
||||
b.style.color=on?'#fff':'var(--txt2)';
|
||||
});
|
||||
// Auch bei manueller Eingabe ins Material-Textfeld: Dropdown refreshen.
|
||||
if(val) _fillSlotProfileDropdown(val, '');
|
||||
if(val) _fillSlotProfileDropdown(val, '', '');
|
||||
}
|
||||
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);
|
||||
@@ -1043,31 +1162,55 @@ function saveSlotEdit(){
|
||||
var mat=document.getElementById('slot-edit-mat').value.trim().toUpperCase()||'PLA';
|
||||
var color=hexToRgb(hex);
|
||||
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 newProfId=profSel?profSel.value:'';
|
||||
var newProfVendor='';
|
||||
if(profSel && profSel.selectedOptions && profSel.selectedOptions[0]){
|
||||
newProfVendor=profSel.selectedOptions[0].dataset.vendor||'';
|
||||
}
|
||||
var sel=profSel && profSel.selectedOptions && profSel.selectedOptions[0];
|
||||
// Primärer Selector: (vendor, name). id ist nur Hint (aus dem JSON-data-attr
|
||||
// mitgegeben — Backend lookt sie nochmal selber nach um veraltete Hints zu
|
||||
// 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'),{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({id:newProfId,vendor:newProfVendor})
|
||||
}).then(function(r){return r.json();}).then(function(){
|
||||
body:JSON.stringify({vendor:newProfVendor, name:newProfName, id:newProfId})
|
||||
})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(){
|
||||
window._slotProfileMap=window._slotProfileMap||{};
|
||||
if(newProfId){ window._slotProfileMap[slotIdx]={id:newProfId,vendor:newProfVendor}; }
|
||||
else delete window._slotProfileMap[slotIdx];
|
||||
}).catch(function(e){clog('Profil-Speichern fehlgeschlagen: '+e,'msg-err');});
|
||||
post('/api/ams/set_slot',{index:slotIdx,type:mat,color:color})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(r){
|
||||
closeSlotEdit();
|
||||
var profSuffix=newProfId?(' ['+newProfId+']'):'';
|
||||
clog(tr('slot_edit_ok')+' '+(slotIdx+1)+': '+mat+' '+hex+profSuffix,'msg-ok');
|
||||
})
|
||||
.catch(function(e){clog('Fehler: '+e,'msg-err');});
|
||||
if(newProfVendor && newProfName){
|
||||
window._slotProfileMap[slotIdx]={id:newProfId, vendor:newProfVendor, name:newProfName};
|
||||
} else {
|
||||
delete window._slotProfileMap[slotIdx];
|
||||
}
|
||||
return post('/api/ams/set_slot',{index:slotIdx,type:mat,color:color});
|
||||
})
|
||||
.then(function(r){return r?r.json():null;})
|
||||
.then(function(){
|
||||
// Slot-Map refreshen damit die Karte sofort den Vendor zeigt.
|
||||
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');
|
||||
if(typeof poll==='function') poll();
|
||||
})
|
||||
.catch(function(e){clog('Fehler: '+e,'msg-err');});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded',function(){
|
||||
document.getElementById('s-printer-ip').addEventListener('input',function(){
|
||||
@@ -1098,10 +1241,16 @@ function saveSettings(){
|
||||
device_id: document.getElementById('s-device-id').value,
|
||||
mode_id: document.getElementById('s-mode-id').value,
|
||||
default_ams_slot: document.getElementById('s-default-slot').value,
|
||||
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
|
||||
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
|
||||
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
|
||||
vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0,
|
||||
flow_calibration: (document.getElementById('s-flow-calibration')||{}).checked?1:0,
|
||||
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
|
||||
web_upload_warning:webUploadWarning,
|
||||
timelapse_local: (document.getElementById('s-timelapse-local')||{}).checked?1:0,
|
||||
timelapse_interval_sec: parseInt((document.getElementById('s-timelapse-interval')||{value:'4'}).value)||4,
|
||||
timelapse_printer: (document.getElementById('s-timelapse-printer')||{}).checked?1:0,
|
||||
}).then(function(){
|
||||
applyLayout((document.getElementById('s-layout')||{value:'1col'}).value);
|
||||
btn.textContent=T.update_restarting;
|
||||
setTimeout(function(){
|
||||
btn.disabled=false;
|
||||
@@ -1164,7 +1313,23 @@ var pollTimer;
|
||||
(function(){
|
||||
var ms=parseInt(localStorage.getItem('pollInterval')||'2000');
|
||||
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);
|
||||
applyLayout(localStorage.getItem('layout')||'1col');
|
||||
})();
|
||||
|
||||
// ── Print actions ──
|
||||
@@ -1172,7 +1337,28 @@ function printAction(a){
|
||||
post('/printer/print/'+a,{}).then(function(){clog('Druck: '+a,'msg-ok');poll()})
|
||||
.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 codes: 0=X, 1=Y, 2=Z
|
||||
@@ -1901,6 +2087,20 @@ function _normalizeMaterialKey(material){
|
||||
var key=(material||'').toUpperCase().replace(/[^A-Z0-9+]/g,'');
|
||||
// Orca often uses PLA for PLA+, while AMS may report PLA+.
|
||||
if(key==='PLA+'||key==='PLAPLUS') return 'PLA';
|
||||
// Handle modifier+base patterns in either order: "Matte PLA", "Silk PETG",
|
||||
// "PLA Silk", "PLA Matte". OrcaSlicer always writes the base type in GCode
|
||||
// (filament_type = PLA), but users label slots with the full product-style name.
|
||||
// Scan each space-separated word; return the first one that is a known base material.
|
||||
// Dash-suffix variants ("PLA-CF", "PETG-CF") contain no space and fall through
|
||||
// unchanged, preserving correct incompatibility with their base types.
|
||||
var trimmed=(material||'').trim();
|
||||
if(trimmed.indexOf(' ')>=0){
|
||||
var words=trimmed.toUpperCase().split(/\s+/);
|
||||
for(var i=0;i<words.length;i++){
|
||||
var w=words[i].replace(/[^A-Z0-9+]/g,'');
|
||||
if(_BASE_MATERIAL_TYPES.indexOf(w)>=0) return w;
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
function _materialsCompatible(a,b){
|
||||
|
||||
@@ -102,6 +102,14 @@
|
||||
<input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0">
|
||||
<label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label>
|
||||
</div>
|
||||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="s-vibration-compensation" style="width:auto;margin:0">
|
||||
<label id="lbl-vibration-compensation" style="margin:0;cursor:pointer" for="s-vibration-compensation">Resonance Compensation</label>
|
||||
</div>
|
||||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="s-flow-calibration" style="width:auto;margin:0">
|
||||
<label id="lbl-flow-calibration" style="margin:0;cursor:pointer" for="s-flow-calibration">Flow Calibration</label>
|
||||
</div>
|
||||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="s-camera-on-print" style="width:auto;margin:0">
|
||||
<label id="lbl-camera-on-print" style="margin:0;cursor:pointer" for="s-camera-on-print">Kamera bei Druckstart einschalten</label>
|
||||
@@ -110,6 +118,29 @@
|
||||
<input type="checkbox" id="s-web-upload-warning" style="width:auto;margin:0">
|
||||
<label id="lbl-web-upload-warning" style="margin:0;cursor:pointer" for="s-web-upload-warning">Warnung bei Web-Upload-Druck anzeigen</label>
|
||||
</div>
|
||||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="s-timelapse-local" style="width:auto;margin:0" onchange="_toggleTlInterval()">
|
||||
<label id="lbl-timelapse-local" style="margin:0;cursor:pointer" for="s-timelapse-local">Lokales Timelapse während Druck</label>
|
||||
</div>
|
||||
<div class="modal-field" id="timelapse-interval-row" style="display:none">
|
||||
<label id="lbl-timelapse-interval" style="font-size:12px;color:var(--txt2)">Aufnahme-Intervall (Sekunden)</label>
|
||||
<input type="number" id="s-timelapse-interval" min="1" max="60" value="4" style="width:80px">
|
||||
</div>
|
||||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="s-timelapse-printer" style="width:auto;margin:0">
|
||||
<label id="lbl-timelapse-printer" style="margin:0;cursor:pointer" for="s-timelapse-printer">Drucker-Timelapse aktivieren (experimentell)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="modal-section" id="modal-sec-layout">Layout</div>
|
||||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<label id="lbl-layout" style="margin:0;font-size:12px;color:var(--txt2)">Dashboard-Layout</label>
|
||||
<select id="s-layout" style="width:auto;margin:0">
|
||||
<option value="1col" id="opt-layout-1col">1 Spalte</option>
|
||||
<option value="2col" id="opt-layout-2col">2 Spalten</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -193,7 +224,7 @@
|
||||
<div class="panel active" id="panel-dashboard">
|
||||
<div class="grid">
|
||||
<!-- Kamera -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<div class="card" id="d-cam-card" style="grid-column:1/-1">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
|
||||
<div class="card-title" style="margin-bottom:0"><span>📷</span> <span id="d-card-cam">Kamera</span></div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
@@ -217,7 +248,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Fortschritt -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<div class="card" id="d-progress-card" style="grid-column:1/-1">
|
||||
<div class="card-title"><span>◉</span> <span id="d-card-progress">Fortschritt</span></div>
|
||||
<img id="d-thumbnail" src="" alt="" style="display:none;width:100%;max-height:160px;object-fit:contain;border-radius:8px;background:#111;margin-bottom:10px">
|
||||
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
|
||||
@@ -243,16 +274,15 @@
|
||||
</div>
|
||||
</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">
|
||||
<button class="btn btn-pause btn-sm" id="d-btn-pause" onclick="printAction('pause')">⏸ Pause</button>
|
||||
<button class="btn btn-resume btn-sm" id="d-btn-resume" onclick="printAction('resume')">▶ Weiter</button>
|
||||
<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="togglePauseResume()">⏸ Pause</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Temperatursteuerung + Verlauf -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<div class="card" id="d-temps-card" style="grid-column:1/-1">
|
||||
<div class="card-title"><span>⊙</span> <span id="d-card-temps">Temperaturen</span></div>
|
||||
<div class="temp-card-inner">
|
||||
<div class="temp-block">
|
||||
@@ -402,8 +432,15 @@
|
||||
<div class="card">
|
||||
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||||
<span id="store-panel-title">🗂 Datei-Browser</span>
|
||||
<button id="store-refresh-btn" onclick="loadStore()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">↻ Aktualisieren</button>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<button id="store-tab-files" class="store-tab-btn active" onclick="switchStoreTab('files')">📁 <span id="store-tab-files-label">Dateien</span></button>
|
||||
<button id="store-tab-timelapses" class="store-tab-btn" onclick="switchStoreTab('timelapses')">🎬 <span id="store-tab-tl-label">Timelapses</span></button>
|
||||
<button id="store-refresh-btn" onclick="refreshStoreTab()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dateien-Bereich -->
|
||||
<div id="store-files-section">
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
|
||||
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
|
||||
style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
|
||||
@@ -434,6 +471,12 @@
|
||||
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
|
||||
</div>
|
||||
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
|
||||
</div><!-- /store-files-section -->
|
||||
|
||||
<!-- Timelapse-Bereich -->
|
||||
<div id="store-timelapses-section" style="display:none">
|
||||
<div id="timelapse-list"><div style="text-align:center;padding:40px;color:var(--txt2)" id="timelapse-empty-msg">Keine Timelapses vorhanden</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -313,3 +313,25 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
|
||||
.modal-box{padding:16px;border-radius:10px}
|
||||
.poll-btns{gap:6px}
|
||||
}
|
||||
|
||||
/* ── 2-Column Dashboard Layout ── */
|
||||
#panel-dashboard.layout-2col .grid{grid-template-columns:1fr 1fr}
|
||||
@media(max-width:768px){
|
||||
#panel-dashboard.layout-2col .grid{grid-template-columns:1fr}
|
||||
}
|
||||
|
||||
/* ── Store tabs ── */
|
||||
.store-tab-btn{padding:6px 16px;border-radius:6px;border:1px solid var(--border);
|
||||
background:var(--raised);color:var(--txt2);cursor:pointer;font-size:13px;font-weight:600;transition:.12s}
|
||||
.store-tab-btn.active{background:var(--accent);border-color:var(--accent);color:#000}
|
||||
|
||||
/* ── Timelapse list ── */
|
||||
.tl-row{display:flex;align-items:center;gap:12px;padding:10px;
|
||||
border:1px solid var(--border);border-radius:8px;margin-bottom:8px}
|
||||
.tl-icon{font-size:28px;color:var(--txt2);flex-shrink:0}
|
||||
.tl-info{flex:1;min-width:0}
|
||||
.tl-name{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.tl-meta{font-size:11px;color:var(--txt2);margin-top:2px}
|
||||
.tl-status-ok{color:var(--ok)}
|
||||
.tl-status-err{color:var(--err)}
|
||||
.tl-status-busy{color:var(--warn)}
|
||||
|
||||
@@ -140,8 +140,22 @@
|
||||
"settings_default_slot": "Standard-Slot (Einfarbdruck)",
|
||||
"settings_slot_auto": "Auto (alle belegten Slots)",
|
||||
"settings_auto_leveling": "Auto-Leveling vor Druck",
|
||||
"settings_vibration_compensation": "Resonanzkompensation vor Druck",
|
||||
"settings_flow_calibration": "Flow-Kalibrierung vor Druck",
|
||||
"settings_camera_on_print": "Kamera bei Druckstart einschalten",
|
||||
"settings_web_upload_warning": "Warnung bei Web-Upload-Druck anzeigen",
|
||||
"settings_timelapse_local": "Lokales Timelapse während Druck",
|
||||
"settings_timelapse_interval": "Aufnahme-Intervall (Sekunden)",
|
||||
"settings_timelapse_printer": "Drucker-Timelapse aktivieren (experimentell)",
|
||||
"settings_layout": "Dashboard-Layout",
|
||||
"settings_layout_1col": "1 Spalte",
|
||||
"settings_layout_2col": "2 Spalten",
|
||||
"store_tab_files": "Dateien",
|
||||
"timelapse_tab": "Timelapses",
|
||||
"timelapse_empty": "Noch keine Timelapses vorhanden",
|
||||
"timelapse_recording": "Aufnahme läuft…",
|
||||
"timelapse_processing": "Kodierung läuft…",
|
||||
"confirm_delete_timelapse": "Dieses Timelapse löschen?",
|
||||
"update_check": "Auf Updates prüfen",
|
||||
"update_checking": "Prüfe...",
|
||||
"update_available": "verfügbar",
|
||||
|
||||
@@ -140,8 +140,22 @@
|
||||
"settings_default_slot": "Default Slot (single color)",
|
||||
"settings_slot_auto": "Auto (all loaded slots)",
|
||||
"settings_auto_leveling": "Auto-Leveling before print",
|
||||
"settings_vibration_compensation": "Resonance Compensation before print",
|
||||
"settings_flow_calibration": "Flow Calibration before print",
|
||||
"settings_camera_on_print": "Turn camera on at print start",
|
||||
"settings_web_upload_warning": "Show warning when printing web uploads",
|
||||
"settings_timelapse_local": "Local timelapse during print",
|
||||
"settings_timelapse_interval": "Capture interval (seconds)",
|
||||
"settings_timelapse_printer": "Enable printer timelapse (experimental)",
|
||||
"settings_layout": "Dashboard layout",
|
||||
"settings_layout_1col": "1 column",
|
||||
"settings_layout_2col": "2 columns",
|
||||
"store_tab_files": "Files",
|
||||
"timelapse_tab": "Timelapses",
|
||||
"timelapse_empty": "No timelapses yet",
|
||||
"timelapse_recording": "Recording…",
|
||||
"timelapse_processing": "Encoding…",
|
||||
"confirm_delete_timelapse": "Delete this timelapse?",
|
||||
"update_check": "Check for Updates",
|
||||
"update_checking": "Checking...",
|
||||
"update_available": "available",
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
"kobra_finished": "Finalizado",
|
||||
"kobra_failed": "Error",
|
||||
"kobra_canceled": "Cancelado",
|
||||
"kobra_offline": "Offline",
|
||||
"kobra_offline": "Desconectada",
|
||||
"nav_dashboard": "Panel",
|
||||
"nav_print": "Impresion",
|
||||
"nav_print": "Impresión",
|
||||
"nav_temps": "Temperaturas",
|
||||
"nav_motion": "Movimiento",
|
||||
"nav_ams": "AMS",
|
||||
@@ -31,12 +31,12 @@
|
||||
"card_progress": "Progreso",
|
||||
"card_temps": "Temperaturas",
|
||||
"card_light_fan": "Ventilador",
|
||||
"card_speed": "Velocidad de impresion",
|
||||
"card_cam": "Camara",
|
||||
"card_speed": "Velocidad de impresión",
|
||||
"card_cam": "Cámara",
|
||||
"lbl_elapsed": "Transcurrido:",
|
||||
"lbl_remaining": "Restante:",
|
||||
"lbl_slicer_time": "Estimacion del slicer:",
|
||||
"lbl_layers": "Layer",
|
||||
"lbl_slicer_time": "Estimación del slicer:",
|
||||
"lbl_layers": "Capa",
|
||||
"speed_silent": "🐢 Silencioso",
|
||||
"speed_normal": "⚡ Normal",
|
||||
"speed_sport": "🚀 Sport",
|
||||
@@ -52,14 +52,14 @@
|
||||
"ace_dry_current_temp": "Temperatura",
|
||||
"ace_dry_chart": "Historial (Temp/Humedad)",
|
||||
"ace_dry_temp": "Temperatura (°C)",
|
||||
"ace_dry_duration": "Duracion (min)",
|
||||
"ace_dry_start": "▶ Start",
|
||||
"ace_dry_stop": "■ Stop",
|
||||
"ace_dry_auto_refill": "Relleno automatico",
|
||||
"ace_dry_duration": "Duración (min)",
|
||||
"ace_dry_start": "▶ Iniciar",
|
||||
"ace_dry_stop": "■ Parar",
|
||||
"ace_dry_auto_refill": "Relleno automático",
|
||||
"ace_dry_enable": "Activar secado",
|
||||
"ace_dry_temp_line": "Temperatura 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_temp": "Temperatura (30-80°C)",
|
||||
"ace_dry_dialog_time": "Tiempo restante (h:m:s)",
|
||||
@@ -67,12 +67,12 @@
|
||||
"ace_dry_dialog_cancel": "Cancelar",
|
||||
"ace_dry_dialog_save_restart": "Guardar y reiniciar",
|
||||
"ace_dry_dialog_custom_name": "Nombre personalizado",
|
||||
"ace_dry_dialog_reset_default": "Restablecer por defecto",
|
||||
"cam_placeholder": "📷 Camara no iniciada",
|
||||
"ace_dry_dialog_reset_default": "Restablecer valores predeterminados",
|
||||
"cam_placeholder": "📷 Cámara no iniciada",
|
||||
"cam_stream_unavailable": "Stream no disponible",
|
||||
"btn_cam_start": "▶ Camara",
|
||||
"btn_cam_stop": "◼ Camara",
|
||||
"btn_pause": "⏸ Pause",
|
||||
"btn_cam_start": "▶ Cámara",
|
||||
"btn_cam_stop": "◼ Cámara",
|
||||
"btn_pause": "⏸ Pausa",
|
||||
"btn_resume": "▶ Reanudar",
|
||||
"btn_cancel": "✕ Detener",
|
||||
"label_nozzle": "Boquilla",
|
||||
@@ -81,20 +81,20 @@
|
||||
"label_light": "💡 Luz",
|
||||
"label_on_off": "Encendido / Apagado",
|
||||
"label_speed": "Velocidad",
|
||||
"panel_print_title": "Control de impresion",
|
||||
"panel_print_btn_pause": "⏸ Pause",
|
||||
"panel_print_title": "Control de impresión",
|
||||
"panel_print_btn_pause": "⏸ Pausa",
|
||||
"panel_print_btn_resume": "▶ Reanudar",
|
||||
"panel_print_btn_cancel": "✕ Cancelar",
|
||||
"panel_print_temps_live": "Temperaturas (en vivo)",
|
||||
"label_set": "Set",
|
||||
"label_off": "Off",
|
||||
"label_off": "Apagado",
|
||||
"panel_temps_nozzle": "Boquilla",
|
||||
"panel_temps_bed": "Cama caliente",
|
||||
"panel_temps_chart": "Historial (ultimas 60 lecturas)",
|
||||
"panel_temps_chart": "Historial (últimas 60 lecturas)",
|
||||
"label_target_c": "Objetivo:",
|
||||
"panel_motion_xy": "Ejes XY",
|
||||
"panel_motion_z": "Eje Z",
|
||||
"label_step": "Tamano del paso:",
|
||||
"label_step": "Tamaño del paso:",
|
||||
"btn_home_z": "Home Z",
|
||||
"btn_home_xy": "Home XY",
|
||||
"btn_home_all": "Home All",
|
||||
@@ -103,11 +103,11 @@
|
||||
"card_ams": "Filamento",
|
||||
"ams_no_data": "No se recibieron datos de AMS",
|
||||
"label_slot": "Ranura",
|
||||
"ams_empty": "Vacio",
|
||||
"ams_empty": "Vacío",
|
||||
"panel_extras_light": "Luz",
|
||||
"panel_extras_fan": "Ventilador",
|
||||
"panel_extras_camera": "Camara",
|
||||
"btn_cam_start2": "▶ Start",
|
||||
"panel_extras_camera": "Cámara",
|
||||
"btn_cam_start2": "▶ Iniciar",
|
||||
"btn_cam_stop2": "◼ Detener",
|
||||
"panel_console_title": "Registro de eventos",
|
||||
"log_light_on": "Luz encendida",
|
||||
@@ -118,30 +118,44 @@
|
||||
"log_axis": "Eje",
|
||||
"log_home": "Home",
|
||||
"log_home_all": "Home All",
|
||||
"log_cam_start": "Camara iniciada:",
|
||||
"log_cam_stop": "Camara detenida",
|
||||
"log_cam_start": "Cámara iniciada:",
|
||||
"log_cam_stop": "Cámara detenida",
|
||||
"log_poll_error": "Error de sondeo:",
|
||||
"log_error": "Error:",
|
||||
"confirm_cancel": "Realmente cancelar la impresion?",
|
||||
"settings_title": "Configuracion",
|
||||
"settings_connection": "Conexion",
|
||||
"settings_print": "Ajustes de impresion",
|
||||
"confirm_cancel": "¿Realmente cancelar la impresión?",
|
||||
"settings_title": "Configuración",
|
||||
"settings_connection": "Conexión",
|
||||
"settings_print": "Ajustes de impresión",
|
||||
"settings_poll": "Intervalo de sondeo",
|
||||
"settings_version": "Version",
|
||||
"settings_version": "Versión",
|
||||
"settings_save": "Guardar y reiniciar",
|
||||
"settings_printer_name": "Nombre de impresora",
|
||||
"settings_printer_ip": "IP de impresora",
|
||||
"settings_mqtt_port": "MQTT Port",
|
||||
"settings_username": "Usuario MQTT",
|
||||
"settings_password": "Contrasena MQTT",
|
||||
"settings_password": "Contraseña MQTT",
|
||||
"settings_device_id": "ID del dispositivo",
|
||||
"settings_mode_id": "Mode ID",
|
||||
"hint_ip_no_port": "Solo direccion IP, sin puerto (p. ej. 192.168.1.102)",
|
||||
"settings_mode_id": "ID de modo",
|
||||
"hint_ip_no_port": "Solo dirección IP, sin puerto (p. ej. 192.168.1.102)",
|
||||
"settings_default_slot": "Ranura predeterminada (un color)",
|
||||
"settings_slot_auto": "Auto (todos los slots cargados)",
|
||||
"settings_auto_leveling": "Autonivelado antes de imprimir",
|
||||
"settings_camera_on_print": "Encender camara al iniciar impresion",
|
||||
"settings_vibration_compensation": "Compensación de resonancia antes de imprimir",
|
||||
"settings_flow_calibration": "Calibración de flujo antes de imprimir",
|
||||
"settings_camera_on_print": "Encender cámara al iniciar impresión",
|
||||
"settings_web_upload_warning": "Mostrar advertencia al imprimir subidas web",
|
||||
"settings_timelapse_local": "Timelapse local durante la impresión",
|
||||
"settings_timelapse_interval": "Intervalo de captura (segundos)",
|
||||
"settings_timelapse_printer": "Activar timelapse de impresora (experimental)",
|
||||
"settings_layout": "Diseño del panel",
|
||||
"settings_layout_1col": "1 columna",
|
||||
"settings_layout_2col": "2 columnas",
|
||||
"store_tab_files": "Archivos",
|
||||
"timelapse_tab": "Timelapses",
|
||||
"timelapse_empty": "Aún no hay timelapses",
|
||||
"timelapse_recording": "Grabando…",
|
||||
"timelapse_processing": "Codificando…",
|
||||
"confirm_delete_timelapse": "¿Eliminar este timelapse?",
|
||||
"update_check": "Buscar actualizaciones",
|
||||
"update_checking": "Comprobando...",
|
||||
"update_available": "disponible",
|
||||
@@ -152,7 +166,7 @@
|
||||
"update_error": "Error",
|
||||
"btn_connect": "⚡ Conectar",
|
||||
"btn_disconnect": "✕ Desconectar",
|
||||
"lbl_conn_error": "Error de conexion:",
|
||||
"lbl_conn_error": "Error de conexión:",
|
||||
"slot_edit_title": "Editar slot",
|
||||
"slot_edit_color": "Color",
|
||||
"slot_edit_material": "Material",
|
||||
@@ -165,61 +179,61 @@
|
||||
"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) —",
|
||||
"log_dir_all": "Todos",
|
||||
"log_lvl_label": "Level:",
|
||||
"file_ready_btn": "▶ Iniciar impresion",
|
||||
"log_lvl_label": "Nivel:",
|
||||
"file_ready_btn": "▶ Iniciar impresión",
|
||||
"file_slots_btn": "🎨 Seleccionar ranuras",
|
||||
"file_cancel_btn": "✕ Cancelar",
|
||||
"nav_printers": "Impresoras",
|
||||
"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_no_objects": "No hay objetos en esta impresion.",
|
||||
"skip_no_objects": "No hay objetos en esta impresión.",
|
||||
"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_success": "Se omitiran los objetos.",
|
||||
"skip_success": "Se omitirán los objetos.",
|
||||
"fd_objects_hint": "Omitir objetos (opcional):",
|
||||
"fd_slots_hint": "Asignar canal GCode a la ranura AMS:",
|
||||
"fd_cancel": "Cancelar",
|
||||
"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_no_matching_material": "No hay material compatible",
|
||||
"fd_used": "USADO",
|
||||
"add_printer": "Agregar impresora",
|
||||
"add_printer": "Añadir impresora",
|
||||
"apd_lbl_ip": "IP de impresora",
|
||||
"apd_lbl_name": "Nombre (opcional)",
|
||||
"apd_placeholder_name": "p. ej. Kobra X Sala",
|
||||
"apd_cancel": "Cancelar",
|
||||
"apd_confirm": "Agregar",
|
||||
"apd_confirm": "Añadir",
|
||||
"apd_fetching": "Obteniendo datos de la impresora…",
|
||||
"apd_success": "Impresora agregada, reiniciando bridge…",
|
||||
"apd_err_ip": "Introduce una direccion IP",
|
||||
"apd_success": "Impresora añadida, reiniciando bridge…",
|
||||
"apd_err_ip": "Introduce una dirección IP",
|
||||
"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_switch": "Cambiar →",
|
||||
"printers_current": "Impresora actual",
|
||||
"printers_loading": "Cargando…",
|
||||
"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",
|
||||
"panel_browser_title": "Explorador de archivos",
|
||||
"store_search_placeholder": "🔍 Buscar…",
|
||||
"store_empty": "Aun no hay archivos subidos.",
|
||||
"store_empty": "Aún no hay archivos subidos.",
|
||||
"store_refresh": "↻ Actualizar",
|
||||
"store_print": "▶ Imprimir",
|
||||
"store_download": "⬇ Descargar",
|
||||
"store_delete_confirm": "Eliminar archivo?",
|
||||
"store_print_confirm": "Imprimir archivo?",
|
||||
"store_delete_confirm": "¿Eliminar archivo?",
|
||||
"store_print_confirm": "¿Imprimir archivo?",
|
||||
"store_web_verify_title": "Verificar archivo",
|
||||
"store_web_verify_msg": "Verifica que este archivo fue creado para Anycubic Kobra X.",
|
||||
"store_web_verify_confirm": "Confirmar",
|
||||
"store_web_verify_abort": "Abortar",
|
||||
"store_no_results": "No se encontraron archivos.",
|
||||
"store_never": "nunca impreso",
|
||||
"store_estimate": "Estimacion",
|
||||
"store_upload_label_prefix": "Arrastra GCode aqui o ",
|
||||
"store_estimate": "Estimación",
|
||||
"store_upload_label_prefix": "Arrastra el GCode aquí o ",
|
||||
"store_upload_label_browse": "buscar",
|
||||
"store_upload_busy": "⏳ Subiendo…",
|
||||
"store_upload_success": "✓ {file}",
|
||||
@@ -230,5 +244,5 @@
|
||||
"sf_new": "Nuevo",
|
||||
"ss_date": "↓ Fecha",
|
||||
"ss_name": "A–Z Nombre",
|
||||
"ss_dur": "⏱ Tiempo de impresion"
|
||||
"ss_dur": "⏱ Tiempo de impresión"
|
||||
}
|
||||
|
||||
@@ -140,8 +140,22 @@
|
||||
"settings_default_slot": "默认槽位 (单色)",
|
||||
"settings_slot_auto": "自动 (所有已装载槽位)",
|
||||
"settings_auto_leveling": "打印前自动调平",
|
||||
"settings_vibration_compensation": "打印前共振补偿",
|
||||
"settings_flow_calibration": "打印前流量校准",
|
||||
"settings_camera_on_print": "打印开始时开启相机",
|
||||
"settings_web_upload_warning": "打印网页上传文件时显示警告",
|
||||
"settings_timelapse_local": "打印期间本地延时摄影",
|
||||
"settings_timelapse_interval": "拍摄间隔(秒)",
|
||||
"settings_timelapse_printer": "启用打印机延时摄影(实验性)",
|
||||
"settings_layout": "仪表板布局",
|
||||
"settings_layout_1col": "单列",
|
||||
"settings_layout_2col": "双列",
|
||||
"store_tab_files": "文件",
|
||||
"timelapse_tab": "延时视频",
|
||||
"timelapse_empty": "暂无延时视频",
|
||||
"timelapse_recording": "录制中…",
|
||||
"timelapse_processing": "编码中…",
|
||||
"confirm_delete_timelapse": "删除此延时视频?",
|
||||
"update_check": "检查更新",
|
||||
"update_checking": "检查中...",
|
||||
"update_available": "可用",
|
||||
|
||||
Reference in New Issue
Block a user