Compare commits

...

9 Commits

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:21:29 +01:00
Phil Merricks
250e89f18a fix: slot assignment dialog now matches "PLA Silk", "Matte PLA" etc. to PLA slots
_normalizeMaterialKey now scans all space-separated words in a slot label and
returns the first known base material type found. This handles both "modifier
first" ("Matte PLA") and "modifier last" ("PLA Silk", "PLA Matte") patterns,
which arise when users label slots with full product-style names while OrcaSlicer
writes only the base type (PLA, PETG, …) in GCode comments.

Dash-suffix composites ("PLA-CF", "PETG-CF") contain no space and are unchanged,
preserving correct incompatibility with their base types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:05:11 +01:00
031e34d8ea merge: PR #42 — Dryer toggle false error (@gangoke)
ACE-Dryer setDry geht jetzt fire-and-forget (timeout=0, kein Response-
Check). Drucker führt den Befehl korrekt aus, aber liefert code:0
statt code:200 — was eine 502-Fehlermeldung in der Bridge-UI auslöste
obwohl der Trockner-Toggle eigentlich funktioniert hat.

Pattern identisch zu setAutoFeed (Z.3161).
2026-06-01 14:28:42 +02:00
Gangoke
fc89dfffa5 fire and forget setDry 2026-05-31 17:27:01 -10:00
ac695ecf36 build: sources for v0.9.18 2026-05-31 19:53:36 +02:00
23b8a69065 merge: PR #40 — Spanish Translation Fixes (@pezfisk)
Native-speaker review der spanischen Übersetzung — fehlende Akzente
(impresión, cámara, después, animación, …) und sprachliche
Korrekturen (Pause→Pausa, Start→Iniciar, Layer→Capa, Stream→Stream).
Zusätzlich neue README.es.md plus Links in README.md + README.de.md.
2026-05-31 18:42:06 +02:00
22dc58258c Spanish translation 2026-05-31 15:47:19 +02:00
e4b4d091f3 Spanish translation 2026-05-31 15:42:28 +02:00
16 changed files with 1486 additions and 216 deletions

View File

@@ -1,5 +1,74 @@
# Changelog # 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 ## [0.9.17] 2026-05-30
### Neu ### Neu

View File

@@ -1,5 +1,74 @@
# Changelog # 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 ## [0.9.17] 2026-05-30
### New ### New

View File

@@ -8,7 +8,7 @@
Eine Moonraker-kompatible Bridge, die direkt mit dem Drucker spricht. Eine Moonraker-kompatible Bridge, die direkt mit dem Drucker spricht.
<sub>🇬🇧 <a href="README.md">English version</a></sub> <sub>🇬🇧 <a href="README.md">English version</a> · 🇪🇸 <a href="README.es.md">Versión española</a></sub>
<br> <br>

224
README.es.md Normal file
View 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>
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Apoya%20este%20proyecto-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/viewitde)
&nbsp;
[![Releases](https://img.shields.io/badge/Descargar-Lanzamientos-2EA043?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
&nbsp;
[![Downloads](https://img.shields.io/badge/Descargas-800%2B-8957E5?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
&nbsp;
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](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
[![Configuración y uso de KX-Bridge](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](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: GPL v3](https://img.shields.io/badge/License-GPL_v3-blue.svg)](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:**
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/viewitde)
</div>

View File

@@ -8,7 +8,7 @@
A Moonraker-compatible bridge that talks directly to the printer. A Moonraker-compatible bridge that talks directly to the printer.
<sub>🇩🇪 <a href="README.de.md">Deutsche Version</a></sub> <sub>🇩🇪 <a href="README.de.md">Deutsche Version</a> · 🇪🇸 <a href="README.es.md">Versión española</a></sub>
<br> <br>

View File

@@ -1 +1 @@
0.9.17 0.9.18

View File

@@ -57,10 +57,15 @@ def _load_config_file(path: pathlib.Path):
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"), "MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"), "MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"), "DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"), "DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"), "AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"), "VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"), "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"), "BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
} }
for env_key, (section, option) in mapping.items(): 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", ""), "device_id": env_vals.get("DEVICE_ID", ""),
} }
cfg[CONFIG_SECTION_PRINT] = { cfg[CONFIG_SECTION_PRINT] = {
"default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"), "default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"),
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"), "auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"), "vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"),
"web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"), "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] = { cfg[CONFIG_SECTION_BRIDGE] = {
"poll_interval": "3", "poll_interval": "3",
@@ -169,16 +179,23 @@ def list_printers() -> list[dict]:
def list_filament_profiles() -> dict[int, dict]: def list_filament_profiles() -> dict[int, dict]:
"""Liest die [filament_profiles]-Sektion aus config.ini. """Liest die [filament_profiles]-Sektion aus config.ini.
Format pro AMS-Slot (slot_N_id + slot_N_vendor): Format pro AMS-Slot — primärer Selector ist (vendor, name), die `id` wird
[filament_profiles] aus der orca_filaments.json beim Speichern nachgeschlagen und mitgeführt
slot_0_id = OGFL01 (als Hint für OrcaSlicer; das Orca-Datenmodell hat ~136 Profile mit
slot_0_vendor = Polymaker derselben filament_id wie 'OGFL99', d.h. die ID ist nicht eindeutig):
slot_1_id = OGFG23
slot_1_vendor = Polymaker
Gibt einen Dict {slot_index: {"id": ..., "vendor": ...}} zurück. [filament_profiles]
Leere/fehlende Slots werden NICHT aufgenommen — das Default-Mapping slot_0_vendor = Polymaker
slot_0_name = PolyTerra PLA
slot_0_id = OGFL01
Gibt einen Dict {slot_index: {"id": ..., "vendor": ..., "name": ...}}
zurück. Leere/fehlende Slots werden NICHT aufgenommen — das Default-Mapping
(per filament_type) in der Bridge bleibt dann aktiv. (per filament_type) in der Bridge bleibt dann aktiv.
Backwards-Kompat: alte Configs mit nur (vendor, id) bleiben lesbar; `name`
fehlt dann und der Aufrufer kann optional aus der orca_filaments.json
rekonstruieren.
""" """
path = _find_config_file() path = _find_config_file()
if not path: if not path:
@@ -189,7 +206,7 @@ def list_filament_profiles() -> dict[int, dict]:
return {} return {}
result: dict[int, dict] = {} result: dict[int, dict] = {}
for key, value in cfg.items("filament_profiles"): for key, value in cfg.items("filament_profiles"):
# Erwartet: slot_<idx>_id oder slot_<idx>_vendor # Erwartet: slot_<idx>_id oder slot_<idx>_vendor oder slot_<idx>_name
if not key.startswith("slot_"): if not key.startswith("slot_"):
continue continue
parts = key.split("_", 2) parts = key.split("_", 2)
@@ -200,13 +217,11 @@ def list_filament_profiles() -> dict[int, dict]:
except ValueError: except ValueError:
continue continue
field = parts[2] field = parts[2]
if field not in ("id", "vendor"): if field not in ("id", "vendor", "name"):
continue continue
if not value.strip(): if not value.strip():
continue continue
result.setdefault(slot_idx, {})[field] = value.strip() result.setdefault(slot_idx, {})[field] = value.strip()
# Leere Einträge (nur vendor ohne id oder umgekehrt) trotzdem behalten —
# der Aufrufer prüft selbst was er nutzt.
return result return result
@@ -214,24 +229,26 @@ def save_filament_profiles(profiles: dict[int, dict]) -> bool:
"""Schreibt die übergebenen Slot-Profile in die [filament_profiles]- """Schreibt die übergebenen Slot-Profile in die [filament_profiles]-
Sektion der config.ini. Existierende Einträge werden komplett ersetzt. Sektion der config.ini. Existierende Einträge werden komplett ersetzt.
profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker"}} profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker", "name": "PolyTerra PLA"}}
Mindestens vendor+name müssen gesetzt sein; id ist optional (Hint).
""" """
path = _find_config_file() path = _find_config_file()
if not path: if not path:
return False return False
cfg = configparser.ConfigParser() cfg = configparser.ConfigParser()
cfg.read(path, encoding="utf-8") cfg.read(path, encoding="utf-8")
# Sektion neu aufbauen — entfernt damit auch alte/verwaiste Slots
if cfg.has_section("filament_profiles"): if cfg.has_section("filament_profiles"):
cfg.remove_section("filament_profiles") cfg.remove_section("filament_profiles")
if profiles: if profiles:
cfg["filament_profiles"] = {} cfg["filament_profiles"] = {}
for slot_idx in sorted(profiles.keys()): for slot_idx in sorted(profiles.keys()):
entry = profiles[slot_idx] or {} entry = profiles[slot_idx] or {}
if entry.get("id"):
cfg["filament_profiles"][f"slot_{slot_idx}_id"] = entry["id"]
if entry.get("vendor"): if entry.get("vendor"):
cfg["filament_profiles"][f"slot_{slot_idx}_vendor"] = entry["vendor"] cfg["filament_profiles"][f"slot_{slot_idx}_vendor"] = entry["vendor"]
if entry.get("name"):
cfg["filament_profiles"][f"slot_{slot_idx}_name"] = entry["name"]
if entry.get("id"):
cfg["filament_profiles"][f"slot_{slot_idx}_id"] = entry["id"]
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
cfg.write(f) cfg.write(f)
return True return True
@@ -249,6 +266,11 @@ PASSWORD = get("MQTT_PASSWORD", "")
MODE_ID = get("MODE_ID", "") MODE_ID = get("MODE_ID", "")
DEVICE_ID = get("DEVICE_ID", "") DEVICE_ID = get("DEVICE_ID", "")
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto") DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
AUTO_LEVELING = int(get("AUTO_LEVELING","1")) AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0")) VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1")) 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"))

View File

@@ -179,10 +179,24 @@ class KobraXClient:
def connect(self): def connect(self):
self._do_connect() self._do_connect()
self._running = True self._running = True
t = threading.Thread(target=self._read_loop, daemon=True) self._ensure_reader()
t.start()
time.sleep(0.3) time.sleep(0.3)
def _ensure_reader(self):
"""Stellt sicher dass der Reader-Thread lebt. Wenn der Reader nach einer
früheren disconnect/reconnect-Sequenz oder einem unbehandelten Fehler
gestorben ist, würden empfangene Replies sonst nie ankommen — publish()
würde dann zwar senden, aber auf Antworten ewig warten."""
if not self._running:
return # gewollter disconnect
t = getattr(self, "_reader_thread", None)
if t is not None and t.is_alive():
return
self._reader_thread = threading.Thread(
target=self._read_loop, daemon=True, name="kobrax-mqtt-reader",
)
self._reader_thread.start()
def disconnect(self): def disconnect(self):
self._running = False self._running = False
try: try:
@@ -191,20 +205,34 @@ class KobraXClient:
pass pass
def _reconnect(self): def _reconnect(self):
"""Persistenter Reconnect: versucht endlos weiter bis der Drucker wieder
antwortet oder disconnect() gerufen wurde. Backoff cappt bei 60 s. Die
ersten 5 Versuche loggen als WARNING (akute Verbindungsstörung), danach
nur DEBUG um Log-Spam bei langem Drucker-Ausfall (z.B. über Nacht
ausgeschaltet) zu vermeiden."""
log.warning("Verbindung verloren reconnect…") log.warning("Verbindung verloren reconnect…")
try: try:
self._sock.close() self._sock.close()
except Exception: except Exception:
pass pass
for delay in [2, 4, 8, 15, 30]: delays = [2, 4, 8, 15, 30, 60]
attempt = 0
while self._running:
delay = delays[min(attempt, len(delays) - 1)]
try: try:
self._do_connect() self._do_connect()
log.info("Reconnect erfolgreich") log.info("Reconnect erfolgreich (nach %d Versuchen)", attempt + 1)
return True return True
except Exception as e: except Exception as e:
log.warning("Reconnect fehlgeschlagen (%s), warte %ss…", e, delay) attempt += 1
time.sleep(delay) lvl = log.warning if attempt <= 5 else log.debug
return False lvl("Reconnect fehlgeschlagen (%s, Versuch %d), warte %ss…", e, attempt, delay)
# Geteiltes Sleep damit disconnect() den Loop schneller bricht.
slept = 0.0
while slept < delay and self._running:
time.sleep(min(0.5, delay - slept))
slept += 0.5
return False # nur wenn disconnect() gerufen wurde
def _subscribe(self, topic: str): def _subscribe(self, topic: str):
with self._lock: with self._lock:
@@ -348,6 +376,9 @@ class KobraXClient:
# -- Publish + request/response ------------------------------------------ # -- Publish + request/response ------------------------------------------
def publish(self, msg_type: str, action: str, data=None, timeout: float = 5.0) -> dict | None: def publish(self, msg_type: str, action: str, data=None, timeout: float = 5.0) -> dict | None:
# Falls Reader-Thread aus historischen Gründen tot ist, wiederbeleben —
# sonst würden Replies nie ankommen und event.wait() läuft ins Timeout.
self._ensure_reader()
msgid = str(uuid.uuid4()) msgid = str(uuid.uuid4())
payload = json.dumps({ payload = json.dumps({
"type": msg_type, "type": msg_type,
@@ -413,6 +444,7 @@ class KobraXClient:
def publish_web(self, msg_type: str, action: str, data=None) -> None: def publish_web(self, msg_type: str, action: str, data=None) -> None:
"""Fire-and-forget publish on the web/printer topic (used for runtime updates during print).""" """Fire-and-forget publish on the web/printer topic (used for runtime updates during print)."""
self._ensure_reader()
msgid = str(uuid.uuid4()) msgid = str(uuid.uuid4())
payload = json.dumps({ payload = json.dumps({
"type": msg_type, "type": msg_type,
@@ -429,7 +461,14 @@ class KobraXClient:
with self._lock: with self._lock:
self._sock.sendall(_build_publish(topic, payload)) self._sock.sendall(_build_publish(topic, payload))
except Exception as e: except Exception as e:
log.error("web send error: %s", e) log.error("web send error: %s, reconnecting…", e)
# Reconnect triggern (analog zu publish()); ohne Retry weil
# fire-and-forget — der nächste Aufruf wird auf den frischen Socket
# treffen.
try:
self._reconnect()
except Exception:
pass
# -- High-level commands ------------------------------------------------- # -- High-level commands -------------------------------------------------

View File

@@ -226,6 +226,35 @@ def _parse_gcode_estimated_time(data: bytes) -> int:
return secs return secs
def _parse_gcode_layer_heights(data: bytes) -> tuple[float, float]:
"""Liest (layer_height, initial_layer_height) aus dem OrcaSlicer-/PrusaSlicer-
GCode-Header. Beide sind als Konfigblock am Ende des GCode hinterlegt.
Beispiel-Zeilen:
; layer_height = 0.2
; initial_layer_print_height = 0.2
Liefert (0.0, 0.0) wenn nicht gefunden — Aufrufer entscheidet was er macht
(typisch: keinen Z-Wert anzeigen)."""
import re
head = data[:16384].decode("utf-8", errors="ignore")
tail = data[-65536:].decode("utf-8", errors="ignore")
search = head + "\n" + tail
def _grab(pat):
m = re.search(pat, search)
if not m:
return 0.0
try:
return float(m.group(1))
except Exception:
return 0.0
layer_h = _grab(r";\s*layer_height\s*=\s*([0-9.]+)")
first_h = (_grab(r";\s*initial_layer_print_height\s*=\s*([0-9.]+)") or
_grab(r";\s*first_layer_height\s*=\s*([0-9.]+)") or
layer_h)
return layer_h, first_h
def _extract_thumbnail(data: bytes) -> str: def _extract_thumbnail(data: bytes) -> str:
"""Extrahiert Base64-PNG-Thumbnail aus GCode (OrcaSlicer-Format).""" """Extrahiert Base64-PNG-Thumbnail aus GCode (OrcaSlicer-Format)."""
try: try:
@@ -378,6 +407,18 @@ class GCodeStore:
filament_assignments TEXT, filament_assignments TEXT,
abort_reason TEXT abort_reason TEXT
); );
CREATE TABLE IF NOT EXISTS timelapses (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
printer_id TEXT NOT NULL,
created_at TEXT NOT NULL,
filename TEXT NOT NULL,
path TEXT NOT NULL,
frame_count INTEGER DEFAULT 0,
fps INTEGER DEFAULT 24,
status TEXT NOT NULL,
duration_sec INTEGER DEFAULT 0
);
""") """)
# Migration: Spalte gcode_filaments nachrüsten falls DB älter # Migration: Spalte gcode_filaments nachrüsten falls DB älter
try: try:
@@ -386,7 +427,13 @@ class GCodeStore:
except Exception: except Exception:
pass pass
# Migration: Spalten objects_skip_parts + svg_image (Part-Skip-Feature, v0.9.10) # Migration: Spalten objects_skip_parts + svg_image (Part-Skip-Feature, v0.9.10)
for col, typ in (("objects_skip_parts", "TEXT"), ("svg_image", "TEXT")): # Plus layer_height / first_layer_height (Obico Z-Höhe, v0.9.18)
for col, typ in (
("objects_skip_parts", "TEXT"),
("svg_image", "TEXT"),
("layer_height", "REAL"),
("first_layer_height", "REAL"),
):
try: try:
self._conn.execute(f"ALTER TABLE gcode_files ADD COLUMN {col} {typ}") self._conn.execute(f"ALTER TABLE gcode_files ADD COLUMN {col} {typ}")
self._conn.commit() self._conn.commit()
@@ -402,7 +449,9 @@ class GCodeStore:
def save_file(self, file_id: str, filename: str, data: bytes, def save_file(self, file_id: str, filename: str, data: bytes,
est_time_sec: int = 0, thumbnail_b64: str = "", est_time_sec: int = 0, thumbnail_b64: str = "",
gcode_filaments: list | None = None, gcode_filaments: list | None = None,
web_unverified: bool = False) -> str: web_unverified: bool = False,
layer_height: float = 0.0,
first_layer_height: float = 0.0) -> str:
"""Speichert GCode-Datei auf Disk und in DB. Gibt Pfad zurück.""" """Speichert GCode-Datei auf Disk und in DB. Gibt Pfad zurück."""
safe_name = os.path.basename(filename) safe_name = os.path.basename(filename)
path = os.path.join(self._gcode_dir, safe_name) path = os.path.join(self._gcode_dir, safe_name)
@@ -413,9 +462,9 @@ class GCodeStore:
filaments_json = json.dumps(gcode_filaments) if gcode_filaments else None filaments_json = json.dumps(gcode_filaments) if gcode_filaments else None
self._conn.execute( self._conn.execute(
"""INSERT OR REPLACE INTO gcode_files """INSERT OR REPLACE INTO gcode_files
(id, filename, path, size_bytes, uploaded_at, thumbnail_b64, est_print_time_sec, gcode_filaments, web_unverified) (id, filename, path, size_bytes, uploaded_at, thumbnail_b64, est_print_time_sec, gcode_filaments, web_unverified, layer_height, first_layer_height)
VALUES (?,?,?,?,?,?,?,?,?)""", VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(file_id, filename, path, len(data), now, thumbnail_b64 or None, est_time_sec or None, filaments_json, 1 if web_unverified else 0) (file_id, filename, path, len(data), now, thumbnail_b64 or None, est_time_sec or None, filaments_json, 1 if web_unverified else 0, layer_height or None, first_layer_height or None)
) )
self._conn.commit() self._conn.commit()
return path return path
@@ -533,6 +582,56 @@ class GCodeStore:
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
def create_timelapse(self, job_id: str, printer_id: str, path: str, fps: int = 24) -> str:
tid = str(uuid.uuid4())
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
filename = os.path.basename(path)
with self._lock:
self._conn.execute(
"""INSERT INTO timelapses (id, job_id, printer_id, created_at, filename, path, fps, status, frame_count, duration_sec)
VALUES (?,?,?,?,?,?,?,'recording',0,0)""",
(tid, job_id, printer_id, now, filename, path, fps),
)
self._conn.commit()
return tid
def update_timelapse(self, tid: str, status: str, frame_count: int = 0, duration_sec: int = 0):
with self._lock:
self._conn.execute(
"UPDATE timelapses SET status=?, frame_count=?, duration_sec=? WHERE id=?",
(status, frame_count, duration_sec, tid),
)
self._conn.commit()
def list_timelapses(self) -> list:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM timelapses ORDER BY created_at DESC"
).fetchall()
return [dict(r) for r in rows]
def get_timelapse(self, tid: str) -> "dict | None":
with self._lock:
row = self._conn.execute(
"SELECT * FROM timelapses WHERE id=?", (tid,)
).fetchone()
return dict(row) if row else None
def delete_timelapse(self, tid: str) -> bool:
rec = self.get_timelapse(tid)
if not rec:
return False
path = rec.get("path", "")
if path and os.path.isfile(path):
try:
os.remove(path)
except Exception:
pass
with self._lock:
self._conn.execute("DELETE FROM timelapses WHERE id=?", (tid,))
self._conn.commit()
return True
class CameraCache: class CameraCache:
"""Zentraler Kamera-Demuxer. """Zentraler Kamera-Demuxer.
@@ -627,10 +726,17 @@ class CameraCache:
except Exception as e: except Exception as e:
log.debug(f"CameraCache: jpeg-loop unterbrochen: {e}") log.debug(f"CameraCache: jpeg-loop unterbrochen: {e}")
finally: finally:
try: # Kill + Wait — sonst bleibt der Child-Prozess als Zombie und
self._proc_jpeg.kill() # asyncio meldet "Unknown child pid …" beim nächsten reaper-Tick.
except Exception: if self._proc_jpeg is not None:
pass try:
self._proc_jpeg.kill()
except Exception:
pass
try:
await self._proc_jpeg.wait()
except Exception:
pass
self._proc_jpeg = None self._proc_jpeg = None
await asyncio.sleep(2.0) # restart delay await asyncio.sleep(2.0) # restart delay
@@ -675,14 +781,109 @@ class CameraCache:
except Exception as e: except Exception as e:
log.debug(f"CameraCache: h264-loop unterbrochen: {e}") log.debug(f"CameraCache: h264-loop unterbrochen: {e}")
finally: finally:
try: if self._proc_h264 is not None:
self._proc_h264.kill() try:
except Exception: self._proc_h264.kill()
pass except Exception:
pass
try:
await self._proc_h264.wait()
except Exception:
pass
self._proc_h264 = None self._proc_h264 = None
await asyncio.sleep(2.0) await asyncio.sleep(2.0)
class TimelapseSpool:
"""Erfasst JPEG-Frames von CameraCache während eines Drucks und kodiert sie
nach Druckende zu einer MP4-Datei mit ffmpeg."""
def __init__(self, camera: CameraCache, timelapse_dir: str):
self._camera = camera
self._dir = timelapse_dir
self._task: "asyncio.Task | None" = None
self._frame_dir: str = ""
self._frame_count: int = 0
self._last_ts: float = 0.0
async def start(self, job_id: str, interval_sec: float = 4.0):
safe_id = job_id.replace("/", "_").replace("\\", "_")
self._frame_dir = os.path.join(self._dir, safe_id, "frames")
os.makedirs(self._frame_dir, exist_ok=True)
self._frame_count = 0
self._last_ts = 0.0
self._task = asyncio.create_task(self._capture_loop(interval_sec))
log.info(f"Timelapse-Aufnahme gestartet: {self._frame_dir} @ {interval_sec}s Intervall")
async def stop(self, output_fps: int = 24) -> "str | None":
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
if self._frame_count == 0:
log.warning("Timelapse: keine Frames aufgenommen")
return None
job_dir = os.path.dirname(self._frame_dir)
output_path = job_dir.rstrip("/\\") + ".mp4"
ok = await self._encode(output_path, output_fps)
import shutil
try:
shutil.rmtree(job_dir)
except Exception as e:
log.warning(f"Timelapse: Frames-Verzeichnis löschen fehlgeschlagen: {e}")
return output_path if ok else None
async def _capture_loop(self, interval_sec: float):
while True:
try:
ts = self._camera.latest_jpeg_ts
frame = self._camera.latest_jpeg
if ts > self._last_ts and frame:
path = os.path.join(self._frame_dir, f"frame_{self._frame_count:05d}.jpg")
with open(path, "wb") as f:
f.write(frame)
self._frame_count += 1
self._last_ts = ts
except Exception as e:
log.warning(f"Timelapse capture-Fehler: {e}")
await asyncio.sleep(interval_sec)
async def _encode(self, output_path: str, fps: int) -> bool:
try:
ffmpeg = _find_ffmpeg()
pattern = os.path.join(self._frame_dir, "frame_%05d.jpg")
cmd = [
ffmpeg, "-y", "-loglevel", "error",
"-framerate", str(fps),
"-i", pattern,
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-movflags", "+faststart",
output_path,
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
log.error(f"Timelapse encode fehlgeschlagen (rc={proc.returncode}): "
f"{stderr.decode(errors='replace')[:300]}")
return False
log.info(f"Timelapse kodiert: {output_path} ({self._frame_count} Frames @ {fps}fps)")
return True
except Exception as e:
log.error(f"Timelapse encode Ausnahme: {e}")
return False
class KobraXBridge: class KobraXBridge:
def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None): def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None):
self.client = client self.client = client
@@ -717,6 +918,12 @@ class KobraXBridge:
"remain_time": 0, "remain_time": 0,
"curr_layer": 0, "curr_layer": 0,
"total_layers": 0, "total_layers": 0,
# Layer-Heights pro aktuell laufender Datei (aus dem GCode-Header
# geparst). Wird im Upload-Pfad + beim _fetch_from_store gesetzt.
# Obico nutzt currentZ aus gcode_position[2] — die Bridge rechnet
# currentZ aus curr_layer + diesen Werten in build_print_payload.
"layer_height": 0.0,
"first_layer_height": 0.0,
"printer_name": env_loader.get("BRIDGE_PRINTER_NAME", "Anycubic Kobra X"), "printer_name": env_loader.get("BRIDGE_PRINTER_NAME", "Anycubic Kobra X"),
"firmware_version": "unknown", "firmware_version": "unknown",
"upload_url": "", "upload_url": "",
@@ -741,9 +948,15 @@ class KobraXBridge:
self._last_uploaded_file: str = "" self._last_uploaded_file: str = ""
self._store = store if store is not None else GCodeStore(args.data_dir) self._store = store if store is not None else GCodeStore(args.data_dir)
self._serve_dir_path: str = self._store._gcode_dir self._serve_dir_path: str = self._store._gcode_dir
self._timelapse_dir: str = os.path.join(args.data_dir, "timelapses")
os.makedirs(self._timelapse_dir, exist_ok=True)
self._current_job_id: str = "" self._current_job_id: str = ""
self._camera_autostarted: bool = False self._camera_autostarted: bool = False
self._timelapse_spool: "TimelapseSpool | None" = None
self._current_timelapse_id: str = ""
self._timelapse_autostarted: bool = False
self.camera_cache: CameraCache = CameraCache() self.camera_cache: CameraCache = CameraCache()
self._loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
self._thumbnail_b64: str = "" self._thumbnail_b64: str = ""
self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config() self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config()
@@ -869,6 +1082,18 @@ class KobraXBridge:
elif kobra_state in ("free", "finished", "stoped", "canceled"): elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False self._camera_autostarted = False
# Timelapse: Druckstart
if kobra_state == "printing" and not self._timelapse_autostarted:
if getattr(self._args, "timelapse_local", 0):
self._timelapse_autostarted = True
asyncio.run_coroutine_threadsafe(self._start_timelapse(), self._loop)
elif kobra_state in ("finished", "stoped", "canceled"):
self._timelapse_autostarted = False
if self._timelapse_spool:
asyncio.run_coroutine_threadsafe(
self._stop_timelapse(success=(kobra_state == "finished")), self._loop
)
# Job-History: Druckstart erkennen # Job-History: Druckstart erkennen
if kobra_state == "printing" and not self._current_job_id: if kobra_state == "printing" and not self._current_job_id:
filename = d.get("filename", self._state.get("filename", "")) filename = d.get("filename", self._state.get("filename", ""))
@@ -903,6 +1128,8 @@ class KobraXBridge:
self._state["print_duration"] = 0 self._state["print_duration"] = 0
self._state["remain_time"] = 0 self._state["remain_time"] = 0
self._state["slicer_time"] = 0 self._state["slicer_time"] = 0
self._state["layer_height"] = 0.0
self._state["first_layer_height"] = 0.0
self._thumbnail_b64 = "" self._thumbnail_b64 = ""
self._state["filename"] = d.get("filename", self._state["filename"]) self._state["filename"] = d.get("filename", self._state["filename"])
if "progress" in d: if "progress" in d:
@@ -953,6 +1180,13 @@ class KobraXBridge:
log.warning(f"Kamera-Autostart fehlgeschlagen: {e}") log.warning(f"Kamera-Autostart fehlgeschlagen: {e}")
elif kobra_state in ("free", "finished", "stoped", "canceled"): elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False self._camera_autostarted = False
# Timelapse-Autostart auch hier (Guard verhindert Doppel-Start mit _on_print).
if kobra_state == "printing" and not self._timelapse_autostarted:
if getattr(self._args, "timelapse_local", 0):
self._timelapse_autostarted = True
asyncio.run_coroutine_threadsafe(self._start_timelapse(), self._loop)
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._timelapse_autostarted = False
if project: if project:
if "filename" in project: if "filename" in project:
self._state["filename"] = project["filename"] self._state["filename"] = project["filename"]
@@ -1489,9 +1723,19 @@ class KobraXBridge:
# Vendor wird mitgesendet (tray_sub_brands + filament_vendor), # Vendor wird mitgesendet (tray_sub_brands + filament_vendor),
# damit ein gepatchter OrcaSlicer den Match nach Marke + Type + # damit ein gepatchter OrcaSlicer den Match nach Marke + Type +
# Farbe machen kann (analog SnapmakerPrinterAgent). # Farbe machen kann (analog SnapmakerPrinterAgent).
# Zwei-Schicht-Resolution für den Filament-Hint an OrcaSlicer:
# 1. User-Wahl (config.ini [filament_profiles]) — exakte Kontrolle
# 2. Generic-Fallback (_TRAY_INFO_IDX) pro Material-Typ — kein
# Vendor-Hint, OrcaSlicer trifft dann sein eigenes Generic-Preset
user_profile = self._filament_profiles.get(slot_index) or {} user_profile = self._filament_profiles.get(slot_index) or {}
tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99") if user_profile.get("name"):
vendor = user_profile.get("vendor", "") vendor = user_profile.get("vendor", "")
fila_name = user_profile.get("name", "")
tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99")
else:
vendor = ""
fila_name = ""
tray_info_idx = self._TRAY_INFO_IDX.get(material, "OGFL99")
tray_array.append({ tray_array.append({
"id": str(slot_id), "id": str(slot_id),
"tag_uid": "0000000000000000", "tag_uid": "0000000000000000",
@@ -1499,7 +1743,17 @@ class KobraXBridge:
"tray_type": material, "tray_type": material,
"tray_color": color_hex, "tray_color": color_hex,
"tray_sub_brands": vendor, "tray_sub_brands": vendor,
"filament_vendor": vendor, # OrcaSlicer-Patch-Ready (Snapmaker-Stil) "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: else:
tray_array.append({ tray_array.append({
@@ -1519,6 +1773,68 @@ class KobraXBridge:
"tray_exist_bits": format(tray_exist_bits, "X"), "tray_exist_bits": format(tray_exist_bits, "X"),
} }
@staticmethod
def _layer_height_from_filename(fname: str) -> float:
"""OrcaSlicer-Default-Filename-Pattern: `<plate>_<material>_<layer>_<dur>.gcode`
z.B. `adapter_e27_plate(01)_PLA_0.2_41m1s.gcode` → 0.2.
Fallback wenn der GCode-Header nicht geparst wurde (z.B. Datei direkt am
Slicer gestartet, oder vor v0.9.18 hochgeladen). Liefert 0.0 wenn das
Pattern nicht greift."""
import re
if not fname:
return 0.0
m = re.search(r"_(0\.\d+)_(\d+[hms])", fname)
if not m:
return 0.0
try:
return float(m.group(1))
except Exception:
return 0.0
def _estimate_current_z(self) -> float:
"""Schätzt die aktuelle Z-Höhe aus curr_layer + Layer-Heights.
Der Drucker liefert keine echte Z-Position via MQTT, aber Obico
(moonraker-obico/printer.py:267) liest currentZ aus `gcode_position[2]`.
Wir rechnen das mit der layer_height aus dem GCode-Header zurück:
z = first_layer_height + (curr_layer - 1) * layer_height
Werte werden im Upload-Pfad gesetzt und nur bei Druckabbruch/-ende
zurückgesetzt (Slot-/Farbänderungen ändern nichts daran). Falls die
Werte fehlen (z.B. weil der Druck direkt am Slicer gestartet wurde
ohne Upload über die Bridge), wird einmalig aus dem GCode-Store
nachgeladen. Liefert 0.0 wenn nichts bekannt — Obico zeigt dann
keinen Z-Wert."""
s = self._state
layer_h = float(s.get("layer_height") or 0.0)
first_h = float(s.get("first_layer_height") or 0.0)
fname = s.get("filename", "")
if not layer_h and fname:
try:
gf = self._store.get_file_by_name(fname)
if gf:
layer_h = float(gf.get("layer_height") or 0.0)
first_h = float(gf.get("first_layer_height") or layer_h)
except Exception:
pass
if not layer_h and fname:
# Letzter Fallback: OrcaSlicer-Default-Filename enthält die Layer-Height
layer_h = self._layer_height_from_filename(fname)
if layer_h and not first_h:
first_h = layer_h
if layer_h:
# cache in state damit nicht jeder Build wieder den Store fragt
s["layer_height"] = layer_h
s["first_layer_height"] = first_h
if not layer_h:
return 0.0
curr = int(s.get("curr_layer") or 0)
if curr <= 0:
return 0.0
# Layer 1 = first_layer_height, Layer 2 = first + layer_h, …
return round(first_h + max(0, curr - 1) * layer_h, 3)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# WebSocket push # WebSocket push
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -1639,15 +1955,18 @@ class KobraXBridge:
"state_message": "Printer is ready", "state_message": "Printer is ready",
}, },
# speed_factor: 1=silent(0.5) / 2=standard(1.0) / 3=high(1.3) / 4=ultra(1.5) # speed_factor: 1=silent(0.5) / 2=standard(1.0) / 3=high(1.3) / 4=ultra(1.5)
# Aktuelle Z-Höhe für Obico aus curr_layer + Layer-Heights schätzen
# (Drucker liefert keine echte Z-Position per MQTT). gcode_position[2]
# ist der Wert den moonraker-obico in printer.py als currentZ liest.
"gcode_move": { "gcode_move": {
"speed_factor": {1: 0.5, 2: 1.0, 3: 1.3, 4: 1.5}.get(int(s.get("print_speed_mode") or 2), 1.0), "speed_factor": {1: 0.5, 2: 1.0, 3: 1.3, 4: 1.5}.get(int(s.get("print_speed_mode") or 2), 1.0),
"extrude_factor": 1.0, "extrude_factor": 1.0,
"speed": 0, "speed": 0,
"gcode_position": [0, 0, 0, 0], "gcode_position": [0, 0, self._estimate_current_z(), 0],
"absolute_coordinates": True, "absolute_coordinates": True,
"absolute_extrude": True, "absolute_extrude": True,
"homing_origin": [0, 0, 0, 0], "homing_origin": [0, 0, 0, 0],
"position": [0, 0, 0, 0], "position": [0, 0, self._estimate_current_z(), 0],
}, },
"fan": { "fan": {
"speed": (int(s.get("fan_speed") or 0)) / 100.0, "speed": (int(s.get("fan_speed") or 0)) / 100.0,
@@ -1780,8 +2099,10 @@ class KobraXBridge:
"status": "loaded" if s.get("status") == 5 else "empty", "status": "loaded" if s.get("status") == 5 else "empty",
"nozzle_temp": 0, "nozzle_temp": 0,
# Aktueller User-Override aus config.ini [filament_profiles] # Aktueller User-Override aus config.ini [filament_profiles]
# — (vendor,name) ist eindeutig, id ist nur Hint.
"filament_id": profile.get("id", ""), "filament_id": profile.get("id", ""),
"filament_vendor": profile.get("vendor", ""), "filament_vendor": profile.get("vendor", ""),
"filament_name": profile.get("name", ""),
}) })
return self._json_cors({"result": slots}) return self._json_cors({"result": slots})
@@ -1795,15 +2116,7 @@ class KobraXBridge:
""" """
type_filter = request.rel_url.query.get("type", "").upper().strip() type_filter = request.rel_url.query.get("type", "").upper().strip()
vendor_filter = request.rel_url.query.get("vendor", "").strip() vendor_filter = request.rel_url.query.get("vendor", "").strip()
data_path = self._find_orca_filaments_json() profiles = self._load_orca_filaments()
if not data_path or not os.path.isfile(data_path):
return self._json_cors({"result": []})
try:
with open(data_path, encoding="utf-8") as f:
profiles = json.load(f)
except Exception as e:
log.warning(f"orca_filaments.json read error: {e}")
return self._json_cors({"result": []})
if type_filter: if type_filter:
profiles = [p for p in profiles if p.get("type", "").upper() == type_filter] profiles = [p for p in profiles if p.get("type", "").upper() == type_filter]
if vendor_filter: if vendor_filter:
@@ -1837,8 +2150,15 @@ class KobraXBridge:
"""POST /kx/filament/slots/<idx>/profile — speichert oder löscht """POST /kx/filament/slots/<idx>/profile — speichert oder löscht
ein User-Override-Mapping für einen einzelnen AMS-Slot. ein User-Override-Mapping für einen einzelnen AMS-Slot.
Body: {"id": "OGFL01", "vendor": "Polymaker"} Primärer Selector ist (vendor, name) — die ID ist im Orca-Datenmodell
{"id": ""} → Mapping entfernen → Default-Fallback aktiv nicht eindeutig (136 Profile teilen sich z.B. 'OGFL99'). Die ID wird
aus orca_filaments.json beim Speichern nachgeschlagen und als Hint
mitgeführt für OrcaSlicer's `tray_info_idx`.
Body: {"vendor": "Polymaker", "name": "PolyTerra PLA"}
{"vendor": "", "name": ""} → Mapping entfernen
(Backwards-Kompat: {"id":..., "vendor":...} wird akzeptiert,
aber `name` ist seit v0.9.18 der primäre Selector.)
""" """
try: try:
slot_idx = int(request.match_info.get("idx", "-1")) slot_idx = int(request.match_info.get("idx", "-1"))
@@ -1850,10 +2170,18 @@ class KobraXBridge:
data = await request.json() data = await request.json()
except Exception: except Exception:
data = {} data = {}
new_id = (data.get("id") or "").strip()
new_vendor = (data.get("vendor") or "").strip() new_vendor = (data.get("vendor") or "").strip()
if new_id: new_name = (data.get("name") or "").strip()
self._filament_profiles[slot_idx] = {"id": new_id, "vendor": new_vendor} new_id = (data.get("id") or "").strip() # Backwards-Kompat-Hint
if new_vendor and new_name:
# ID aus JSON lookup'en (nicht aus dem Request-Body, der könnte
# veraltet sein oder ein Generic-Fallback).
looked_up_id = self._lookup_filament_id(new_vendor, new_name)
self._filament_profiles[slot_idx] = {
"vendor": new_vendor,
"name": new_name,
"id": looked_up_id or new_id,
}
else: else:
self._filament_profiles.pop(slot_idx, None) self._filament_profiles.pop(slot_idx, None)
# Persistieren in config.ini # Persistieren in config.ini
@@ -1863,10 +2191,36 @@ class KobraXBridge:
except Exception as e: except Exception as e:
log.warning(f"save_filament_profiles failed: {e}") log.warning(f"save_filament_profiles failed: {e}")
return self._json_cors({"error": str(e)}, status=500) return self._json_cors({"error": str(e)}, status=500)
entry = self._filament_profiles.get(slot_idx, {})
return self._json_cors({"result": "ok", return self._json_cors({"result": "ok",
"slot_index": slot_idx, "slot_index": slot_idx,
"id": new_id, "vendor": entry.get("vendor", ""),
"vendor": new_vendor}) "name": entry.get("name", ""),
"id": entry.get("id", "")})
def _load_orca_filaments(self) -> list[dict]:
"""Lädt 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): async def handle_kx_history(self, request):
limit = int(request.rel_url.query.get("limit", 50)) limit = int(request.rel_url.query.get("limit", 50))
@@ -2020,7 +2374,9 @@ class KobraXBridge:
ams_box_mapping = self._build_auto_ams_box_mapping() ams_box_mapping = self._build_auto_ams_box_mapping()
use_ams = len(ams_box_mapping) > 0 use_ams = len(ams_box_mapping) > 0
auto_leveling = getattr(self._args, "auto_leveling", 1) auto_leveling = getattr(self._args, "auto_leveling", 1)
vibration_compensation = getattr(self._args, "vibration_compensation", 0)
flow_calibration = getattr(self._args, "flow_calibration", 0)
filename = gcode_file["filename"] filename = gcode_file["filename"]
file_path = gcode_file["path"] file_path = gcode_file["path"]
@@ -2042,11 +2398,11 @@ class KobraXBridge:
}, },
"task_settings": { "task_settings": {
"auto_leveling": auto_leveling, "auto_leveling": auto_leveling,
"vibration_compensation": 0, "vibration_compensation": vibration_compensation,
"flow_calibration": 0, "flow_calibration": flow_calibration,
"dry_mode": 0, "dry_mode": 0,
"ai_settings": {"status": 0, "count": 0, "type": 1}, "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}, "drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
"model_objects_skip_parts": excluded_objects, "model_objects_skip_parts": excluded_objects,
}, },
@@ -2164,6 +2520,52 @@ class KobraXBridge:
}) })
return web.json_response({"result": files}) return web.json_response({"result": files})
async def handle_files_metadata(self, request):
"""Moonraker /server/files/metadata — moonraker-obico-Plugin holt das
einmal pro Druck und liest daraus `object_height` (für `currentZ`-
Anzeige im Obico-UI: `mmProgress` braucht maxZ), `layer_count`,
`layer_height` und `first_layer_height` (für die Layer-Berechnung).
Quelle: aktueller `_state` + GCode-Store-Eintrag wenn vorhanden.
Wenn Layer-Heights weder im State noch im Store sind, Fallback auf die
OrcaSlicer-Default-Filename-Heuristik (`_layer_height_from_filename`)."""
filename = request.rel_url.query.get("filename", "") or self._state.get("filename", "")
if not filename:
return web.json_response({"result": {}})
s = self._state
layer_h = float(s.get("layer_height") or 0.0)
first_h = float(s.get("first_layer_height") or 0.0)
total_layers = int(s.get("total_layers") or 0)
est_time = int(s.get("slicer_time") or 0)
size_bytes = 0
try:
gf = self._store.get_file_by_name(filename) or {}
if not layer_h:
layer_h = float(gf.get("layer_height") or 0.0)
first_h = float(gf.get("first_layer_height") or layer_h)
if not total_layers:
total_layers = int(gf.get("layer_count") or 0)
if not est_time:
est_time = int(gf.get("est_print_time_sec") or 0)
size_bytes = int(gf.get("size_bytes") or 0)
except Exception:
pass
if not layer_h:
layer_h = self._layer_height_from_filename(filename)
if layer_h and not first_h:
first_h = layer_h
object_height = round(first_h + max(0, total_layers - 1) * layer_h, 3) if (layer_h and total_layers) else 0.0
return web.json_response({"result": {
"filename": filename,
"size": size_bytes,
"modified": time.time(),
"estimated_time": est_time or None,
"layer_height": layer_h or None,
"first_layer_height": first_h or None,
"layer_count": total_layers or None,
"object_height": object_height or None,
}})
# ── Moonraker-Stubs für moonraker-obico ────────────────────────────────── # ── Moonraker-Stubs für moonraker-obico ──────────────────────────────────
async def handle_access_api_key(self, request): async def handle_access_api_key(self, request):
"""Moonraker /access/api_key — wir haben keine Auth, geben einen Dummy zurück. """Moonraker /access/api_key — wir haben keine Auth, geben einen Dummy zurück.
@@ -2290,6 +2692,9 @@ class KobraXBridge:
self._state["slicer_time"] = est_time self._state["slicer_time"] = est_time
thumbnail_b64 = _extract_thumbnail(file_data) thumbnail_b64 = _extract_thumbnail(file_data)
gcode_filaments = _extract_filament_info(file_data) gcode_filaments = _extract_filament_info(file_data)
layer_h, first_h = _parse_gcode_layer_heights(file_data)
self._state["layer_height"] = layer_h
self._state["first_layer_height"] = first_h
# Datei persistent im GCode-Store ablegen # Datei persistent im GCode-Store ablegen
self._store.save_file( self._store.save_file(
@@ -2300,6 +2705,8 @@ class KobraXBridge:
thumbnail_b64=thumbnail_b64, thumbnail_b64=thumbnail_b64,
gcode_filaments=gcode_filaments or None, gcode_filaments=gcode_filaments or None,
web_unverified=web_upload, web_unverified=web_upload,
layer_height=layer_h,
first_layer_height=first_h,
) )
serve_path = os.path.join(self._serve_dir_path, os.path.basename(remote_filename)) serve_path = os.path.join(self._serve_dir_path, os.path.basename(remote_filename))
del file_data # RAM freigeben del file_data # RAM freigeben
@@ -2391,7 +2798,9 @@ class KobraXBridge:
use_ams = len(loaded) > 0 use_ams = len(loaded) > 0
ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded) ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded)
log.debug(f"AMS-Slots: {len(loaded)} gemappt (genutzte Paints: {used_paint_indices}) → {[i for i, _ in loaded]}") log.debug(f"AMS-Slots: {len(loaded)} gemappt (genutzte Paints: {used_paint_indices}) → {[i for i, _ in loaded]}")
auto_leveling = getattr(self._args, "auto_leveling", 1) auto_leveling = getattr(self._args, "auto_leveling", 1)
vibration_compensation = getattr(self._args, "vibration_compensation", 0)
flow_calibration = getattr(self._args, "flow_calibration", 0)
payload = { payload = {
"taskid": "-1", "taskid": "-1",
"url": url, "url": url,
@@ -2407,11 +2816,11 @@ class KobraXBridge:
}, },
"task_settings": { "task_settings": {
"auto_leveling": auto_leveling, "auto_leveling": auto_leveling,
"vibration_compensation": 0, "vibration_compensation": vibration_compensation,
"flow_calibration": 0, "flow_calibration": flow_calibration,
"dry_mode": 0, "dry_mode": 0,
"ai_settings": {"status": 0, "count": 0, "type": 1}, "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}, "drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
"model_objects_skip_parts": [], "model_objects_skip_parts": [],
}, },
@@ -2479,7 +2888,9 @@ class KobraXBridge:
ams_box_mapping = self._build_auto_ams_box_mapping() ams_box_mapping = self._build_auto_ams_box_mapping()
use_ams = len(ams_box_mapping) > 0 use_ams = len(ams_box_mapping) > 0
auto_leveling = getattr(self._args, "auto_leveling", 1) auto_leveling = getattr(self._args, "auto_leveling", 1)
vibration_compensation = getattr(self._args, "vibration_compensation", 0)
flow_calibration = getattr(self._args, "flow_calibration", 0)
url = self._state.get("last_upload_url", "") url = self._state.get("last_upload_url", "")
filesize = self._state.get("last_upload_size", 0) filesize = self._state.get("last_upload_size", 0)
md5 = self._state.get("last_upload_md5", "") md5 = self._state.get("last_upload_md5", "")
@@ -2499,11 +2910,11 @@ class KobraXBridge:
}, },
"task_settings": { "task_settings": {
"auto_leveling": auto_leveling, "auto_leveling": auto_leveling,
"vibration_compensation": 0, "vibration_compensation": vibration_compensation,
"flow_calibration": 0, "flow_calibration": flow_calibration,
"dry_mode": 0, "dry_mode": 0,
"ai_settings": {"status": 0, "count": 0, "type": 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}, "drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
"model_objects_skip_parts": excluded_objects, "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) return web.json_response({"error": "color must be [r,g,b]"}, status=400)
box_id, local_slot = self._global_to_box_slot(index) box_id, local_slot = self._global_to_box_slot(index)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
# setInfo geht über web/printer-Topic (wie tempature/set). Per
# Workbench-Vue mqtt_setInfo verifiziert — via slicer/printer/ wurden
# die Slot-Änderungen vom Drucker ignoriert und beim nächsten
# multiColorBox/report mit dem alten Material überschrieben.
def _send(): def _send():
resp = self.client.publish( self.client.publish_web(
"multiColorBox", "setInfo", "multiColorBox", "setInfo",
{"multi_color_box": [{"id": box_id, "slots": [{"index": local_slot, "type": mat, "color": color}]}]}, {"multi_color_box": [{"id": box_id, "slots": [{"index": local_slot, "type": mat, "color": color}]}]},
timeout=5
) )
log.info(f"setInfo global={index} box={box_id} local_slot={local_slot} type={mat} color={color}{resp}") log.info(f"setInfo (web) global={index} box={box_id} local_slot={local_slot} type={mat} color={color}")
return resp await loop.run_in_executor(None, _send)
resp = await loop.run_in_executor(None, _send) # Optimistisches Update: cached slot sofort anpassen (Drucker echoed
if resp and resp.get("code") == 200: # gleich via multiColorBox/report — falls er den Befehl ignoriert,
# Update cached slot immediately # überschreibt der Report das wieder).
for s in self._ams_slots: for s in self._ams_slots:
if s.get("global_index") == index: if s.get("global_index") == index:
s["type"] = mat s["type"] = mat
s["color"] = color s["color"] = color
break break
return web.json_response({"result": "ok"}) return web.json_response({"result": "ok"})
async def handle_api_ams_feed(self, request): async def handle_api_ams_feed(self, request):
@@ -2854,13 +3268,10 @@ class KobraXBridge:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
def _send(): def _send():
return self.client.publish("multiColorBox", "setDry", payload, timeout=5) return self.client.publish("multiColorBox", "setDry", payload, timeout=0)
# Fire-and-forget: setDry ACK arrives via multiColorBox/report callback.
resp = await loop.run_in_executor(None, _send) # Waiting for a response on that busy push topic causes false "code:0" rejections.
if resp is None: await loop.run_in_executor(None, _send)
return web.json_response({"error": "No response from printer"}, status=504)
if int(resp.get("code", 200)) != 200:
return web.json_response({"error": f"Printer rejected command: {resp}"}, status=502)
self._state["ace_drying"] = ui_state self._state["ace_drying"] = ui_state
self._state_dirty = True self._state_dirty = True
@@ -3062,6 +3473,10 @@ class KobraXBridge:
proc.kill() proc.kill()
except Exception: except Exception:
pass pass
try:
await proc.wait()
except Exception:
pass
return resp return resp
@@ -3271,6 +3686,97 @@ class KobraXBridge:
self._ams_loaded_slot = global_loaded self._ams_loaded_slot = global_loaded
return self._ams_slots return self._ams_slots
# ─── Timelapse ───────────────────────────────────────────────────────────
async def _start_timelapse(self):
camera_url = self._state.get("camera_url", "")
if not camera_url:
log.info("Timelapse: kein Kamera-URL bekannt — übersprungen")
return
await self.camera_cache.ensure_running()
interval = float(getattr(self._args, "timelapse_interval_sec", 4))
spool = TimelapseSpool(self.camera_cache, self._timelapse_dir)
job_id = self._current_job_id or str(uuid.uuid4())
safe_id = job_id.replace("/", "_").replace("\\", "_")
mp4_path = os.path.join(self._timelapse_dir, safe_id + ".mp4")
tid = self._store.create_timelapse(
job_id=job_id,
printer_id=self._printer_id,
path=mp4_path,
fps=24,
)
self._timelapse_spool = spool
self._current_timelapse_id = tid
await spool.start(job_id, interval_sec=interval)
log.info(f"Timelapse ID={tid} für Job {job_id} gestartet")
async def _stop_timelapse(self, success: bool = True):
spool = self._timelapse_spool
if not spool:
return
self._timelapse_spool = None
tid = self._current_timelapse_id
self._current_timelapse_id = ""
frame_count = spool._frame_count
self._store.update_timelapse(tid, "processing", frame_count=frame_count)
mp4_path = await spool.stop(output_fps=24)
if mp4_path:
interval = float(getattr(self._args, "timelapse_interval_sec", 4))
duration_sec = int(frame_count * interval)
self._store.update_timelapse(tid, "completed", frame_count=frame_count,
duration_sec=duration_sec)
log.info(f"Timelapse {tid} abgeschlossen: {mp4_path}")
else:
self._store.update_timelapse(tid, "failed", frame_count=frame_count)
log.warning(f"Timelapse {tid} fehlgeschlagen")
async def handle_kx_timelapses(self, request):
rows = self._store.list_timelapses()
# Laufende Aufnahme in der DB-Ansicht sichtbar machen
if self._current_timelapse_id:
spool = self._timelapse_spool
for r in rows:
if r["id"] == self._current_timelapse_id and spool:
r["frame_count"] = spool._frame_count
return self._json_cors(rows)
async def handle_kx_timelapse_download(self, request):
tid = request.match_info["tid"]
rec = self._store.get_timelapse(tid)
if not rec or not os.path.isfile(rec.get("path", "")):
return web.Response(status=404)
filename = rec.get("filename") or os.path.basename(rec["path"])
return web.FileResponse(
rec["path"],
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
async def handle_kx_timelapse_thumb(self, request):
tid = request.match_info["tid"]
rec = self._store.get_timelapse(tid)
if not rec:
return web.Response(status=404)
job_id = rec.get("job_id", "")
safe_id = job_id.replace("/", "_").replace("\\", "_")
frame_dir = os.path.join(self._timelapse_dir, safe_id, "frames")
try:
if os.path.isdir(frame_dir):
frames = sorted(f for f in os.listdir(frame_dir) if f.endswith(".jpg"))
if frames:
with open(os.path.join(frame_dir, frames[0]), "rb") as f:
data = f.read()
return web.Response(body=data, content_type="image/jpeg")
except Exception:
pass
return web.Response(status=404)
async def handle_kx_timelapse_delete(self, request):
tid = request.match_info["tid"]
ok = self._store.delete_timelapse(tid)
if not ok:
return self._json_cors({"error": "not found"}, status=404)
return self._json_cors({"status": "deleted"})
# ─── Settings ──────────────────────────────────────────────────────────── # ─── Settings ────────────────────────────────────────────────────────────
def _find_config_path(self) -> pathlib.Path: def _find_config_path(self) -> pathlib.Path:
@@ -3294,10 +3800,15 @@ class KobraXBridge:
"password": self._args.password, "password": self._args.password,
"mode_id": self._args.mode_id, "mode_id": self._args.mode_id,
"device_id": self._args.device_id, "device_id": self._args.device_id,
"default_ams_slot": getattr(self._args, "default_ams_slot", "auto"), "default_ams_slot": getattr(self._args, "default_ams_slot", "auto"),
"auto_leveling": getattr(self._args, "auto_leveling", 1), "auto_leveling": getattr(self._args, "auto_leveling", 1),
"camera_on_print": getattr(self._args, "camera_on_print", 0), "vibration_compensation": getattr(self._args, "vibration_compensation", 0),
"web_upload_warning": getattr(self._args, "web_upload_warning", 1), "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, "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", "password", str(data.get("password", self._args.password or "")))
cfg.set("connection", "mode_id", str(data.get("mode_id", self._args.mode_id or ""))) cfg.set("connection", "mode_id", str(data.get("mode_id", self._args.mode_id or "")))
cfg.set("connection", "device_id", str(data.get("device_id", self._args.device_id or ""))) cfg.set("connection", "device_id", str(data.get("device_id", self._args.device_id or "")))
cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto")))) cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto"))))
cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1)))) cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1))))
cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0)))))) cfg.set("print", "vibration_compensation", str(int(bool(data.get("vibration_compensation", getattr(self._args, "vibration_compensation", 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", "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"): if not cfg.has_option("bridge", "poll_interval"):
cfg.set("bridge", "poll_interval", "3") cfg.set("bridge", "poll_interval", "3")
printer_name = str(data.get("printer_name", "")).strip() printer_name = str(data.get("printer_name", "")).strip()
@@ -4143,6 +4659,7 @@ def build_app(bridge: KobraXBridge) -> web.Application:
r.add_get("/printer/objects/subscribe", bridge.handle_objects_subscribe) r.add_get("/printer/objects/subscribe", bridge.handle_objects_subscribe)
r.add_post("/printer/objects/subscribe", bridge.handle_objects_subscribe) r.add_post("/printer/objects/subscribe", bridge.handle_objects_subscribe)
r.add_get("/server/files/list", bridge.handle_files_list) r.add_get("/server/files/list", bridge.handle_files_list)
r.add_get("/server/files/metadata", bridge.handle_files_metadata)
r.add_post("/server/files/upload", bridge.handle_file_upload) r.add_post("/server/files/upload", bridge.handle_file_upload)
r.add_post("/printer/print/start", bridge.handle_print_start) r.add_post("/printer/print/start", bridge.handle_print_start)
r.add_post("/printer/print/pause", bridge.handle_print_pause) r.add_post("/printer/print/pause", bridge.handle_print_pause)
@@ -4207,6 +4724,10 @@ def build_app(bridge: KobraXBridge) -> web.Application:
r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles) r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles)
r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile) r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile)
r.add_get("/kx/history", bridge.handle_kx_history) r.add_get("/kx/history", bridge.handle_kx_history)
r.add_get("/kx/timelapses", bridge.handle_kx_timelapses)
r.add_get("/kx/timelapse/{tid}/download", bridge.handle_kx_timelapse_download)
r.add_get("/kx/timelapse/{tid}/thumb", bridge.handle_kx_timelapse_thumb)
r.add_delete("/kx/timelapse/{tid}", bridge.handle_kx_timelapse_delete)
r.add_get("/kx/ui/{name:.*}", bridge.handle_kx_ui_asset) r.add_get("/kx/ui/{name:.*}", bridge.handle_kx_ui_asset)
r.add_get("/kx/files/{id}/objects", bridge.handle_kx_file_objects) r.add_get("/kx/files/{id}/objects", bridge.handle_kx_file_objects)
r.add_post("/kx/skip", bridge.handle_kx_skip) r.add_post("/kx/skip", bridge.handle_kx_skip)
@@ -4369,9 +4890,14 @@ def main():
parser.add_argument("--mode-id", default=env_loader.MODE_ID) parser.add_argument("--mode-id", default=env_loader.MODE_ID)
parser.add_argument("--device-id", default=env_loader.DEVICE_ID) parser.add_argument("--device-id", default=env_loader.DEVICE_ID)
parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT) parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT)
parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING) parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING)
parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT) parser.add_argument("--vibration-compensation", type=int, default=env_loader.VIBRATION_COMPENSATION)
parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING) 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", parser.add_argument("--host", default="0.0.0.0",
help="Bind-Adresse für den Bridge-Server") help="Bind-Adresse für den Bridge-Server")

View File

@@ -285,9 +285,9 @@ function applyLang(){
setText('d-lbl-light',T.lbl_light); setText('d-lbl-light',T.lbl_light);
setText('d-lbl-nozzle',T.label_nozzle); setText('d-lbl-nozzle',T.label_nozzle);
setText('d-lbl-bed',T.label_bed); setText('d-lbl-bed',T.label_bed);
// Dashboard buttons // Dashboard buttons — Pause-Button wird zur Toggle-Action; Resume-Beschriftung
setText('d-btn-pause',T.btn_pause); // wird in updatePauseResumeBtn() je nach Druckerzustand gesetzt.
setText('d-btn-resume',T.btn_resume); updatePauseResumeBtn();
setText('d-btn-cancel',T.btn_cancel); setText('d-btn-cancel',T.btn_cancel);
setText('cam-toggle-btn',camOn?T.btn_cam_stop:T.btn_cam_start); setText('cam-toggle-btn',camOn?T.btn_cam_stop:T.btn_cam_start);
setText('cam-placeholder-txt',T.cam_placeholder); setText('cam-placeholder-txt',T.cam_placeholder);
@@ -323,8 +323,18 @@ function applyLang(){
setText('lbl-default-slot',T.settings_default_slot); setText('lbl-default-slot',T.settings_default_slot);
setText('opt-slot-auto',T.settings_slot_auto); setText('opt-slot-auto',T.settings_slot_auto);
setText('lbl-auto-leveling',T.settings_auto_leveling); setText('lbl-auto-leveling',T.settings_auto_leveling);
setText('lbl-vibration-compensation',T.settings_vibration_compensation);
setText('lbl-flow-calibration',T.settings_flow_calibration);
setText('lbl-camera-on-print',T.settings_camera_on_print); setText('lbl-camera-on-print',T.settings_camera_on_print);
setText('lbl-web-upload-warning',T.settings_web_upload_warning); setText('lbl-web-upload-warning',T.settings_web_upload_warning);
setText('lbl-timelapse-local',T.settings_timelapse_local);
setText('lbl-timelapse-interval',T.settings_timelapse_interval);
setText('lbl-timelapse-printer',T.settings_timelapse_printer);
setText('lbl-layout',T.settings_layout);
setText('opt-layout-1col',T.settings_layout_1col);
setText('opt-layout-2col',T.settings_layout_2col);
setText('store-tab-files-label',T.store_tab_files||'Dateien');
setText('store-tab-tl-label',T.timelapse_tab);
setText('lbl-update-check',T.update_check); setText('lbl-update-check',T.update_check);
setText('lbl-update-apply',T.update_apply); setText('lbl-update-apply',T.update_apply);
@@ -612,11 +622,14 @@ function applyState(){
}else{frb.style.display='none';} }else{frb.style.display='none';}
} }
// skip-button (mid-print) nur sichtbar wenn aktuell gedruckt wird // skip-button (mid-print) nur sichtbar wenn aktuell gedruckt wird
var printing=(s.print_state==='printing'||s.print_state==='paused');
var skipBtn=document.getElementById('d-btn-skip'); var skipBtn=document.getElementById('d-btn-skip');
if(skipBtn){ if(skipBtn) skipBtn.style.display=printing?'':'none';
var printing=(s.print_state==='printing'||s.print_state==='paused'); // Pause/Stopp-Buttons nur bei aktivem Druck zeigen (sonst verwirrend wenn
skipBtn.style.display=printing?'':'none'; // der Drucker idle ist). Pause-Button rendert sich passend zum State um.
} var ctrlBtns=document.getElementById('d-ctrl-btns');
if(ctrlBtns) ctrlBtns.style.display=printing?'':'none';
updatePauseResumeBtn();
// header // header
var b=document.getElementById('h-badge'); var b=document.getElementById('h-badge');
@@ -778,10 +791,17 @@ function applyState(){
var activity=(slot.activity||''); var activity=(slot.activity||'');
var pct=empty?T.ams_empty:(slot.consumables_percent!=null?slot.consumables_percent+'%':''); var pct=empty?T.ams_empty:(slot.consumables_percent!=null?slot.consumables_percent+'%':'');
var slotLabel=T.label_slot+' '+(globalIdx+1); var slotLabel=T.label_slot+' '+(globalIdx+1);
var profile=(window._slotProfileMap||{})[globalIdx];
var vendorBadge='';
if(!empty && profile && profile.vendor){
var tt=(profile.name||'')+(profile.id?' ('+profile.id+')':'');
vendorBadge='<div class="slot-label" style="font-size:9px;color:var(--accent);font-weight:600;margin-top:1px" title="'+tt+'">'+profile.vendor+'</div>';
}
html+='<div class="ams-slot'+(active?' active':'')+(loaded?' loaded':'')+(activity?' '+activity:'')+(empty?' empty':'') html+='<div class="ams-slot'+(active?' active':'')+(loaded?' loaded':'')+(activity?' '+activity:'')+(empty?' empty':'')
+'" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+';cursor:pointer" onclick="openSlotEdit('+i+')">' +'" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+';cursor:pointer" onclick="openSlotEdit('+i+')">'
+'<div class="slot-circle" style="background:'+col+'"></div>' +'<div class="slot-circle" style="background:'+col+'"></div>'
+'<div class="slot-material">'+(empty?'':(slot.type||slot.material_type||''))+'</div>' +'<div class="slot-material">'+(empty?'':(slot.type||slot.material_type||''))+'</div>'
+vendorBadge
+'<div class="slot-label">'+slotLabel+'</div>' +'<div class="slot-label">'+slotLabel+'</div>'
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>' +'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>'
+'<div style="font-size:9px;color:var(--txt2);margin-top:2px">✏</div>' +'<div style="font-size:9px;color:var(--txt2);margin-top:2px">✏</div>'
@@ -880,8 +900,14 @@ function openSettings(){
document.getElementById('s-mode-id').value=d.mode_id||''; document.getElementById('s-mode-id').value=d.mode_id||'';
document.getElementById('s-default-slot').value=d.default_ams_slot||'auto'; document.getElementById('s-default-slot').value=d.default_ams_slot||'auto';
document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling); document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling);
var vc=document.getElementById('s-vibration-compensation');if(vc)vc.checked=!!d.vibration_compensation;
var fc=document.getElementById('s-flow-calibration');if(fc)fc.checked=!!d.flow_calibration;
var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print; var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print;
var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning); var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning);
var tlLocal=document.getElementById('s-timelapse-local');if(tlLocal){tlLocal.checked=!!d.timelapse_local;_toggleTlInterval();}
var tlInt=document.getElementById('s-timelapse-interval');if(tlInt)tlInt.value=d.timelapse_interval_sec||4;
var tlPrinter=document.getElementById('s-timelapse-printer');if(tlPrinter)tlPrinter.checked=!!d.timelapse_printer;
var lay=document.getElementById('s-layout');if(lay)lay.value=localStorage.getItem('layout')||'1col';
}); });
var v=localStorage.getItem('pollInterval')||'2000'; var v=localStorage.getItem('pollInterval')||'2000';
document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')}); document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')});
@@ -898,10 +924,90 @@ function closeSettings(){
document.getElementById('settings-modal').classList.remove('open'); 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 &bull; '+dur+t.created_at.slice(0,10)+'</div>'+
'</div>'+
dlBtn+
'<button onclick="deleteTimelapse(\''+t.id+'\')" style="flex-shrink:0;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer;font-size:12px">✕</button>'+
'</div>';
}).join('');
}
function deleteTimelapse(id){
if(!confirm(T.confirm_delete_timelapse||'Dieses Timelapse löschen?'))return;
fetch(_apiUrl('/kx/timelapse/'+id),{method:'DELETE'}).then(function(){loadTimelapses();});
}
// ── AMS Slot Edit ── // ── AMS Slot Edit ──
var _slotEditIndex=-1; var _slotEditIndex=-1;
var _slotEditLoaded=false; var _slotEditLoaded=false;
var _MAT_PRESETS=['PLA','PETG','ABS','ASA','TPU','PA','PC','HIPS']; var _MAT_PRESETS=['PLA','PETG','ABS','ASA','TPU','PA','PC','HIPS'];
var _BASE_MATERIAL_TYPES=['PLA','PETG','ABS','ASA','TPU','TPE','PA','PC','HIPS','PEI','PEEK'];
function updateSlotEditFeedButton(){ function updateSlotEditFeedButton(){
var btn=document.getElementById('btn-slot-edit-feed'); var btn=document.getElementById('btn-slot-edit-feed');
if(!btn)return; if(!btn)return;
@@ -920,9 +1026,16 @@ function _loadOrcaFilaments(cb){
cb(_orcaFilamentCache); cb(_orcaFilamentCache);
}).catch(function(){ cb([]); }); }).catch(function(){ cb([]); });
} }
function _fillSlotProfileDropdown(material, currentId){ function _profileKey(vendor, name){
// Eindeutiger Selector: (vendor, name). IDs aus orca_filaments.json sind
// NICHT eindeutig (z.B. 136 Profile mit OGFL99). Wir kodieren beide in den
// <option>-Value-String mit | als Trenner.
return (vendor||'')+'|'+(name||'');
}
function _fillSlotProfileDropdown(material, currentVendor, currentName){
var sel=document.getElementById('slot-edit-profile'); var sel=document.getElementById('slot-edit-profile');
if(!sel) return; if(!sel) return;
var wantKey=_profileKey(currentVendor, currentName);
_loadOrcaFilaments(function(profiles){ _loadOrcaFilaments(function(profiles){
// Type-Filter: nur Profile vom passenden material zeigen (z.B. PLA → alle PLA-Varianten) // Type-Filter: nur Profile vom passenden material zeigen (z.B. PLA → alle PLA-Varianten)
var matU=(material||'').toUpperCase().trim(); var matU=(material||'').toUpperCase().trim();
@@ -939,9 +1052,12 @@ function _fillSlotProfileDropdown(material, currentId){
var g=document.createElement('optgroup'); g.label=v; var g=document.createElement('optgroup'); g.label=v;
byVendor[v].forEach(function(p){ byVendor[v].forEach(function(p){
var o=document.createElement('option'); 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; o.textContent=p.name;
if(p.id===currentId) o.selected=true; if(o.value===wantKey) o.selected=true;
g.appendChild(o); g.appendChild(o);
}); });
sel.appendChild(g); 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>'; +(m===mat?'background:var(--accent);color:#fff':'background:var(--raised);color:var(--txt2)')+'">'+m+'</button>';
}).join(''); }).join('');
// OrcaSlicer-Profil-Dropdown: aktuellen User-Override für diesen Slot // OrcaSlicer-Profil-Dropdown: aktuellen User-Override für diesen Slot
// aus /kx/filament/slots holen (enthält filament_id+filament_vendor). // aus /kx/filament/slots holen (enthält vendor+name+id).
// Mit dem material-Filter (PLA→PLA*) wird die Liste auf passende Profile reduziert.
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json();}).then(function(d){ fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json();}).then(function(d){
var arr=d.result||[]; var arr=d.result||[];
var entry=arr.find(function(x){return x.slot_index===globalIdx;})||{}; var entry=arr.find(function(x){return x.slot_index===globalIdx;})||{};
window._slotProfileMap=window._slotProfileMap||{}; window._slotProfileMap=window._slotProfileMap||{};
window._slotProfileMap[globalIdx]={id:entry.filament_id||'',vendor:entry.filament_vendor||''}; window._slotProfileMap[globalIdx]={
_fillSlotProfileDropdown(mat, entry.filament_id||''); id: entry.filament_id||'',
}).catch(function(){ _fillSlotProfileDropdown(mat,''); }); vendor:entry.filament_vendor||'',
name: entry.filament_name||'',
};
_fillSlotProfileDropdown(mat, entry.filament_vendor||'', entry.filament_name||'');
}).catch(function(){ _fillSlotProfileDropdown(mat,'',''); });
updateSlotEditFeedButton(); updateSlotEditFeedButton();
document.getElementById('slot-edit-modal').classList.add('open'); document.getElementById('slot-edit-modal').classList.add('open');
} }
@@ -1023,7 +1142,7 @@ function selectMatPreset(m){
highlightMatBtn(m); highlightMatBtn(m);
// Filament-Profile-Dropdown an neues Material anpassen // Filament-Profile-Dropdown an neues Material anpassen
// (vorherige Selektion zurücksetzen — andere Material-Profile passen nicht) // (vorherige Selektion zurücksetzen — andere Material-Profile passen nicht)
_fillSlotProfileDropdown(m, ''); _fillSlotProfileDropdown(m, '', '');
} }
function highlightMatBtn(val){ function highlightMatBtn(val){
document.querySelectorAll('.mat-preset-btn').forEach(function(b){ document.querySelectorAll('.mat-preset-btn').forEach(function(b){
@@ -1032,7 +1151,7 @@ function highlightMatBtn(val){
b.style.color=on?'#fff':'var(--txt2)'; b.style.color=on?'#fff':'var(--txt2)';
}); });
// Auch bei manueller Eingabe ins Material-Textfeld: Dropdown refreshen. // Auch bei manueller Eingabe ins Material-Textfeld: Dropdown refreshen.
if(val) _fillSlotProfileDropdown(val, ''); if(val) _fillSlotProfileDropdown(val, '', '');
} }
function hexToRgb(hex){ function hexToRgb(hex){
var r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16); var r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
@@ -1043,31 +1162,55 @@ function saveSlotEdit(){
var mat=document.getElementById('slot-edit-mat').value.trim().toUpperCase()||'PLA'; var mat=document.getElementById('slot-edit-mat').value.trim().toUpperCase()||'PLA';
var color=hexToRgb(hex); var color=hexToRgb(hex);
var slotIdx=_slotEditIndex; var slotIdx=_slotEditIndex;
// OrcaSlicer-Profil-Override: parallel persistieren (Profile bleiben auch
// erhalten wenn der User nur Farbe/Material ändert)
var profSel=document.getElementById('slot-edit-profile'); var profSel=document.getElementById('slot-edit-profile');
var newProfId=profSel?profSel.value:''; var sel=profSel && profSel.selectedOptions && profSel.selectedOptions[0];
var newProfVendor=''; // Primärer Selector: (vendor, name). id ist nur Hint (aus dem JSON-data-attr
if(profSel && profSel.selectedOptions && profSel.selectedOptions[0]){ // mitgegeben — Backend lookt sie nochmal selber nach um veraltete Hints zu
newProfVendor=profSel.selectedOptions[0].dataset.vendor||''; // korrigieren).
} var newProfVendor=sel?(sel.dataset.vendor||''):'';
var newProfName =sel?(sel.dataset.name ||''):'';
var newProfId =sel?(sel.dataset.id ||''):'';
// Sequenziell speichern: erst Profil-Override (config.ini), dann Material/Farbe
// (MQTT zum Drucker). Sonst können beide Pfade sich überholen und der Slot-State
// ist beim nächsten Re-Open inkonsistent.
fetch(_apiUrl('/kx/filament/slots/'+slotIdx+'/profile'),{ fetch(_apiUrl('/kx/filament/slots/'+slotIdx+'/profile'),{
method:'POST', method:'POST',
headers:{'Content-Type':'application/json'}, headers:{'Content-Type':'application/json'},
body:JSON.stringify({id:newProfId,vendor:newProfVendor}) body:JSON.stringify({vendor:newProfVendor, name:newProfName, id:newProfId})
}).then(function(r){return r.json();}).then(function(){ })
.then(function(r){return r.json();})
.then(function(){
window._slotProfileMap=window._slotProfileMap||{}; window._slotProfileMap=window._slotProfileMap||{};
if(newProfId){ window._slotProfileMap[slotIdx]={id:newProfId,vendor:newProfVendor}; } if(newProfVendor && newProfName){
else delete window._slotProfileMap[slotIdx]; window._slotProfileMap[slotIdx]={id:newProfId, vendor:newProfVendor, name:newProfName};
}).catch(function(e){clog('Profil-Speichern fehlgeschlagen: '+e,'msg-err');}); } else {
post('/api/ams/set_slot',{index:slotIdx,type:mat,color:color}) delete window._slotProfileMap[slotIdx];
.then(function(r){return r.json();}) }
.then(function(r){ return post('/api/ams/set_slot',{index:slotIdx,type:mat,color:color});
closeSlotEdit(); })
var profSuffix=newProfId?(' ['+newProfId+']'):''; .then(function(r){return r?r.json():null;})
clog(tr('slot_edit_ok')+' '+(slotIdx+1)+': '+mat+' '+hex+profSuffix,'msg-ok'); .then(function(){
}) // Slot-Map refreshen damit die Karte sofort den Vendor zeigt.
.catch(function(e){clog('Fehler: '+e,'msg-err');}); return fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json();});
})
.then(function(d){
var arr=(d && d.result)||[];
window._slotProfileMap={};
arr.forEach(function(e){
if(e.filament_vendor && e.filament_name){
window._slotProfileMap[e.slot_index]={
id: e.filament_id||'',
vendor:e.filament_vendor,
name: e.filament_name,
};
}
});
closeSlotEdit();
var profSuffix=newProfName?(' ['+newProfVendor+' '+newProfName+']'):'';
clog(tr('slot_edit_ok')+' '+(slotIdx+1)+': '+mat+' '+hex+profSuffix,'msg-ok');
if(typeof poll==='function') poll();
})
.catch(function(e){clog('Fehler: '+e,'msg-err');});
} }
document.addEventListener('DOMContentLoaded',function(){ document.addEventListener('DOMContentLoaded',function(){
document.getElementById('s-printer-ip').addEventListener('input',function(){ document.getElementById('s-printer-ip').addEventListener('input',function(){
@@ -1098,10 +1241,16 @@ function saveSettings(){
device_id: document.getElementById('s-device-id').value, device_id: document.getElementById('s-device-id').value,
mode_id: document.getElementById('s-mode-id').value, mode_id: document.getElementById('s-mode-id').value,
default_ams_slot: document.getElementById('s-default-slot').value, default_ams_slot: document.getElementById('s-default-slot').value,
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0, auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
camera_on_print: (document.getElementById('s-camera-on-print')||{}).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, web_upload_warning:webUploadWarning,
timelapse_local: (document.getElementById('s-timelapse-local')||{}).checked?1:0,
timelapse_interval_sec: parseInt((document.getElementById('s-timelapse-interval')||{value:'4'}).value)||4,
timelapse_printer: (document.getElementById('s-timelapse-printer')||{}).checked?1:0,
}).then(function(){ }).then(function(){
applyLayout((document.getElementById('s-layout')||{value:'1col'}).value);
btn.textContent=T.update_restarting; btn.textContent=T.update_restarting;
setTimeout(function(){ setTimeout(function(){
btn.disabled=false; btn.disabled=false;
@@ -1164,7 +1313,23 @@ var pollTimer;
(function(){ (function(){
var ms=parseInt(localStorage.getItem('pollInterval')||'2000'); var ms=parseInt(localStorage.getItem('pollInterval')||'2000');
initPrinters(); initPrinters();
// Slot-Profile-Map initial laden, sonst zeigen die Karten beim ersten
// Render keine Vendor-Badge obwohl in der config.ini ein Override steht.
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json();}).then(function(d){
var arr=(d && d.result)||[];
window._slotProfileMap={};
arr.forEach(function(e){
if(e.filament_vendor && e.filament_name){
window._slotProfileMap[e.slot_index]={
id: e.filament_id||'',
vendor:e.filament_vendor,
name: e.filament_name,
};
}
});
}).catch(function(){});
poll();pollTimer=setInterval(poll,ms); poll();pollTimer=setInterval(poll,ms);
applyLayout(localStorage.getItem('layout')||'1col');
})(); })();
// ── Print actions ── // ── Print actions ──
@@ -1172,7 +1337,28 @@ function printAction(a){
post('/printer/print/'+a,{}).then(function(){clog('Druck: '+a,'msg-ok');poll()}) post('/printer/print/'+a,{}).then(function(){clog('Druck: '+a,'msg-ok');poll()})
.catch(function(e){clog('Fehler: '+e,'msg-err')}); .catch(function(e){clog('Fehler: '+e,'msg-err')});
} }
function confirmCancel(){if(confirm('Druck wirklich abbrechen?'))printAction('cancel')} function togglePauseResume(){
// Druckt → pause; Pausiert → resume. Status kommt aus dem zuletzt gepollten
// print_state in S; bei Unklarheit (kein State) Pause als Default.
var state=(S && S.print_state)||'';
if(state==='paused') printAction('resume');
else printAction('pause');
}
function updatePauseResumeBtn(){
var btn=document.getElementById('d-btn-pause');
if(!btn) return;
var state=(S && S.print_state)||'';
if(state==='paused'){
btn.textContent=T.btn_resume||'▶ Weiter';
btn.classList.add('btn-resume');
btn.classList.remove('btn-pause');
} else {
btn.textContent=T.btn_pause||'⏸ Pause';
btn.classList.add('btn-pause');
btn.classList.remove('btn-resume');
}
}
function confirmCancel(){if(confirm(T.confirm_cancel||'Druck wirklich abbrechen?'))printAction('cancel')}
// ── Axis motion ── // ── Axis motion ──
// axis codes: 0=X, 1=Y, 2=Z // axis codes: 0=X, 1=Y, 2=Z
@@ -1901,6 +2087,20 @@ function _normalizeMaterialKey(material){
var key=(material||'').toUpperCase().replace(/[^A-Z0-9+]/g,''); var key=(material||'').toUpperCase().replace(/[^A-Z0-9+]/g,'');
// Orca often uses PLA for PLA+, while AMS may report PLA+. // Orca often uses PLA for PLA+, while AMS may report PLA+.
if(key==='PLA+'||key==='PLAPLUS') return 'PLA'; if(key==='PLA+'||key==='PLAPLUS') return 'PLA';
// Handle modifier+base patterns in either order: "Matte PLA", "Silk PETG",
// "PLA Silk", "PLA Matte". OrcaSlicer always writes the base type in GCode
// (filament_type = PLA), but users label slots with the full product-style name.
// Scan each space-separated word; return the first one that is a known base material.
// Dash-suffix variants ("PLA-CF", "PETG-CF") contain no space and fall through
// unchanged, preserving correct incompatibility with their base types.
var trimmed=(material||'').trim();
if(trimmed.indexOf(' ')>=0){
var words=trimmed.toUpperCase().split(/\s+/);
for(var i=0;i<words.length;i++){
var w=words[i].replace(/[^A-Z0-9+]/g,'');
if(_BASE_MATERIAL_TYPES.indexOf(w)>=0) return w;
}
}
return key; return key;
} }
function _materialsCompatible(a,b){ function _materialsCompatible(a,b){

View File

@@ -102,6 +102,14 @@
<input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0"> <input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0">
<label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label> <label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label>
</div> </div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-vibration-compensation" style="width:auto;margin:0">
<label id="lbl-vibration-compensation" style="margin:0;cursor:pointer" for="s-vibration-compensation">Resonance Compensation</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-flow-calibration" style="width:auto;margin:0">
<label id="lbl-flow-calibration" style="margin:0;cursor:pointer" for="s-flow-calibration">Flow Calibration</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px"> <div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-camera-on-print" style="width:auto;margin:0"> <input type="checkbox" id="s-camera-on-print" style="width:auto;margin:0">
<label id="lbl-camera-on-print" style="margin:0;cursor:pointer" for="s-camera-on-print">Kamera bei Druckstart einschalten</label> <label id="lbl-camera-on-print" style="margin:0;cursor:pointer" for="s-camera-on-print">Kamera bei Druckstart einschalten</label>
@@ -110,6 +118,29 @@
<input type="checkbox" id="s-web-upload-warning" style="width:auto;margin:0"> <input type="checkbox" id="s-web-upload-warning" style="width:auto;margin:0">
<label id="lbl-web-upload-warning" style="margin:0;cursor:pointer" for="s-web-upload-warning">Warnung bei Web-Upload-Druck anzeigen</label> <label id="lbl-web-upload-warning" style="margin:0;cursor:pointer" for="s-web-upload-warning">Warnung bei Web-Upload-Druck anzeigen</label>
</div> </div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-timelapse-local" style="width:auto;margin:0" onchange="_toggleTlInterval()">
<label id="lbl-timelapse-local" style="margin:0;cursor:pointer" for="s-timelapse-local">Lokales Timelapse während Druck</label>
</div>
<div class="modal-field" id="timelapse-interval-row" style="display:none">
<label id="lbl-timelapse-interval" style="font-size:12px;color:var(--txt2)">Aufnahme-Intervall (Sekunden)</label>
<input type="number" id="s-timelapse-interval" min="1" max="60" value="4" style="width:80px">
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-timelapse-printer" style="width:auto;margin:0">
<label id="lbl-timelapse-printer" style="margin:0;cursor:pointer" for="s-timelapse-printer">Drucker-Timelapse aktivieren (experimentell)</label>
</div>
</div>
<div>
<div class="modal-section" id="modal-sec-layout">Layout</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px;flex-wrap:wrap">
<label id="lbl-layout" style="margin:0;font-size:12px;color:var(--txt2)">Dashboard-Layout</label>
<select id="s-layout" style="width:auto;margin:0">
<option value="1col" id="opt-layout-1col">1 Spalte</option>
<option value="2col" id="opt-layout-2col">2 Spalten</option>
</select>
</div>
</div> </div>
<div> <div>
@@ -193,7 +224,7 @@
<div class="panel active" id="panel-dashboard"> <div class="panel active" id="panel-dashboard">
<div class="grid"> <div class="grid">
<!-- Kamera --> <!-- Kamera -->
<div class="card" 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 style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<div class="card-title" style="margin-bottom:0"><span>📷</span> <span id="d-card-cam">Kamera</span></div> <div class="card-title" style="margin-bottom:0"><span>📷</span> <span id="d-card-cam">Kamera</span></div>
<div style="display:flex;align-items:center;gap:10px"> <div style="display:flex;align-items:center;gap:10px">
@@ -217,7 +248,7 @@
</div> </div>
<!-- Fortschritt --> <!-- 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> <div class="card-title"><span></span> <span id="d-card-progress">Fortschritt</span></div>
<img id="d-thumbnail" src="" alt="" style="display:none;width:100%;max-height:160px;object-fit:contain;border-radius:8px;background:#111;margin-bottom:10px"> <img id="d-thumbnail" src="" alt="" style="display:none;width:100%;max-height:160px;object-fit:contain;border-radius:8px;background:#111;margin-bottom:10px">
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div> <div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
@@ -243,16 +274,15 @@
</div> </div>
</div> </div>
<div class="fname" id="d-fname" title="" style="margin-top:6px"></div> <div class="fname" id="d-fname" title="" style="margin-top:6px"></div>
<div class="ctrl-btns" id="d-ctrl-btns" style="margin-top:12px"> <div class="ctrl-btns" id="d-ctrl-btns" style="margin-top:12px;display:none">
<button class="btn btn-pause btn-sm" id="d-btn-pause" onclick="printAction('pause')">⏸ Pause</button> <button class="btn btn-pause btn-sm" id="d-btn-pause" onclick="togglePauseResume()">⏸ Pause</button>
<button class="btn btn-resume btn-sm" id="d-btn-resume" onclick="printAction('resume')">▶ Weiter</button>
<button class="btn btn-skip btn-sm" id="d-btn-skip" onclick="openSkipDialog()" style="display:none"><span id="d-btn-skip-label">Objekte</span></button> <button class="btn btn-skip btn-sm" id="d-btn-skip" onclick="openSkipDialog()" style="display:none"><span id="d-btn-skip-label">Objekte</span></button>
<button class="btn btn-cancel btn-sm" id="d-btn-cancel" onclick="confirmCancel()">✕ Stopp</button> <button class="btn btn-cancel btn-sm" id="d-btn-cancel" onclick="confirmCancel()">✕ Stopp</button>
</div> </div>
</div> </div>
<!-- Temperatursteuerung + Verlauf --> <!-- 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="card-title"><span></span> <span id="d-card-temps">Temperaturen</span></div>
<div class="temp-card-inner"> <div class="temp-card-inner">
<div class="temp-block"> <div class="temp-block">
@@ -402,8 +432,15 @@
<div class="card"> <div class="card">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px"> <div class="card-title" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<span id="store-panel-title">🗂 Datei-Browser</span> <span id="store-panel-title">🗂 Datei-Browser</span>
<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> </div>
<!-- Dateien-Bereich -->
<div id="store-files-section">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap"> <div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()" <input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px"> style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
@@ -434,6 +471,12 @@
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px"> <div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
</div> </div>
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div> <div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div><!-- /store-files-section -->
<!-- Timelapse-Bereich -->
<div id="store-timelapses-section" style="display:none">
<div id="timelapse-list"><div style="text-align:center;padding:40px;color:var(--txt2)" id="timelapse-empty-msg">Keine Timelapses vorhanden</div></div>
</div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -20,9 +20,9 @@
"kobra_finished": "Finalizado", "kobra_finished": "Finalizado",
"kobra_failed": "Error", "kobra_failed": "Error",
"kobra_canceled": "Cancelado", "kobra_canceled": "Cancelado",
"kobra_offline": "Offline", "kobra_offline": "Desconectada",
"nav_dashboard": "Panel", "nav_dashboard": "Panel",
"nav_print": "Impresion", "nav_print": "Impresión",
"nav_temps": "Temperaturas", "nav_temps": "Temperaturas",
"nav_motion": "Movimiento", "nav_motion": "Movimiento",
"nav_ams": "AMS", "nav_ams": "AMS",
@@ -31,12 +31,12 @@
"card_progress": "Progreso", "card_progress": "Progreso",
"card_temps": "Temperaturas", "card_temps": "Temperaturas",
"card_light_fan": "Ventilador", "card_light_fan": "Ventilador",
"card_speed": "Velocidad de impresion", "card_speed": "Velocidad de impresión",
"card_cam": "Camara", "card_cam": "Cámara",
"lbl_elapsed": "Transcurrido:", "lbl_elapsed": "Transcurrido:",
"lbl_remaining": "Restante:", "lbl_remaining": "Restante:",
"lbl_slicer_time": "Estimacion del slicer:", "lbl_slicer_time": "Estimación del slicer:",
"lbl_layers": "Layer", "lbl_layers": "Capa",
"speed_silent": "🐢 Silencioso", "speed_silent": "🐢 Silencioso",
"speed_normal": "⚡ Normal", "speed_normal": "⚡ Normal",
"speed_sport": "🚀 Sport", "speed_sport": "🚀 Sport",
@@ -52,14 +52,14 @@
"ace_dry_current_temp": "Temperatura", "ace_dry_current_temp": "Temperatura",
"ace_dry_chart": "Historial (Temp/Humedad)", "ace_dry_chart": "Historial (Temp/Humedad)",
"ace_dry_temp": "Temperatura (°C)", "ace_dry_temp": "Temperatura (°C)",
"ace_dry_duration": "Duracion (min)", "ace_dry_duration": "Duración (min)",
"ace_dry_start": "▶ Start", "ace_dry_start": "▶ Iniciar",
"ace_dry_stop": "■ Stop", "ace_dry_stop": "■ Parar",
"ace_dry_auto_refill": "Relleno automatico", "ace_dry_auto_refill": "Relleno automático",
"ace_dry_enable": "Activar secado", "ace_dry_enable": "Activar secado",
"ace_dry_temp_line": "Temperatura de secado", "ace_dry_temp_line": "Temperatura de secado",
"ace_dry_time_line": "Tiempo de secado", "ace_dry_time_line": "Tiempo de secado",
"ace_dry_ui_pending": "(solo UI, backend despues)", "ace_dry_ui_pending": "(solo UI, backend después)",
"ace_dry_dialog_title": "Ajustes de temp/tiempo del secador", "ace_dry_dialog_title": "Ajustes de temp/tiempo del secador",
"ace_dry_dialog_temp": "Temperatura (30-80°C)", "ace_dry_dialog_temp": "Temperatura (30-80°C)",
"ace_dry_dialog_time": "Tiempo restante (h:m:s)", "ace_dry_dialog_time": "Tiempo restante (h:m:s)",
@@ -67,12 +67,12 @@
"ace_dry_dialog_cancel": "Cancelar", "ace_dry_dialog_cancel": "Cancelar",
"ace_dry_dialog_save_restart": "Guardar y reiniciar", "ace_dry_dialog_save_restart": "Guardar y reiniciar",
"ace_dry_dialog_custom_name": "Nombre personalizado", "ace_dry_dialog_custom_name": "Nombre personalizado",
"ace_dry_dialog_reset_default": "Restablecer por defecto", "ace_dry_dialog_reset_default": "Restablecer valores predeterminados",
"cam_placeholder": "📷 Camara no iniciada", "cam_placeholder": "📷 Cámara no iniciada",
"cam_stream_unavailable": "Stream no disponible", "cam_stream_unavailable": "Stream no disponible",
"btn_cam_start": "▶ Camara", "btn_cam_start": "▶ Cámara",
"btn_cam_stop": "◼ Camara", "btn_cam_stop": "◼ Cámara",
"btn_pause": "⏸ Pause", "btn_pause": "⏸ Pausa",
"btn_resume": "▶ Reanudar", "btn_resume": "▶ Reanudar",
"btn_cancel": "✕ Detener", "btn_cancel": "✕ Detener",
"label_nozzle": "Boquilla", "label_nozzle": "Boquilla",
@@ -81,20 +81,20 @@
"label_light": "💡 Luz", "label_light": "💡 Luz",
"label_on_off": "Encendido / Apagado", "label_on_off": "Encendido / Apagado",
"label_speed": "Velocidad", "label_speed": "Velocidad",
"panel_print_title": "Control de impresion", "panel_print_title": "Control de impresión",
"panel_print_btn_pause": "⏸ Pause", "panel_print_btn_pause": "⏸ Pausa",
"panel_print_btn_resume": "▶ Reanudar", "panel_print_btn_resume": "▶ Reanudar",
"panel_print_btn_cancel": "✕ Cancelar", "panel_print_btn_cancel": "✕ Cancelar",
"panel_print_temps_live": "Temperaturas (en vivo)", "panel_print_temps_live": "Temperaturas (en vivo)",
"label_set": "Set", "label_set": "Set",
"label_off": "Off", "label_off": "Apagado",
"panel_temps_nozzle": "Boquilla", "panel_temps_nozzle": "Boquilla",
"panel_temps_bed": "Cama caliente", "panel_temps_bed": "Cama caliente",
"panel_temps_chart": "Historial (ultimas 60 lecturas)", "panel_temps_chart": "Historial (últimas 60 lecturas)",
"label_target_c": "Objetivo:", "label_target_c": "Objetivo:",
"panel_motion_xy": "Ejes XY", "panel_motion_xy": "Ejes XY",
"panel_motion_z": "Eje Z", "panel_motion_z": "Eje Z",
"label_step": "Tamano del paso:", "label_step": "Tamaño del paso:",
"btn_home_z": "Home Z", "btn_home_z": "Home Z",
"btn_home_xy": "Home XY", "btn_home_xy": "Home XY",
"btn_home_all": "Home All", "btn_home_all": "Home All",
@@ -103,11 +103,11 @@
"card_ams": "Filamento", "card_ams": "Filamento",
"ams_no_data": "No se recibieron datos de AMS", "ams_no_data": "No se recibieron datos de AMS",
"label_slot": "Ranura", "label_slot": "Ranura",
"ams_empty": "Vacio", "ams_empty": "Vacío",
"panel_extras_light": "Luz", "panel_extras_light": "Luz",
"panel_extras_fan": "Ventilador", "panel_extras_fan": "Ventilador",
"panel_extras_camera": "Camara", "panel_extras_camera": "Cámara",
"btn_cam_start2": "▶ Start", "btn_cam_start2": "▶ Iniciar",
"btn_cam_stop2": "◼ Detener", "btn_cam_stop2": "◼ Detener",
"panel_console_title": "Registro de eventos", "panel_console_title": "Registro de eventos",
"log_light_on": "Luz encendida", "log_light_on": "Luz encendida",
@@ -118,30 +118,44 @@
"log_axis": "Eje", "log_axis": "Eje",
"log_home": "Home", "log_home": "Home",
"log_home_all": "Home All", "log_home_all": "Home All",
"log_cam_start": "Camara iniciada:", "log_cam_start": "Cámara iniciada:",
"log_cam_stop": "Camara detenida", "log_cam_stop": "Cámara detenida",
"log_poll_error": "Error de sondeo:", "log_poll_error": "Error de sondeo:",
"log_error": "Error:", "log_error": "Error:",
"confirm_cancel": "Realmente cancelar la impresion?", "confirm_cancel": "¿Realmente cancelar la impresión?",
"settings_title": "Configuracion", "settings_title": "Configuración",
"settings_connection": "Conexion", "settings_connection": "Conexión",
"settings_print": "Ajustes de impresion", "settings_print": "Ajustes de impresión",
"settings_poll": "Intervalo de sondeo", "settings_poll": "Intervalo de sondeo",
"settings_version": "Version", "settings_version": "Versión",
"settings_save": "Guardar y reiniciar", "settings_save": "Guardar y reiniciar",
"settings_printer_name": "Nombre de impresora", "settings_printer_name": "Nombre de impresora",
"settings_printer_ip": "IP de impresora", "settings_printer_ip": "IP de impresora",
"settings_mqtt_port": "MQTT Port", "settings_mqtt_port": "MQTT Port",
"settings_username": "Usuario MQTT", "settings_username": "Usuario MQTT",
"settings_password": "Contrasena MQTT", "settings_password": "Contraseña MQTT",
"settings_device_id": "ID del dispositivo", "settings_device_id": "ID del dispositivo",
"settings_mode_id": "Mode ID", "settings_mode_id": "ID de modo",
"hint_ip_no_port": "Solo direccion IP, sin puerto (p. ej. 192.168.1.102)", "hint_ip_no_port": "Solo dirección IP, sin puerto (p. ej. 192.168.1.102)",
"settings_default_slot": "Ranura predeterminada (un color)", "settings_default_slot": "Ranura predeterminada (un color)",
"settings_slot_auto": "Auto (todos los slots cargados)", "settings_slot_auto": "Auto (todos los slots cargados)",
"settings_auto_leveling": "Autonivelado antes de imprimir", "settings_auto_leveling": "Autonivelado antes de imprimir",
"settings_camera_on_print": "Encender camara al iniciar impresion", "settings_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_web_upload_warning": "Mostrar advertencia al imprimir subidas web",
"settings_timelapse_local": "Timelapse local durante la impresión",
"settings_timelapse_interval": "Intervalo de captura (segundos)",
"settings_timelapse_printer": "Activar timelapse de impresora (experimental)",
"settings_layout": "Diseño del panel",
"settings_layout_1col": "1 columna",
"settings_layout_2col": "2 columnas",
"store_tab_files": "Archivos",
"timelapse_tab": "Timelapses",
"timelapse_empty": "Aún no hay timelapses",
"timelapse_recording": "Grabando…",
"timelapse_processing": "Codificando…",
"confirm_delete_timelapse": "¿Eliminar este timelapse?",
"update_check": "Buscar actualizaciones", "update_check": "Buscar actualizaciones",
"update_checking": "Comprobando...", "update_checking": "Comprobando...",
"update_available": "disponible", "update_available": "disponible",
@@ -152,7 +166,7 @@
"update_error": "Error", "update_error": "Error",
"btn_connect": "⚡ Conectar", "btn_connect": "⚡ Conectar",
"btn_disconnect": "✕ Desconectar", "btn_disconnect": "✕ Desconectar",
"lbl_conn_error": "Error de conexion:", "lbl_conn_error": "Error de conexión:",
"slot_edit_title": "Editar slot", "slot_edit_title": "Editar slot",
"slot_edit_color": "Color", "slot_edit_color": "Color",
"slot_edit_material": "Material", "slot_edit_material": "Material",
@@ -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_hint": "Envía al sincronizar con OrcaSlicer la marca concreta en lugar de solo \"Generic\"",
"slot_edit_profile_default": "— Genérico (Predeterminado) —", "slot_edit_profile_default": "— Genérico (Predeterminado) —",
"log_dir_all": "Todos", "log_dir_all": "Todos",
"log_lvl_label": "Level:", "log_lvl_label": "Nivel:",
"file_ready_btn": "▶ Iniciar impresion", "file_ready_btn": "▶ Iniciar impresión",
"file_slots_btn": "🎨 Seleccionar ranuras", "file_slots_btn": "🎨 Seleccionar ranuras",
"file_cancel_btn": "✕ Cancelar", "file_cancel_btn": "✕ Cancelar",
"nav_printers": "Impresoras", "nav_printers": "Impresoras",
"skip_title": "✂ Omitir objetos", "skip_title": "✂ Omitir objetos",
"skip_hint": "Desmarca objetos que ya no quieras imprimir:", "skip_hint": "Deselecciona los objetos que ya no quieras imprimir:",
"skip_btn_label": "Objetos", "skip_btn_label": "Objetos",
"skip_no_objects": "No hay objetos en esta impresion.", "skip_no_objects": "No hay objetos en esta impresión.",
"skip_already": "omitido", "skip_already": "omitido",
"skip_select_at_least_one": "Elige al menos un objeto.", "skip_select_at_least_one": "Selecciona al menos un objeto.",
"skip_sending": "Enviando …", "skip_sending": "Enviando …",
"skip_success": "Se omitiran los objetos.", "skip_success": "Se omitirán los objetos.",
"fd_objects_hint": "Omitir objetos (opcional):", "fd_objects_hint": "Omitir objetos (opcional):",
"fd_slots_hint": "Asignar canal GCode a la ranura AMS:", "fd_slots_hint": "Asignar canal GCode a la ranura AMS:",
"fd_cancel": "Cancelar", "fd_cancel": "Cancelar",
"fd_print": "▶ Imprimir", "fd_print": "▶ Imprimir",
"fd_no_slots_msg": "No hay slots AMS cargados.{br}Iniciar impresion de todos modos?", "fd_no_slots_msg": "No hay slots AMS cargados.{br}¿Iniciar impresión de todos modos?",
"fd_slot": "Ranura", "fd_slot": "Ranura",
"fd_no_matching_material": "No hay material compatible", "fd_no_matching_material": "No hay material compatible",
"fd_used": "USADO", "fd_used": "USADO",
"add_printer": "Agregar impresora", "add_printer": "Añadir impresora",
"apd_lbl_ip": "IP de impresora", "apd_lbl_ip": "IP de impresora",
"apd_lbl_name": "Nombre (opcional)", "apd_lbl_name": "Nombre (opcional)",
"apd_placeholder_name": "p. ej. Kobra X Sala", "apd_placeholder_name": "p. ej. Kobra X Sala",
"apd_cancel": "Cancelar", "apd_cancel": "Cancelar",
"apd_confirm": "Agregar", "apd_confirm": "Añadir",
"apd_fetching": "Obteniendo datos de la impresora…", "apd_fetching": "Obteniendo datos de la impresora…",
"apd_success": "Impresora agregada, reiniciando bridge…", "apd_success": "Impresora añadida, reiniciando bridge…",
"apd_err_ip": "Introduce una direccion IP", "apd_err_ip": "Introduce una dirección IP",
"printers_remove": "Eliminar impresora", "printers_remove": "Eliminar impresora",
"printers_remove_confirm": "Eliminar impresora \"{name}\"? El bridge se reiniciara.", "printers_remove_confirm": "¿Eliminar impresora \"{name}\"? El bridge se reiniciará.",
"printers_active": "● activa", "printers_active": "● activa",
"printers_switch": "Cambiar →", "printers_switch": "Cambiar →",
"printers_current": "Impresora actual", "printers_current": "Impresora actual",
"printers_loading": "Cargando…", "printers_loading": "Cargando…",
"printers_none": "No hay impresoras configuradas.", "printers_none": "No hay impresoras configuradas.",
"printers_empty_hint": "Aun no hay impresora configurada.", "printers_empty_hint": "Aún no hay impresora configurada.",
"nav_browser": "Explorador", "nav_browser": "Explorador",
"panel_browser_title": "Explorador de archivos", "panel_browser_title": "Explorador de archivos",
"store_search_placeholder": "🔍 Buscar…", "store_search_placeholder": "🔍 Buscar…",
"store_empty": "Aun no hay archivos subidos.", "store_empty": "Aún no hay archivos subidos.",
"store_refresh": "↻ Actualizar", "store_refresh": "↻ Actualizar",
"store_print": "▶ Imprimir", "store_print": "▶ Imprimir",
"store_download": "⬇ Descargar", "store_download": "⬇ Descargar",
"store_delete_confirm": "Eliminar archivo?", "store_delete_confirm": "¿Eliminar archivo?",
"store_print_confirm": "Imprimir archivo?", "store_print_confirm": "¿Imprimir archivo?",
"store_web_verify_title": "Verificar archivo", "store_web_verify_title": "Verificar archivo",
"store_web_verify_msg": "Verifica que este archivo fue creado para Anycubic Kobra X.", "store_web_verify_msg": "Verifica que este archivo fue creado para Anycubic Kobra X.",
"store_web_verify_confirm": "Confirmar", "store_web_verify_confirm": "Confirmar",
"store_web_verify_abort": "Abortar", "store_web_verify_abort": "Abortar",
"store_no_results": "No se encontraron archivos.", "store_no_results": "No se encontraron archivos.",
"store_never": "nunca impreso", "store_never": "nunca impreso",
"store_estimate": "Estimacion", "store_estimate": "Estimación",
"store_upload_label_prefix": "Arrastra GCode aqui o ", "store_upload_label_prefix": "Arrastra el GCode aquí o ",
"store_upload_label_browse": "buscar", "store_upload_label_browse": "buscar",
"store_upload_busy": "⏳ Subiendo…", "store_upload_busy": "⏳ Subiendo…",
"store_upload_success": "✓ {file}", "store_upload_success": "✓ {file}",
@@ -230,5 +244,5 @@
"sf_new": "Nuevo", "sf_new": "Nuevo",
"ss_date": "↓ Fecha", "ss_date": "↓ Fecha",
"ss_name": "AZ Nombre", "ss_name": "AZ Nombre",
"ss_dur": "⏱ Tiempo de impresion" "ss_dur": "⏱ Tiempo de impresión"
} }

View File

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