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
## [0.9.18] 2026-05-31
### Neu
- **🎉 Filament-Material und -Farbe pro AMS-Slot aus der Bridge an den
Drucker senden:** Im Slot-Edit-Dialog gewählte Werte gehen jetzt
tatsächlich an den Drucker und werden persistent übernommen — am
Drucker-Display siehst du sofort dieselbe Belegung wie in der Bridge-UI.
In 0.9.17 wurde der Befehl über das falsche MQTT-Topic
(`slicer/printer/…`) gesendet, der Drucker hat ihn stillschweigend
ignoriert. Jetzt geht er über `web/printer/…` wie der Anycubic
Slicer Next es macht (verifiziert via Live-MQTT-Sniff +
Workbench-Vue-Source). **Achtung:** der aktiv geladene Slot kann
während des Drucks nicht umgeschrieben werden — vorher ausziehen.
- **Mehrsprachiges UI spanische Übersetzung von Muttersprachler
überarbeitet (PR #40 von @pezfisk):** fehlende Akzente
(impresión, cámara, después, animación, …), Begriffe vereinheitlicht
(Pause → Pausa, Start → Iniciar, Layer → Capa). Plus neues
`README.es.md` und Cross-Links in den drei READMEs.
- **Z-Höhen-Anzeige in Obico** funktioniert jetzt. Der Drucker liefert
keine echte Z-Position via MQTT (per Live-Sniff bestätigt), die Bridge
schätzt sie aus `curr_layer × layer_height + first_layer_height`.
Layer-Heights kommen aus dem GCode-Header beim Upload, persistiert
im GCode-Store; Fallback aus dem OrcaSlicer-Default-Filename
(`…_0.2_…gcode`). `/server/files/metadata` liefert zusätzlich
`object_height` (Gesamt-Z), damit Obicos `mmProgress`-Widget
`aktuelles Z / Gesamt-Z` anzeigt.
- **Slot-Karte zeigt den OrcaSlicer-Profil-Vendor** unter dem Material
(z.B. „PLA / Polymaker"), mit Profil-Namen + interner ID als
Tooltip. So ist auf einen Blick erkennbar welcher Slot-Override
aktiv ist.
### Fixes
- **Slot-Profil-Auswahl im AMS-Dialog (Issue #39 von @harrygeier):**
drei separate Bugs in 0.9.17 sorgten dafür dass die gewählte Marke
nach dem Speichern verschwand und beim erneuten Öffnen ein falsches
Material angezeigt wurde.
- `multiColorBox/setInfo` über das falsche MQTT-Topic — siehe oben.
- Speichern lief in zwei parallelen Requests (Profil-Override +
Material/Farbe) → Race-Bedingung. Läuft jetzt sequenziell und
reloaded den lokalen State bevor der Dialog geschlossen wird.
- OrcaSlicer-Filament-IDs sind nicht eindeutig — `orca_filaments.json`
hat 68 duplikate IDs, `OGFL99` allein ist 136 Vendor-Profilen
zugeordnet (Erkenntnis von @gangoke). Der primäre Selector ist
jetzt `(vendor, name)` — über alle 1002 Profile eindeutig.
- **MQTT-Reconnect (Issue #33 von @icebear):** wurde der Drucker über
Nacht ausgeschaltet, schlug die Bridge nach 5 Reconnect-Versuchen
(~60 s gesamt) endgültig fehl — Filament-Sync ging morgens noch
(weil das HTTP ist), aber Print-Start scheiterte mit
„connection refused", User musste die Bridge selbst neu starten.
Reader-Thread reconnectet jetzt **endlos** (Backoff cappt bei 60 s)
bis der Drucker wieder antwortet, mit DEBUG-Logging nach den ersten
5 Versuchen damit das Log nicht über Nacht zugemüllt wird.
- **„Unknown child pid"-Warnungen im Log:** beim Killen der Kamera-
`ffmpeg`-Prozesse fehlte das `wait()` — Children blieben als
Zombies und asyncio meldete sie alle ~20 s. Gefixt im CameraCache
+ `/api/camera/stream`.
### UI-Aufräumen
- **Pause-Button als Toggle:** druckt der Drucker → `⏸ Pause`, ist
pausiert → `▶ Weiter`. Der separate „Weiter"-Button entfällt.
- **Pause + Stopp komplett ausgeblendet wenn Drucker idle** — bei
Standby waren beide Buttons vorher dauerhaft sichtbar, was beim
Idle-Drucker verwirrend wirkte.
### Build
- **GCode-Store-Migration:** neue Spalten `layer_height` +
`first_layer_height` in `gcode_files` (automatisch beim ersten
Start von 0.9.18 angelegt).
## [0.9.17] 2026-05-30
### Neu

View File

@@ -1,5 +1,74 @@
# Changelog
## [0.9.18] 2026-05-31
### New
- **🎉 Push filament material and colour from the bridge to the
printer:** The values you pick in the slot-edit dialog now actually
reach the printer and stick — the printer display shows the same
slot setup as the bridge UI right away. In 0.9.17 the command was
sent over the wrong MQTT topic (`slicer/printer/…`) and the printer
silently dropped it. It now goes via `web/printer/…` like the
Anycubic Slicer Next does (verified by live MQTT sniff +
Workbench-Vue source). **Note:** the currently loaded slot can not
be overwritten during a print — unload it first.
- **Spanish translation reviewed by a native speaker (PR #40 by
@pezfisk):** missing accents (impresión, cámara, después,
animación, …) and term consistency (Pause → Pausa, Start →
Iniciar, Layer → Capa). New `README.es.md` and cross-links between
the three READMEs.
- **Z-height now shows up in Obico.** The printer does not report a
real Z position over MQTT (live-sniff confirmed), so the bridge
estimates it from `current_layer × layer_height + first_layer_height`.
Layer heights are parsed from the gcode header at upload time and
persisted in the gcode store; fallback for prints started directly
from the slicer is the OrcaSlicer default filename pattern
(`…_0.2_…gcode`). `/server/files/metadata` also serves
`object_height` (total Z) so Obicos `mmProgress` widget can render
`current Z / total Z`.
- **Slot card shows the OrcaSlicer profile vendor** under the
material (e.g. `PLA / Polymaker`), with the profile name + internal
ID as tooltip. Lets you see at a glance which slot override is
active.
### Fixes
- **Slot profile picker in the AMS dialog (issue #39 by
@harrygeier):** three separate bugs in 0.9.17 caused the chosen
brand to disappear after save and a different material to show up
on re-open.
- `multiColorBox/setInfo` was sent on the wrong MQTT topic — see
above.
- Save fired two parallel requests (profile override + material/
colour) → race. Now sequential, and the local state is reloaded
before the dialog closes.
- OrcaSlicer filament IDs are not unique — `orca_filaments.json`
has 68 duplicate IDs, `OGFL99` alone is shared by 136 vendor
profiles (caught by @gangoke). The primary selector is now
`(vendor, name)` — unique across all 1002 profiles.
- **MQTT reconnect (issue #33 by @icebear):** if the printer was
powered off overnight the bridge gave up after 5 reconnect attempts
(~60 s total) — filament sync still worked in the morning (its
HTTP), but starting a print failed with `connection refused` and
the user had to restart the bridge itself. The reader thread now
reconnects **forever** (backoff caps at 60 s) until the printer
responds again, with logs dropping to DEBUG after the first 5
attempts so an overnight outage does not spam the log.
- **`Unknown child pid` warnings in the log:** the camera ffmpeg
helpers were killed without awaiting their `wait()` — children
lingered as zombies and asyncio reported them every ~20 s. Fixed
in CameraCache + `/api/camera/stream`.
### UI polish
- **Pause button is now a toggle:** while printing → `⏸ Pause`,
while paused → `▶ Resume`. The separate resume button is gone.
- **Pause + stop hidden when the printer is idle** — both used to be
visible at all times, which was confusing on a standby printer.
### Build
- **gcode store migration:** new columns `layer_height` +
`first_layer_height` on `gcode_files` (added automatically on first
start of 0.9.18).
## [0.9.17] 2026-05-30
### New

View File

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

224
README.es.md Normal file
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.
<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>

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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