Compare commits

...

8 Commits

Author SHA1 Message Date
5e87f1f94f docs: update README video link and feature list
- Replace outdated YouTube tutorial link with the current video
- Add recently shipped features: custom RFID vendor matching, Spoolman
  integration, multi-ACE support, on-printer GCode browser tab with
  thumbnails, free-form dashboard grid, automatic camera reconnect
- Mention docker-compose-KX.yml (full stack: bridge + Spoolman +
  self-hosted Obico) as an option alongside the plain docker compose setup
2026-07-27 20:34:38 +02:00
6c9363c718 docs: translate docker-compose-KX.yml comments to English 2026-07-27 20:19:32 +02:00
86adde0a45 chore: Version auf 0.9.29 erhöhen
All checks were successful
Stable Release / release (push) Successful in 7m28s
2026-07-27 19:24:11 +02:00
gitea-actions
c4f321c9b0 chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.28-nightly48 release 2026-07-27 12:33:09 +00:00
1f1d60d571 feat(browser): show real GCode thumbnails in the "On Printer" tab
All checks were successful
Nightly Build / build (push) Successful in 6m53s
The printer's file/fileDetails MQTT action extracts and base64-encodes
the embedded "; thumbnail begin" block from a GCode file's header on
demand and returns it inline as data.file_details.thumbnail - verified
live against a real Kobra X (a valid 230x110 PNG came back for an
existing print file).

New endpoint GET /kx/printer-files/{filename}/thumbnail wraps this via
the existing _wait_for_file_action() helper (same fire-and-forget +
file/report-callback pattern as listLocal/deleteBatch). Results are
cached in-memory per filename (self._printer_thumbnail_cache) - a
file's thumbnail never changes while it exists on the printer, and the
tab can list 100+ files at once.

Frontend fetches thumbnails lazily through a small sequential queue
(_printerThumbQueue/_pumpPrinterThumbQueue) after rendering the file
list, one request at a time rather than firing 100+ concurrent MQTT
roundtrips, and swaps each card's placeholder printer icon for the
real <img> once its thumbnail arrives. Client-side cache
(_printerThumbCache) avoids re-fetching on re-render (e.g. after a
selection change).

Verified end-to-end against the real printer and visually via
Playwright: all 10 listed files rendered their actual, distinct print
preview thumbnails instead of the generic icon.
2026-07-27 14:25:50 +02:00
gitea-actions
9f76d28622 chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.28-nightly47 release 2026-07-27 12:13:12 +00:00
cbcb17f45a feat(browser): add second tab for files on the printer's own storage
All checks were successful
Nightly Build / build (push) Successful in 6m50s
The GCode browser previously only showed files the bridge itself had
stored (its own SQLite GCodeStore, uploaded through the bridge). Files
printed directly via Anycubic Slicer Next (bypassing the bridge) land
on the printer's internal storage instead, and were only visible/
manageable from the printer's own display.

Adds a second sub-tab ("On Printer") using the same master-detail
tab pattern already used for Settings categories (showSettingsCat),
backed by the printer's file/listLocal and file/deleteBatch MQTT
actions - verified live against a real Kobra X, see memory
reference_mqtt_listlocal.md.

Key implementation detail: publish()'s own return value for these
actions is just a generic immediate ACK skeleton (code=0, empty
fields) - the real response arrives asynchronously via the file/report
callback (_on_file), same as the existing fileDetails fire-and-forget
pattern. Added _wait_for_file_action() as a small reusable bridge
between that async callback delivery and the synchronous HTTP handler,
via a per-action threading.Event registered in _on_file.

New endpoints: GET /kx/printer-files, POST /kx/printer-files/delete
(single endpoint for both single- and multi-select delete, since
deleteBatch natively accepts a filename list).

Frontend mirrors the existing store multi-select pattern (Issue #94):
select mode, select-all (scoped to what's loaded), bulk delete with
confirmation. No print/download actions in this tab for now - printing
a file already on the printer without re-uploading needs its own MQTT
schema that hasn't been verified yet.

Verified end-to-end against the real printer: listed 146 files,
deleted one, confirmed via a follow-up list that it was gone and
nothing else was affected. Also verified visually (Playwright):
tab switching, card rendering, multi-select mode, and cancel all work
as expected.
2026-07-27 14:04:22 +02:00
gitea-actions
4e7f851799 chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.28-nightly46 release 2026-07-27 02:07:48 +00:00
17 changed files with 822 additions and 165 deletions

View File

@@ -1,12 +1,2 @@
## Changes in this build
- Fix: on printers with multiple daisy-chained ACE units and no toolhead buffer (e.g. Kobra S1 with 2 ACE Pro), only the first unit's 4 slots were ever shown on the dashboard or synced to OrcaSlicer — the bridge silently discarded every ACE unit after the first. All units now show up correctly (Issue #95, thanks @hoovercl for the detailed report and logs)
- Feat: the GCode browser now supports multi-select and bulk delete — click any file's checkbox to enter select mode, use "Select All" to grab everything currently visible (respecting your active search/filter), then delete the selection with one confirmation instead of one-by-one (Issue #94, thanks @Blaim)
- Fix: **printing via OrcaSlicer "Upload and print" failed (or fed the wrong spool) when a slot below the used filament was empty** — e.g. printing with Filament 4 while slot 3 was empty. The generated AMS slot mapping inserted a placeholder pointing at the physically empty tray, which the printer rejects. Placeholders now point at a loaded tray instead. Printing with all slots full already worked and is unchanged.
- Fix: manually assigning a filament profile to an ACE slot could crash the MQTT callback (`'list' object has no attribute 'get'`) when the printer rejected the assignment, e.g. for custom-RFID/third-party filament. The bridge now handles the printer's failure response cleanly instead of crashing, and the log now correlates a rejected assignment with the request that triggered it (slot/type/color) so a rejection reason can be diagnosed from bridge logs alone (Issue #100, thanks @Blaim)
- Fix: **the camera stream could hang forever after a printer reboot** — the printer rotates its stream token on reboot, but the running ffmpeg processes kept reading from the stale, now-silent connection and never noticed. The bridge now detects the URL change and automatically restarts the camera pipeline, ffmpeg itself now times out on a stalled read as a second line of defense, and `/api/camera/stream` now returns a proper 503 instead of hanging if no frame arrives within 5s (Issue #99, thanks @fmontagna for the excellent root-cause analysis and reproduction)
- Fix: shrinking the Progress dashboard tile below its default height clipped the lower content (time grid, filename, buttons) out of view instead of scaling with it; dashboard tiles in general could show a scrollbar even when there was nothing to scroll. Both fixed (Issue #97, thanks @Blaim)
- Feat: custom ACE-RFID filament tags (e.g. written via the "ACE RFID" app for third-party spools) are now matched automatically against your imported OrcaSlicer profiles. Anycubic's ACE RFID system concatenates vendor + material + a truncated serial into one type string (e.g. "GEEETECH PLA Bas") for custom tags — the bridge now recognizes the vendor prefix and resolves it to your already-imported profile instead of falling back to a generic default, so no manual per-slot reassignment is needed anymore (Issue #101, thanks @Blaim)
- Fix: `server/files/metadata` could leak the currently/last tracked print job's layer count and estimated time into a query for a *different* file, since those values were read from live state before falling back to the queried file's own stored metadata. Now only the currently-tracked file's own query uses live state — any other filename relies solely on its own record (Issue #102, thanks @fmontagna)
- Fix: `curr_layer`/`total_layers` (and, after a successful print, every other per-job field — progress, filename, duration, etc.) were never reset when a print finished normally, only on stop/cancel — they stayed at the last job's values until the next print happened to overwrite them (Issue #102, thanks @fmontagna)
- Fix: the printer's own progress value during pre-print phases (auto-leveling/preheating/bed-checking) was passed straight through to `virtual_sdcard.progress`/`display_status.progress`, causing it to jump non-monotonically once real printing started and progress reset. Pre-print progress is no longer forwarded (Issue #102, thanks @fmontagna)

View File

@@ -23,7 +23,7 @@ Feedback willkommen.</sub>
&nbsp;
[![Downloads](https://img.shields.io/badge/Downloads-3.1k%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)
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=E3sDigSeSdM)
<sub>Gefällt dir KX-Bridge? Ein Kaffee auf <a href="https://ko-fi.com/viewitde">Ko-fi</a> hält das Projekt am Leben. ☕</sub>
@@ -41,12 +41,16 @@ Feedback willkommen.</sub>
| 🖨️ | **Druckersteuerung** — Start, Pause, Resume, Abbruch, Temperaturen, Druckgeschwindigkeit |
| 📊 | **Live-Status** — Temperatur, Fortschritt, Layer, Restzeit, Kamera-Stream |
| 🎨 | **AMS / Multicolor** — Slots mit **Profil-Picker pro Slot** (eigene Marke aus OrcaSlicer-Profilen pro Slot zuweisen); Bridge schreibt Material und Farbe ans Drucker-Display zurück |
| 🏷️ | **Custom-RFID-Tag-Matching** — mit Drittanbieter-Tools (z.B. der „ACE RFID"-App) beschriebene Spulen werden automatisch nach Marke + Material gegen deine importierten OrcaSlicer-Profile gematcht, statt auf ein generisches Profil zurückzufallen |
| 📦 | **Eigene OrcaSlicer-Profile importieren** — ZIP aus `~/.config/OrcaSlicer/user/<id>/filament/` in die Bridge ziehen; tauchen im Slot-Dropdown unter ★ Eigene Profile auf |
| 🧵 | **Spoolman-Integration** — Spulen einzelnen AMS-Slots zuweisen, Filament-Verbrauch wird automatisch beim Drucken erfasst und synchronisiert |
| 🔗 | **Multi-ACE-Unterstützung** — mehrere aneinandergekettete ACE-Einheiten, auch bei Druckern ohne Toolhead-Buffer |
| 📷 | **Obico-Integration (experimentell)** — Time-Lapse und WebRTC-Livestream gegen einen selbst gehosteten [Obico-Server](https://github.com/TheSpaghettiDetective/obico-server) via moonraker-obico |
| 📐 | **H.264-Direkt-Stream + Z-Höhe** — sparsamer Kamera-Pfad für Obico, aktuelle Z aus der Layer-Höhe abgeleitet (Mm-Progress-Widget) |
| 🗂️ | **GCode-Browser** — hochgeladene Dateien mit Thumbnail, Druckhistorie, Suche & Filter |
| 📐 | **H.264-Direkt-Stream + Z-Höhe** — sparsamer Kamera-Pfad für Obico, aktuelle Z aus der Layer-Höhe abgeleitet (Mm-Progress-Widget); erholt sich automatisch nach einem Drucker-Reboot mit rotiertem Stream-Token, kein manueller Reset nötig |
| 🗂️ | **GCode-Browser** zwei Tabs: hochgeladene Dateien (mit Thumbnail, Druckhistorie, Suche & Filter, Mehrfachauswahl + Sammel-Löschen) und Dateien direkt auf dem Drucker-Speicher (mit echten Thumbnails, Mehrfachauswahl + Löschen) |
| 🧩 | **Multi-Printer** — mehrere Drucker in **einer** Bridge-Instanz, Umschalten per Dropdown |
| | **Drucker hinzufügen per Klick** — nur die IP eingeben, Zugangsdaten werden automatisch importiert |
| 🖱️ | **Frei anpassbares Dashboard** — Kacheln per Drag & Drop verschieben und in der Größe anpassen, als Preset speichern |
| 🔁 | **Robuster MQTT-Reconnect** — Bridge überlebt nächtlichen Drucker-Reboot ohne manuellen Neustart |
| 🌐 | **Mehrsprachiges UI** — DE / EN / ES / FR / IT / 中文, Browser-Sprache automatisch erkannt |
| 🔄 | **Self-Update** — neue Versionen direkt im Browser installieren |
@@ -68,6 +72,11 @@ LAN-Modus am Kobra X aktivieren:
docker compose up -d
```
> Zusätzlich Spoolman und einen kompletten selbst gehosteten Obico-Setup
> (Spaghetti-Erkennung, Live-Stream) neben der Bridge? [`docker-compose-KX.yml`](docker-compose-KX.yml)
> bündelt KX-Bridge + Spoolman + Obico (Web/ML/Tasks/Redis) + moonraker-obico
> in einem Netzwerk — Setup-Schritte in den Kommentaren am Dateianfang.
**Linux-Binary (kein Docker):**
```bash
chmod +x kx-bridge && ./kx-bridge
@@ -110,7 +119,7 @@ Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125`
## 📺 Video-Tutorial
[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/E3sDigSeSdM/hqdefault.jpg)](https://www.youtube.com/watch?v=E3sDigSeSdM)
---

View File

@@ -22,7 +22,7 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
&nbsp;
[![Downloads](https://img.shields.io/badge/Descargas-3.1k%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)
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=E3sDigSeSdM)
<sub>¿Te gusta KX-Bridge? Un café en <a href="https://ko-fi.com/viewitde">Ko-fi</a> mantiene el proyecto vivo. ☕</sub>
@@ -42,12 +42,16 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
| 🖨️ | **Control de impresora** — iniciar, pausar, reanudar, cancelar, temperaturas, velocidad de impresión |
| 📊 | **Estado en tiempo real** — temperatura, progreso, capas, tiempo restante, transmisión de cámara |
| 🎨 | **AMS / multicolor** — ranuras con **selector de perfil por ranura** (asigna tu propia marca de los perfiles de OrcaSlicer a cada ranura); el puente escribe material y color al display de la impresora |
| 🏷️ | **Coincidencia de etiquetas RFID personalizadas** — las bobinas etiquetadas con herramientas de terceros (p. ej. la app "ACE RFID") se emparejan automáticamente por marca + material con tus perfiles de OrcaSlicer importados, en lugar de caer en un perfil genérico |
| 📦 | **Importa tus propios perfiles de OrcaSlicer** — arrastra un ZIP de `~/.config/OrcaSlicer/user/<id>/filament/` al puente; aparecen en el desplegable de la ranura bajo ★ Perfiles propios |
| 🧵 | **Integración con Spoolman** — asigna bobinas a las ranuras del AMS, el consumo de filamento se registra y sincroniza automáticamente al imprimir |
| 🔗 | **Soporte multi-ACE** — múltiples unidades ACE encadenadas, incluso en impresoras sin buffer en el cabezal |
| 📷 | **Integración con Obico (experimental)** — Time-Lapse y stream en vivo WebRTC contra un [servidor Obico](https://github.com/TheSpaghettiDetective/obico-server) autoalojado vía moonraker-obico |
| 📐 | **Stream H.264 directo + altura Z** — ruta de cámara de bajo consumo de CPU para Obico, Z actual derivada de la altura de capa (widget de progreso) |
| 🗂️ | **Explorador de GCode** — archivos subidos con vistas previas, historial de impresión, búsqueda y filtros |
| 📐 | **Stream H.264 directo + altura Z** — ruta de cámara de bajo consumo de CPU para Obico, Z actual derivada de la altura de capa (widget de progreso); se recupera automáticamente tras un reinicio de la impresora que rota el token del stream, sin necesidad de reinicio manual |
| 🗂️ | **Explorador de GCode** dos pestañas: archivos subidos (con vistas previas, historial de impresión, búsqueda y filtros, selección múltiple + borrado masivo) y archivos almacenados directamente en la memoria de la impresora (con vistas previas reales, selección múltiple + borrado) |
| 🧩 | **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 |
| 🖱️ | **Panel de control libre** — arrastra y redimensiona las tarjetas del panel a tu gusto, guárdalo como preset |
| 🔁 | **Reconexión MQTT robusta** — el puente sobrevive a reinicios nocturnos de la impresora sin reinicio manual |
| 🌐 | **Interfaz multilingüe** — DE / EN / ES / FR / IT / 中文, detecta automáticamente el idioma del navegador |
| 🔄 | **Actualización automática** — instala nuevas versiones directamente desde el navegador |
@@ -69,6 +73,11 @@ Activa el modo LAN en la Kobra X:
docker compose up -d
```
> ¿Quieres Spoolman y un setup completo de Obico autoalojado (detección de
> espagueti, stream en vivo) junto al puente? [`docker-compose-KX.yml`](docker-compose-KX.yml)
> combina KX-Bridge + Spoolman + Obico (web/ML/tasks/redis) + moonraker-obico
> en una sola red — consulta los comentarios al inicio del archivo para los pasos de configuración.
**Binario Linux (sin Docker):**
```bash
chmod +x kx-bridge && ./kx-bridge
@@ -111,7 +120,7 @@ Impresora → Tipo de conexión **Moonraker** → Host: `http://IP-DEL-PUENTE:71
## 📺 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)
[![Configuración y uso de KX-Bridge](https://img.youtube.com/vi/E3sDigSeSdM/hqdefault.jpg)](https://www.youtube.com/watch?v=E3sDigSeSdM)
---

View File

@@ -22,7 +22,7 @@ officially tested or supported. Feedback welcome.</sub>
&nbsp;
[![Downloads](https://img.shields.io/badge/Downloads-3.1k%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)
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=E3sDigSeSdM)
<sub>Like KX-Bridge? A coffee on <a href="https://ko-fi.com/viewitde">Ko-fi</a> keeps the project alive. ☕</sub>
@@ -42,12 +42,16 @@ officially tested or supported. Feedback welcome.</sub>
| 🖨️ | **Printer control** — start, pause, resume, cancel, temperatures, print speed |
| 📊 | **Live status** — temperature, progress, layers, remaining time, camera stream |
| 🎨 | **AMS / multicolor** — slots with per-slot **profile picker** (assign your own brand from OrcaSlicer profiles per slot); bridge writes material & colour back to the printer display |
| 🏷️ | **Custom RFID tag matching** — spools tagged with third-party tools (e.g. the "ACE RFID" app) auto-match vendor + material against your imported OrcaSlicer profiles instead of falling back to a generic default |
| 📦 | **Import your own OrcaSlicer profiles** — drag a ZIP from `~/.config/OrcaSlicer/user/<id>/filament/` into the bridge; they show up in the slot dropdown under ★ Own profiles |
| 🧵 | **Spoolman integration** — assign spools to AMS slots, filament usage tracked and synced automatically as you print |
| 🔗 | **Multi-ACE support** — multiple daisy-chained ACE units, including printers without a toolhead buffer |
| 📷 | **Obico integration (experimental)** — Time-Lapse and WebRTC live stream against a self-hosted [Obico server](https://github.com/TheSpaghettiDetective/obico-server) via moonraker-obico |
| 📐 | **Direct H.264 stream + Z-height** — low-CPU camera path for Obico, current Z derived from layer-height for the print-progress widget |
| 🗂️ | **GCode browser**uploaded files with thumbnails, print history, search & filter |
| 📐 | **Direct H.264 stream + Z-height** — low-CPU camera path for Obico, current Z derived from layer-height for the print-progress widget; auto-recovers after a printer reboot rotates its stream token, no manual reset needed |
| 🗂️ | **GCode browser**two tabs: files you've uploaded (with thumbnails, print history, search & filter, multi-select + bulk delete) and files stored directly on the printer's own storage (with real thumbnails, multi-select + delete) |
| 🧩 | **Multi-printer** — multiple printers in **one** bridge instance, switch via dropdown |
| | **Add a printer with one click** — just enter the IP, credentials are imported automatically |
| 🖱️ | **Free-form dashboard** — drag & resize the dashboard tiles into your own layout, save it as a preset |
| 🔁 | **Robust MQTT reconnect** — bridge survives overnight printer reboots without manual restart |
| 🌐 | **Multi-language UI** — DE / EN / ES / FR / IT / 中文, auto-detect browser locale |
| 🔄 | **Self-update** — install new versions directly in the browser |
@@ -69,6 +73,11 @@ Enable LAN mode on the Kobra X:
docker compose up -d
```
> Want Spoolman and a full self-hosted Obico setup (spaghetti detection, live stream)
> alongside the bridge? [`docker-compose-KX.yml`](docker-compose-KX.yml) bundles
> KX-Bridge + Spoolman + Obico (web/ML/tasks/redis) + moonraker-obico behind one
> network — see the file's header comments for setup steps.
**Linux binary (no Docker):**
```bash
chmod +x kx-bridge && ./kx-bridge
@@ -111,7 +120,7 @@ Printer → Connection type **Moonraker** → Host: `http://BRIDGE-IP:7125`
## 📺 Video Tutorial
[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/E3sDigSeSdM/hqdefault.jpg)](https://www.youtube.com/watch?v=E3sDigSeSdM)
---

View File

@@ -1 +1 @@
0.9.27
0.9.29

View File

@@ -1,8 +1,8 @@
# KobraX Full Stack — KX-Bridge + Obico Self-Hosted + Spoolman
#
# Für Portainer: Stack → Add Stack → Upload → diese Datei wählen
# For Portainer: Stack → Add Stack → Upload → select this file
#
# Voraussetzung: Obico-Images einmalig in Gitea-Registry pushen:
# Prerequisite: push the Obico images to the Gitea registry once:
# docker tag obico-server-web:latest gitea.it-drui.de/viewit/obico-web:latest
# docker tag obico-server-ml_api:latest gitea.it-drui.de/viewit/obico-ml:latest
# docker tag obico-server-tasks:latest gitea.it-drui.de/viewit/obico-tasks:latest
@@ -10,14 +10,14 @@
# docker push gitea.it-drui.de/viewit/obico-ml:latest
# docker push gitea.it-drui.de/viewit/obico-tasks:latest
#
# Persistente Daten: /mnt/dockerdata/KobraXStack/<service>/
# Persistent data: /mnt/dockerdata/KobraXStack/<service>/
#
# Ports:
# 7125 — KX-Bridge (Moonraker-API)
# 3334 — Obico (Web-UI)
# 7912 — Spoolman (Web-UI)
# 7125 — KX-Bridge (Moonraker API)
# 3334 — Obico (Web UI)
# 7912 — Spoolman (Web UI)
#
# Obico Admin-Account nach dem ersten Start:
# Obico admin account after first start:
# docker exec obico-web python manage.py createsuperuser
x-obico-base: &obico-base
@@ -162,12 +162,12 @@ services:
max-size: "10m"
max-file: "3"
# ── moonraker-obico Plugin ──────────────────────────────────
# Verbindet KX-Bridge mit dem Obico-Server (Spaghetti-Detektion, Remote-UI)
# Voraussetzung: /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg
# muss existieren und einen gültigen auth_token enthalten.
# ── moonraker-obico plugin ──────────────────────────────────
# Connects KX-Bridge to the Obico server (spaghetti detection, remote UI)
# Prerequisite: /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg
# must exist and contain a valid auth_token.
#
# Token holen (nach erstem obico-web Start):
# Getting a token (after the first obico-web start):
# docker exec obico-web python manage.py shell -c "
# from app.models import OneTimeVerificationCode, User
# from django.utils import timezone; from datetime import timedelta; import random
@@ -175,7 +175,7 @@ services:
# c = OneTimeVerificationCode.objects.create(user=u, code='%06d' % random.randint(100000,999999), expired_at=timezone.now()+timedelta(hours=2))
# print('CODE:', c.code)"
# curl -X POST 'http://localhost:3334/api/v1/octo/verify/?code=<CODE>'
# → printer.auth_token aus der Antwort in die cfg eintragen
# → enter printer.auth_token from the response into the cfg
moonraker-obico:
image: gitea.it-drui.de/viewit/moonraker-obico:latest
container_name: moonraker-obico
@@ -195,7 +195,7 @@ networks:
kobrax-stack:
driver: bridge
# Verzeichnisse müssen auf dem Host existieren:
# Directories must exist on the host:
# mkdir -p /mnt/dockerdata/KobraXStack/kx-bridge/config \
# /mnt/dockerdata/KobraXStack/kx-bridge/data \
# /mnt/dockerdata/KobraXStack/spoolman \
@@ -203,8 +203,8 @@ networks:
# /mnt/dockerdata/KobraXStack/obico/frontend \
# /mnt/dockerdata/KobraXStack/obico/redis \
# /mnt/dockerdata/KobraXStack/moonraker-obico/logs
# Spoolman benötigt UID/GID 1000:
# Spoolman requires UID/GID 1000:
# sudo chown -R 1000:1000 /mnt/dockerdata/KobraXStack/spoolman
#
# moonraker-obico Config anlegen (auth_token nach Obico-Setup eintragen):
# Create the moonraker-obico config (enter auth_token after Obico setup):
# cp /path/to/moonraker-obico.cfg.example /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg

View File

@@ -1051,6 +1051,17 @@ class KobraXBridge:
self._head_tools_model: int = -1
self._filament_mode: str = "toolhead"
self._last_uploaded_file: str = ""
# Pending waiters for a specific file/report `action` (e.g. "listLocal",
# "deleteBatch"). publish()'s own return value for these actions is just
# a generic immediate ACK skeleton (code=0, all fields empty) - the real
# answer arrives later via the file/report callback (_on_file), same
# as the existing fileDetails fire-and-forget pattern. Format:
# {action: {"event": threading.Event(), "result": dict|None}}.
self._file_action_waiters: dict[str, dict] = {}
# Thumbnail cache for files on the printer's own storage (filename ->
# base64 PNG string, "" if the file has no embedded thumbnail).
# In-memory only - not persisted, cleared on restart.
self._printer_thumbnail_cache: dict[str, str] = {}
self._store = store if store is not None else GCodeStore(args.data_dir)
self._serve_dir_path: str = self._store._gcode_dir
self._current_job_id: str = ""
@@ -1559,7 +1570,41 @@ class KobraXBridge:
if payload.get("state") == "done" or payload.get("code") == 200:
log.info(f"Skip response: state={payload.get('state')} code={payload.get('code')} skipped={skipped}")
def _wait_for_file_action(self, action: str, send_fn, timeout: float = 8.0) -> dict | None:
"""Sends a file/* MQTT request (via send_fn, which must call
self.client.publish(..., timeout=0) fire-and-forget) and blocks the
calling thread until a matching file/report with this `action`
arrives via _on_file, or the timeout elapses.
Needed because the printer's publish() return value for actions like
listLocal/deleteBatch is just a generic immediate ACK skeleton
(code=0, empty fields) - the real response is a separate, later
file/report message, same as the existing fileDetails pattern.
Must be called from a worker thread (e.g. via run_in_executor), not
the asyncio event loop, since it blocks on a threading.Event.
"""
event = threading.Event()
waiter = {"event": event, "result": None}
self._file_action_waiters[action] = waiter
try:
send_fn()
event.wait(timeout)
return waiter["result"]
finally:
if self._file_action_waiters.get(action) is waiter:
del self._file_action_waiters[action]
def _on_file(self, payload: dict):
# Deliver to any pending listLocal/deleteBatch waiter first (see
# _wait_for_file_action) - these actions carry no file_details/
# thumbnail payload of their own, so this doesn't interfere with the
# handling below.
action = payload.get("action") or ""
waiter = self._file_action_waiters.get(action)
if waiter is not None:
waiter["result"] = payload
waiter["event"].set()
d = payload.get("data") or {}
details = d.get("file_details") or {}
thumb = details.get("thumbnail") or details.get("png_image") or ""
@@ -2776,6 +2821,93 @@ class KobraXBridge:
return self._json_cors({"result": "ok"})
return self._json_cors({"error": "not found"}, status=404)
async def handle_kx_printer_files(self, request):
"""GET /kx/printer-files - lists files on the printer's OWN internal
storage (file/listLocal MQTT action), as opposed to /kx/files which
lists what the bridge itself has stored. Needed because prints
started directly from Anycubic Slicer Next (bypassing the bridge)
leave files on the printer that were previously only visible/
deletable from the printer's own display (Issue #102 context)."""
loop = asyncio.get_event_loop()
def _fetch():
return self._wait_for_file_action(
"listLocal",
lambda: self.client.publish(
"file", "listLocal",
{"page_num": 1, "page_size": 200, "path": "/"},
timeout=0,
),
timeout=8.0,
)
result = await loop.run_in_executor(None, _fetch)
if not result or result.get("code") != 200:
return self._json_cors({"error": "printer unreachable or query failed"}, status=502)
records = (result.get("data") or {}).get("records") or []
files = [r for r in records if not r.get("is_dir")]
return self._json_cors({"result": files})
async def handle_kx_printer_file_delete(self, request):
"""POST /kx/printer-files/delete - body: {"filenames": ["a.gcode", ...]}.
Single endpoint for both single and multi-select delete - the
printer's file/deleteBatch MQTT action natively accepts a list."""
try:
body = await request.json()
except Exception:
body = {}
filenames = body.get("filenames") or []
if not filenames:
return self._json_cors({"error": "no filenames given"}, status=400)
files = [{"path": "/", "filename": fn} for fn in filenames if fn]
loop = asyncio.get_event_loop()
def _delete():
return self._wait_for_file_action(
"deleteBatch",
lambda: self.client.publish(
"file", "deleteBatch",
{"root": "local", "files": files},
timeout=0,
),
timeout=8.0,
)
result = await loop.run_in_executor(None, _delete)
if not result or result.get("state") != "success":
return self._json_cors({"error": "delete failed", "detail": result}, status=502)
return self._json_cors({"result": "ok"})
async def handle_kx_printer_file_thumbnail(self, request):
"""GET /kx/printer-files/{filename}/thumbnail - fetches the embedded
GCode thumbnail for a file on the printer's own storage, via
file/fileDetails. The printer extracts and base64-encodes the
"; thumbnail begin"-block from the GCode header on demand and
returns it inline in data.file_details.thumbnail - no separate
download/presigned-URL step needed (verified live against a real
Kobra X). Cached in-memory per filename since a file's thumbnail
never changes while it exists on the printer, and re-querying on
every render/scroll would mean one MQTT roundtrip per visible card."""
filename = request.match_info.get("filename", "")
if not filename:
return self._json_cors({"error": "no filename given"}, status=400)
cached = self._printer_thumbnail_cache.get(filename)
if cached is not None:
return self._json_cors({"result": {"thumbnail": cached}})
loop = asyncio.get_event_loop()
def _fetch():
return self._wait_for_file_action(
"fileDetails",
lambda: self.client.publish(
"file", "fileDetails",
{"root": "local", "filename": filename},
timeout=0,
),
timeout=8.0,
)
result = await loop.run_in_executor(None, _fetch)
if not result or result.get("code") != 200:
return self._json_cors({"error": "printer unreachable or query failed"}, status=502)
thumb = ((result.get("data") or {}).get("file_details") or {}).get("thumbnail") or ""
self._printer_thumbnail_cache[filename] = thumb
return self._json_cors({"result": {"thumbnail": thumb}})
async def handle_kx_file_download(self, request):
file_id = request.match_info["file_id"]
f = self._store.get_file(file_id)
@@ -5733,6 +5865,9 @@ def build_app(bridge: KobraXBridge) -> web.Application:
r.add_delete("/kx/files/{file_id}", bridge.handle_kx_file_delete)
r.add_get("/kx/files/{file_id}/download", bridge.handle_kx_file_download)
r.add_post("/kx/files/{file_id}/verify", bridge.handle_kx_file_verify)
r.add_get("/kx/printer-files", bridge.handle_kx_printer_files)
r.add_post("/kx/printer-files/delete", bridge.handle_kx_printer_file_delete)
r.add_get("/kx/printer-files/{filename}/thumbnail", bridge.handle_kx_printer_file_thumbnail)
r.add_get("/kx/filament/slots", bridge.handle_kx_filament_slots)
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)

View File

@@ -0,0 +1,233 @@
"""
Tests für /kx/printer-files (list) und /kx/printer-files/delete —
der zweite Browser-Tab, der Dateien auf dem Drucker selbst zeigt
(via MQTT file/listLocal + file/deleteBatch, live gegen den echten
Kobra X verifiziert, siehe Memory reference_mqtt_listlocal.md).
Important: publish()'s own return value for these actions is just a
generic immediate ACK skeleton (code=0, empty fields) - the real answer
arrives asynchronously via the file/report callback (_on_file), same as
the existing fileDetails fire-and-forget pattern. So publish() itself
returns None/skeleton here, and the "real" response is delivered by
firing bridge._on_file(...) from a background thread, simulating what
the MQTT reader thread would do when the printer's file/report arrives.
"""
import threading
import time
import pytest
LISTLOCAL_SUCCESS = {
"action": "listLocal",
"code": 200,
"state": "success",
"data": {
"list_mode": 0,
"records": [
{"filename": "a.gcode", "is_dir": False, "size": 100, "timestamp": 1700000000000},
{"filename": "subdir", "is_dir": True, "size": 0, "timestamp": 1700000001000},
{"filename": "b.gcode", "is_dir": False, "size": 200, "timestamp": 1700000002000},
],
},
}
LISTLOCAL_FAILED = {
"action": "listLocal",
"code": 10112,
"state": "failed",
"data": None,
}
DELETEBATCH_SUCCESS = {
"action": "deleteBatch",
"code": 200,
"state": "success",
"data": None,
"msg": "done",
}
DELETEBATCH_FAILED = {
"action": "deleteBatch",
"code": 10112,
"state": "failed",
"data": None,
}
FILEDETAILS_SUCCESS = {
"action": "fileDetails",
"code": 200,
"state": "done",
"data": {
"file_details": {
"thumbnail": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB",
"png_image": "",
"svg_image": "",
"objects_skip_parts": [],
},
"filename": "a.gcode",
"root": "local",
},
}
FILEDETAILS_NO_THUMBNAIL = {
"action": "fileDetails",
"code": 200,
"state": "done",
"data": {
"file_details": {"thumbnail": "", "png_image": "", "svg_image": "", "objects_skip_parts": []},
"filename": "a.gcode",
"root": "local",
},
}
FILEDETAILS_FAILED = {
"action": "fileDetails",
"code": 10112,
"state": "failed",
"data": None,
}
def _deliver_async(bridge, payload, delay=0.05):
"""Simulates the MQTT reader thread delivering a file/report a moment
after the fire-and-forget publish() call returns."""
def _fire():
time.sleep(delay)
bridge._on_file(payload)
threading.Thread(target=_fire, daemon=True).start()
@pytest.mark.asyncio
async def test_printer_files_lists_files_and_excludes_dirs(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1]
resp = await c.get("/kx/printer-files")
assert resp.status == 200
data = await resp.json()
filenames = [f["filename"] for f in data["result"]]
assert filenames == ["a.gcode", "b.gcode"] # "subdir" (is_dir=True) excluded
@pytest.mark.asyncio
async def test_printer_files_uses_correct_mqtt_payload(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1]
await c.get("/kx/printer-files")
args, kwargs = bridge.client.publish.call_args
assert args[0] == "file"
assert args[1] == "listLocal"
assert args[2] == {"page_num": 1, "page_size": 200, "path": "/"}
@pytest.mark.asyncio
async def test_printer_files_returns_502_on_printer_failure(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_FAILED), None)[1]
resp = await c.get("/kx/printer-files")
assert resp.status == 502
@pytest.mark.asyncio
async def test_printer_files_returns_502_on_timeout(client):
c, bridge = client
bridge.client.publish.return_value = None # no file/report ever arrives
resp = await c.get("/kx/printer-files")
assert resp.status == 502
@pytest.mark.asyncio
async def test_printer_file_delete_success(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1]
resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]})
assert resp.status == 200
data = await resp.json()
assert data["result"] == "ok"
@pytest.mark.asyncio
async def test_printer_file_delete_uses_correct_mqtt_payload(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1]
await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode", "b.gcode"]})
args, kwargs = bridge.client.publish.call_args
assert args[0] == "file"
assert args[1] == "deleteBatch"
assert args[2] == {
"root": "local",
"files": [
{"path": "/", "filename": "a.gcode"},
{"path": "/", "filename": "b.gcode"},
],
}
@pytest.mark.asyncio
async def test_printer_file_delete_empty_filenames_returns_400(client):
c, _ = client
resp = await c.post("/kx/printer-files/delete", json={"filenames": []})
assert resp.status == 400
@pytest.mark.asyncio
async def test_printer_file_delete_returns_502_on_printer_rejection(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_FAILED), None)[1]
resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]})
assert resp.status == 502
@pytest.mark.asyncio
async def test_printer_file_thumbnail_success(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1]
resp = await c.get("/kx/printer-files/a.gcode/thumbnail")
assert resp.status == 200
data = await resp.json()
assert data["result"]["thumbnail"] == "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
@pytest.mark.asyncio
async def test_printer_file_thumbnail_uses_correct_mqtt_payload(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1]
await c.get("/kx/printer-files/a.gcode/thumbnail")
args, kwargs = bridge.client.publish.call_args
assert args[0] == "file"
assert args[1] == "fileDetails"
assert args[2] == {"root": "local", "filename": "a.gcode"}
@pytest.mark.asyncio
async def test_printer_file_thumbnail_empty_when_no_thumbnail_embedded(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_NO_THUMBNAIL), None)[1]
resp = await c.get("/kx/printer-files/a.gcode/thumbnail")
assert resp.status == 200
data = await resp.json()
assert data["result"]["thumbnail"] == ""
@pytest.mark.asyncio
async def test_printer_file_thumbnail_returns_502_on_printer_failure(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_FAILED), None)[1]
resp = await c.get("/kx/printer-files/a.gcode/thumbnail")
assert resp.status == 502
@pytest.mark.asyncio
async def test_printer_file_thumbnail_is_cached_after_first_fetch(client):
"""Second request for the same filename must not call publish() again."""
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1]
resp1 = await c.get("/kx/printer-files/a.gcode/thumbnail")
assert resp1.status == 200
call_count_after_first = bridge.client.publish.call_count
resp2 = await c.get("/kx/printer-files/a.gcode/thumbnail")
assert resp2.status == 200
data2 = await resp2.json()
assert data2["result"]["thumbnail"] == "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
assert bridge.client.publish.call_count == call_count_after_first # no new MQTT call

View File

@@ -357,6 +357,11 @@ function applyLang(){
setText('store-lbl-select-all',T.store_select_all||'Select All');
setText('store-lbl-delete-selected',T.store_delete_selected||'Delete Selected');
setText('store-lbl-exit-select',T.store_exit_select||'Cancel');
setText('btab-lbl-uploaded',T.browser_tab_uploaded||'Uploaded');
setText('btab-lbl-printer',T.browser_tab_printer||'On Printer');
setText('printer-store-lbl-select-all',T.store_select_all||'Select All');
setText('printer-store-lbl-delete-selected',T.store_delete_selected||'Delete Selected');
setText('printer-store-lbl-exit-select',T.store_exit_select||'Cancel');
// Dashboard card titles
setText('d-card-progress',T.card_progress);
setText('d-card-temps',T.card_temps);
@@ -594,6 +599,17 @@ function showSettingsCat(name){
var c=document.getElementById('setcat-'+name);if(c)c.classList.add('active');
}
// Browser-Sub-Tab umschalten: hochgeladene Dateien (Bridge-Store) vs. Dateien
// auf dem Drucker selbst (interner Speicher, via listLocal MQTT).
var _printerFilesLoaded=false;
function showBrowserTab(name){
document.querySelectorAll('.browser-group').forEach(g=>g.classList.remove('active'));
document.querySelectorAll('.browser-tab').forEach(b=>b.classList.remove('active'));
var g=document.getElementById('browser-group-'+name);if(g)g.classList.add('active');
var t=document.getElementById('btab-'+name);if(t)t.classList.add('active');
if(name==='printer'&&!_printerFilesLoaded)loadPrinterFiles();
}
// ── Console log ──
var consoleLogs=[];
var logAutoScroll=true;
@@ -2987,6 +3003,188 @@ function storeDeleteSelected(){
});
}
// ── Files on the printer's own internal storage (Issue: 2nd Browser tab) ──
// Uses filename as the identity key (the printer has no numeric file id like
// the bridge's own GCodeStore) and a single batch-delete call, since the
// printer's file/deleteBatch MQTT action natively accepts a filename list.
var printerFiles=[];
var _printerFilesSelectMode=false;
var _printerFilesSelected={}; // filename -> true
function loadPrinterFiles(){
var errEl=document.getElementById('printer-store-error');
if(errEl)errEl.style.display='none';
fetch(_apiUrl('/kx/printer-files')).then(function(r){return r.json()}).then(function(d){
_printerFilesLoaded=true;
if(d.error){
printerFiles=[];
if(errEl){errEl.textContent=T.printer_store_unreachable||d.error;errEl.style.display='block';}
renderPrinterFiles();
return;
}
printerFiles=d.result||[];
_printerFilesSelected={};
_printerFilesSelectMode=false;
var bar=document.getElementById('printer-store-select-bar');
if(bar)bar.style.display='none';
renderPrinterFiles();
}).catch(function(e){
_printerFilesLoaded=true;
if(errEl){errEl.textContent=T.printer_store_unreachable||String(e);errEl.style.display='block';}
});
}
function renderPrinterFiles(){
var grid=document.getElementById('printer-store-grid');
var empty=document.getElementById('printer-store-empty');
if(!grid||!empty)return;
if(!printerFiles.length){
empty.textContent=T.printer_store_empty||'No files on the printer.';
grid.innerHTML='';
empty.style.display='block';
return;
}
empty.style.display='none';
grid.innerHTML=printerFiles.map(function(f,idx){
var name=f.filename.length>28?f.filename.slice(0,25)+'…':f.filename;
var sizeKb=f.size?(f.size/1024).toFixed(0)+' KB':'';
var date=f.timestamp?new Date(f.timestamp).toISOString().replace('T',' ').slice(0,16):'';
var isSelected=!!_printerFilesSelected[f.filename];
var selectBorder=isSelected?'border:2px solid var(--accent);padding:9px':'border:1px solid var(--border);padding:10px';
var checkbox='<span style="position:absolute;top:6px;left:6px;width:22px;height:22px;'+
'border-radius:50%;background:rgba(255,255,255,.85);z-index:2;display:flex;'+
'align-items:center;justify-content:center">'+
'<input type="checkbox" class="store-card-cb" '+(isSelected?'checked':'')+
' onclick="event.stopPropagation();printerFileToggleSelect(\''+f.filename.replace(/'/g,"\\'")+'\')" '+
'style="width:16px;height:16px;margin:0"></span>';
var cardClick=_printerFilesSelectMode?'onclick="printerFileToggleSelect(\''+f.filename.replace(/'/g,"\\'")+'\')" style="cursor:pointer;position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"':
'style="position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"';
var thumbId='pft-'+idx;
var cachedThumb=_printerThumbCache[f.filename];
var thumbHtml=cachedThumb
? '<img id="'+thumbId+'" src="data:image/png;base64,'+cachedThumb+'" style="width:100%;height:60px;object-fit:contain;border-radius:6px;background:var(--raised);margin-bottom:8px">'
: '<div id="'+thumbId+'" data-filename="'+encodeURIComponent(f.filename)+'" style="width:100%;height:60px;background:var(--raised);border-radius:6px;display:flex;align-items:center;justify-content:center;margin-bottom:8px;font-size:28px">🖨</div>';
return '<div '+cardClick+'>'+
checkbox+
thumbHtml+
'<div title="'+f.filename+'" style="font-size:12px;font-weight:600;margin-bottom:4px;color:var(--txt)">'+name+'</div>'+
'<div style="font-size:11px;color:var(--txt2);margin-bottom:2px">💾 '+sizeKb+'</div>'+
'<div style="font-size:11px;color:var(--txt2);margin-bottom:8px">📅 '+date+'</div>'+
'<div style="display:flex;gap:6px;margin-top:auto">'+
'<button onclick="event.stopPropagation();printerFileDelete(\''+f.filename.replace(/'/g,"\\'")+'\')" '+
'style="flex:1;font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">🗑</button>'+
'</div>'+
'</div>';
}).join('');
_printerFilesUpdateSelectBar();
_loadVisiblePrinterThumbnails();
}
// Thumbnails are fetched lazily, one at a time, only for cards not already
// cached - fetching all of them upfront would mean one MQTT roundtrip per
// file (145+ files is common), overwhelming the single MQTT connection.
var _printerThumbCache={}; // filename -> base64 PNG string ("" = no thumbnail)
var _printerThumbQueue=[];
var _printerThumbLoading=false;
function _loadVisiblePrinterThumbnails(){
var placeholders=document.querySelectorAll('#printer-store-grid [data-filename]');
_printerThumbQueue=Array.prototype.slice.call(placeholders);
_pumpPrinterThumbQueue();
}
function _pumpPrinterThumbQueue(){
if(_printerThumbLoading||!_printerThumbQueue.length)return;
var el=_printerThumbQueue.shift();
if(!el||!el.isConnected){_pumpPrinterThumbQueue();return;}
var encodedName=el.getAttribute('data-filename');
_printerThumbLoading=true;
fetch(_apiUrl('/kx/printer-files/'+encodedName+'/thumbnail'))
.then(function(r){return r.json()})
.then(function(d){
var thumb=(d.result&&d.result.thumbnail)||'';
_printerThumbCache[decodeURIComponent(encodedName)]=thumb;
if(thumb&&el.isConnected){
var img=document.createElement('img');
img.src='data:image/png;base64,'+thumb;
img.style.cssText='width:100%;height:60px;object-fit:contain;border-radius:6px;background:var(--raised);margin-bottom:8px';
img.id=el.id;
el.replaceWith(img);
}
})
.catch(function(){/* keep the placeholder icon on failure */})
.finally(function(){
_printerThumbLoading=false;
_pumpPrinterThumbQueue();
});
}
function _printerFilesUpdateSelectBar(){
var selectAll=document.getElementById('printer-store-select-all');
var countEl=document.getElementById('printer-store-selected-count');
var delBtn=document.getElementById('printer-store-delete-selected-btn');
if(!selectAll||!countEl||!delBtn)return;
var allNames=printerFiles.map(function(f){return f.filename;});
var selected=allNames.filter(function(n){return _printerFilesSelected[n];});
var n=selected.length;
selectAll.checked=n>0&&n===allNames.length;
selectAll.indeterminate=n>0&&n<allNames.length;
countEl.textContent=n>0?(T.store_selected_count||'{n} selected').replace('{n}',n):'';
delBtn.disabled=n===0;
}
function printerFileToggleSelect(filename){
if(!_printerFilesSelectMode){
_printerFilesSelectMode=true;
var bar=document.getElementById('printer-store-select-bar');
if(bar)bar.style.display='flex';
}
if(_printerFilesSelected[filename])delete _printerFilesSelected[filename];
else _printerFilesSelected[filename]=true;
renderPrinterFiles();
}
function printerFileToggleSelectAll(checked){
_printerFilesSelected={};
if(checked)printerFiles.forEach(function(f){_printerFilesSelected[f.filename]=true;});
renderPrinterFiles();
}
function printerFileExitSelectMode(){
_printerFilesSelectMode=false;
_printerFilesSelected={};
var bar=document.getElementById('printer-store-select-bar');
if(bar)bar.style.display='none';
renderPrinterFiles();
}
function _printerFilesDeleteRequest(filenames){
return fetch(_apiUrl('/kx/printer-files/delete'),{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({filenames:filenames}),
}).then(function(r){return r.json().then(function(d){return{ok:r.ok,body:d};});});
}
function printerFileDelete(filename){
if(!confirm(T.printer_store_delete_confirm||'Delete file?'))return;
_printerFilesDeleteRequest([filename]).then(function(res){
if(!res.ok){clog((T.log_delete_failed||'Delete failed')+': '+(res.body.error||''),'msg-err');return;}
loadPrinterFiles();
});
}
function printerFileDeleteSelected(){
var names=Object.keys(_printerFilesSelected);
if(!names.length)return;
if(!confirm((T.printer_store_delete_selected_confirm||'Delete {n} selected files?').replace('{n}',names.length)))return;
_printerFilesDeleteRequest(names).then(function(res){
if(!res.ok){clog((T.log_delete_failed||'Delete failed')+': '+(res.body.error||''),'msg-err');return;}
printerFileExitSelectMode();
loadPrinterFiles();
});
}
var _gcodeFilaments=[];
function _setGcodeFilamentsFromFileObj(fileObj){

View File

@@ -388,49 +388,81 @@
<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>
<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">
<select id="store-filter" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="all" id="sf-all">Alle</option>
<option value="completed" id="sf-ok">✓ Erfolgreich</option>
<option value="failed" id="sf-err">✗ Fehler</option>
<option value="never" id="sf-new">Neu</option>
</select>
<select id="store-sort" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="date_desc" id="ss-date">↓ Datum</option>
<option value="name_asc" id="ss-name">AZ Name</option>
<option value="duration_asc" id="ss-dur">⏱ Druckzeit</option>
</select>
<div class="browser-subtabs" style="display:flex;gap:4px;margin-bottom:14px;border-bottom:1px solid var(--border)">
<button class="browser-tab active" id="btab-uploaded" onclick="showBrowserTab('uploaded')"
style="padding:8px 14px;background:none;border:none;border-bottom:2px solid transparent;color:var(--txt2);cursor:pointer;font-size:13px">
<span id="btab-lbl-uploaded">Hochgeladen</span></button>
<button class="browser-tab" id="btab-printer" onclick="showBrowserTab('printer')"
style="padding:8px 14px;background:none;border:none;border-bottom:2px solid transparent;color:var(--txt2);cursor:pointer;font-size:13px">
<span id="btab-lbl-printer">Auf dem Drucker</span></button>
</div>
<div id="store-upload-zone" onclick="document.getElementById('store-upload-input').click()"
ondragover="event.preventDefault();this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="event.preventDefault();this.classList.remove('drag-over');uploadGcode(event.dataTransfer.files[0])">
<input type="file" id="store-upload-input" accept=".gcode,.bgcode"
style="display:none" onchange="uploadGcode(this.files[0]);this.value=''">
<span id="store-upload-icon"></span>
<span id="store-upload-label"><span id="store-upload-label-prefix">GCode hierher ziehen oder </span><u id="store-upload-label-browse">durchsuchen</u></span>
<span id="store-upload-status" style="display:none"></span>
<div id="browser-group-uploaded" class="browser-group active">
<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">
<select id="store-filter" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="all" id="sf-all">Alle</option>
<option value="completed" id="sf-ok">✓ Erfolgreich</option>
<option value="failed" id="sf-err">✗ Fehler</option>
<option value="never" id="sf-new">Neu</option>
</select>
<select id="store-sort" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="date_desc" id="ss-date">↓ Datum</option>
<option value="name_asc" id="ss-name">AZ Name</option>
<option value="duration_asc" id="ss-dur">⏱ Druckzeit</option>
</select>
</div>
<div id="store-upload-zone" onclick="document.getElementById('store-upload-input').click()"
ondragover="event.preventDefault();this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="event.preventDefault();this.classList.remove('drag-over');uploadGcode(event.dataTransfer.files[0])">
<input type="file" id="store-upload-input" accept=".gcode,.bgcode"
style="display:none" onchange="uploadGcode(this.files[0]);this.value=''">
<span id="store-upload-icon"></span>
<span id="store-upload-label"><span id="store-upload-label-prefix">GCode hierher ziehen oder </span><u id="store-upload-label-browse">durchsuchen</u></span>
<span id="store-upload-status" style="display:none"></span>
</div>
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
</div>
<div id="store-select-bar" style="display:none;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
<input type="checkbox" id="store-select-all" onchange="storeToggleSelectAll(this.checked)">
<span id="store-lbl-select-all">Alle auswählen</span>
</label>
<span id="store-selected-count" style="color:var(--txt2);font-size:13px"></span>
<button id="store-delete-selected-btn" disabled onclick="storeDeleteSelected()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer">
🗑 <span id="store-lbl-delete-selected">Auswahl löschen</span></button>
<button onclick="storeExitSelectMode()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">
<span id="store-lbl-exit-select">Abbrechen</span></button>
</div>
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div>
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
<div id="browser-group-printer" class="browser-group">
<div id="printer-store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
</div>
<div id="printer-store-error" style="display:none;color:var(--err);text-align:center;padding:20px 0;font-size:13px">
</div>
<div id="printer-store-select-bar" style="display:none;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
<input type="checkbox" id="printer-store-select-all" onchange="printerFileToggleSelectAll(this.checked)">
<span id="printer-store-lbl-select-all">Alle auswählen</span>
</label>
<span id="printer-store-selected-count" style="color:var(--txt2);font-size:13px"></span>
<button id="printer-store-delete-selected-btn" disabled onclick="printerFileDeleteSelected()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer">
🗑 <span id="printer-store-lbl-delete-selected">Auswahl löschen</span></button>
<button onclick="printerFileExitSelectMode()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">
<span id="printer-store-lbl-exit-select">Abbrechen</span></button>
</div>
<div id="printer-store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div>
<div id="store-select-bar" style="display:none;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
<input type="checkbox" id="store-select-all" onchange="storeToggleSelectAll(this.checked)">
<span id="store-lbl-select-all">Alle auswählen</span>
</label>
<span id="store-selected-count" style="color:var(--txt2);font-size:13px"></span>
<button id="store-delete-selected-btn" disabled onclick="storeDeleteSelected()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer">
🗑 <span id="store-lbl-delete-selected">Auswahl löschen</span></button>
<button onclick="storeExitSelectMode()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">
<span id="store-lbl-exit-select">Abbrechen</span></button>
</div>
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div>
</div>

View File

@@ -322,6 +322,12 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:
.set-cat .nav-text{display:inline}
}
/* ── BROWSER SUB-TABS (uploaded vs. on-printer files) ── */
.browser-tab.active{color:var(--accent);border-bottom-color:var(--accent)!important}
.browser-tab:hover{color:var(--txt)}
.browser-group{display:none}
.browser-group.active{display:block}
/* ── FILE BROWSER UPLOAD ZONE ── */
#store-upload-zone{
display:flex;flex-direction:column;align-items:center;justify-content:center;

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "z.B. Kobra X Wohnzimmer",
"apd_success": "Drucker hinzugefügt, Bridge startet neu…",
"apd_title": "Drucker hinzufügen",
"browser_tab_printer": "Auf dem Drucker",
"browser_tab_uploaded": "Hochgeladen",
"btn_cam_start": "▶ Kamera",
"btn_cam_start2": "▶ Start",
"btn_cam_stop": "◼ Kamera",
@@ -68,6 +70,18 @@
"card_speed": "Druckgeschwindigkeit",
"card_temps": "Temperaturen",
"confirm_cancel": "Druck wirklich abbrechen?",
"dash_done": "Fertig",
"dash_edit": "Dashboard anpassen",
"dash_hidden_cards": "Ausgeblendete Karten",
"dash_hide": "Ausblenden",
"dash_preset_delete_confirm": "Preset \"{name}\" löschen?",
"dash_preset_name_prompt": "Preset-Name:",
"dash_preset_standard": "Standard",
"dash_preset_wide89": "Desktop breit",
"dash_reset": "Zurücksetzen",
"dash_save_preset": "Als Preset speichern",
"dash_show": "Einblenden",
"dash_toggle_width": "Breite umschalten",
"fd_cancel": "Abbrechen",
"fd_no_matching_material": "Kein passendes Material",
"fd_no_slots_msg": "Keine belegten AMS-Slots.{br}Druck trotzdem starten?",
@@ -200,6 +214,10 @@
"panel_temps_chart": "Verlauf (letzte 60 Messungen)",
"panel_temps_nozzle": "Düse",
"print_auto_leveling": "Auto-Leveling für diesen Druck",
"printer_store_delete_confirm": "Datei vom Drucker löschen?",
"printer_store_delete_selected_confirm": "{n} ausgewählte Dateien vom Drucker löschen?",
"printer_store_empty": "Keine Dateien auf dem Drucker.",
"printer_store_unreachable": "Drucker nicht erreichbar oder Abfrage fehlgeschlagen.",
"printers_active": "● aktiv",
"printers_current": "Aktueller Drucker",
"printers_empty_hint": "Noch kein Drucker eingerichtet.",
@@ -213,7 +231,6 @@
"progress_action_slots": "Slots zuordnen",
"settings_auto_leveling": "Auto-Leveling vor Druck",
"settings_auto_leveling_label": "Auto-Leveling vor dem Druck",
"settings_vibration_compensation": "Resonanzkompensation",
"settings_btn_tooltip": "Einstellungen",
"settings_camera_on_print": "Kamera bei Druckstart einschalten",
"settings_cat_connection": "Verbindung",
@@ -247,19 +264,6 @@
"settings_password": "MQTT-Passwort",
"settings_poll": "Poll-Intervall (Sekunden)",
"settings_poll_interval_hint": "Wie oft die Bridge den Druckerstatus abfragt",
"settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)",
"dash_edit": "Dashboard anpassen",
"dash_done": "Fertig",
"dash_reset": "Zurücksetzen",
"dash_preset_standard": "Standard",
"dash_preset_wide89": "Desktop breit",
"dash_hidden_cards": "Ausgeblendete Karten",
"dash_show": "Einblenden",
"dash_hide": "Ausblenden",
"dash_toggle_width": "Breite umschalten",
"dash_save_preset": "Als Preset speichern",
"dash_preset_name_prompt": "Preset-Name:",
"dash_preset_delete_confirm": "Preset \"{name}\" löschen?",
"settings_poll_interval_label": "Poll-Intervall (Sekunden)",
"settings_print": "Druckeinstellungen",
"settings_printer_ip": "Drucker-IP",
@@ -271,7 +275,9 @@
"settings_title": "Einstellungen",
"settings_username": "MQTT-Benutzername",
"settings_vendor_filter_placeholder": "Hersteller suchen…",
"settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)",
"settings_version": "Version",
"settings_vibration_compensation": "Resonanzkompensation",
"settings_visible_vendors": "Sichtbare Hersteller (Profil-Dropdown)",
"settings_visible_vendors_hint": "Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic\" und eigene Profile sind immer sichtbar.",
"settings_visible_vendors_label": "Sichtbare Hersteller (Profil-Dropdown)",
@@ -293,6 +299,7 @@
"skip_sending": "Sende …",
"skip_success": "Objekte werden übersprungen.",
"skip_title": "✂ Objekte überspringen",
"slot_copy_from": "Farbe von Slot kopieren…",
"slot_edit_color": "Farbe",
"slot_edit_custom": "z.B. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Einziehen",
@@ -316,15 +323,15 @@
"store_download": "⬇ Download",
"store_empty": "Noch keine Dateien hochgeladen.",
"store_estimate": "Schätzung",
"store_exit_select": "Abbrechen",
"store_never": "noch nicht gedruckt",
"store_no_results": "Keine Dateien gefunden.",
"store_print": "▶ Drucken",
"store_print_confirm": "Datei drucken?",
"store_refresh": "↻ Aktualisieren",
"store_search_placeholder": "🔍 Suche…",
"store_select_all": "Alle auswählen",
"store_selected_count": "{n} ausgewählt",
"store_exit_select": "Abbrechen",
"store_search_placeholder": "🔍 Suche…",
"store_upload_busy": "⏳ Hochladen…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "durchsuchen",
@@ -344,6 +351,5 @@
"update_docker_copied": "Kopiert! Ausführen: docker compose pull && docker compose up -d",
"update_error": "Fehler",
"update_none": "Bereits aktuell",
"update_restarting": "Starte neu...",
"slot_copy_from": "Farbe von Slot kopieren…"
}
"update_restarting": "Starte neu..."
}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "e.g. Kobra X Living Room",
"apd_success": "Printer added, bridge restarting…",
"apd_title": "Add printer",
"browser_tab_printer": "On Printer",
"browser_tab_uploaded": "Uploaded",
"btn_cam_start": "▶ Camera",
"btn_cam_start2": "▶ Start",
"btn_cam_stop": "◼ Camera",
@@ -68,6 +70,18 @@
"card_speed": "Print Speed",
"card_temps": "Temperatures",
"confirm_cancel": "Really cancel the print?",
"dash_done": "Done",
"dash_edit": "Customize dashboard",
"dash_hidden_cards": "Hidden cards",
"dash_hide": "Hide",
"dash_preset_delete_confirm": "Delete preset \"{name}\"?",
"dash_preset_name_prompt": "Preset name:",
"dash_preset_standard": "Standard",
"dash_preset_wide89": "Wide desktop",
"dash_reset": "Reset",
"dash_save_preset": "Save as preset",
"dash_show": "Show",
"dash_toggle_width": "Toggle width",
"fd_cancel": "Cancel",
"fd_no_matching_material": "No matching material",
"fd_no_slots_msg": "No loaded AMS slots.{br}Start print anyway?",
@@ -200,6 +214,10 @@
"panel_temps_chart": "History (last 60 readings)",
"panel_temps_nozzle": "Nozzle",
"print_auto_leveling": "Auto-Leveling",
"printer_store_delete_confirm": "Delete file from the printer?",
"printer_store_delete_selected_confirm": "Delete {n} selected files from the printer?",
"printer_store_empty": "No files on the printer.",
"printer_store_unreachable": "Printer unreachable or query failed.",
"printers_active": "● active",
"printers_current": "Current printer",
"printers_empty_hint": "No printer set up yet.",
@@ -213,7 +231,6 @@
"progress_action_slots": "Map slots",
"settings_auto_leveling": "Auto-Leveling Default",
"settings_auto_leveling_label": "Auto-Leveling before print",
"settings_vibration_compensation": "Resonance Compensation",
"settings_btn_tooltip": "Settings",
"settings_camera_on_print": "Turn camera on at print start",
"settings_cat_connection": "Connection",
@@ -247,19 +264,6 @@
"settings_password": "MQTT Password",
"settings_poll": "Poll Interval (seconds)",
"settings_poll_interval_hint": "How often the bridge queries printer status",
"settings_verbose_http_log": "Log every HTTP request (verbose)",
"dash_edit": "Customize dashboard",
"dash_done": "Done",
"dash_reset": "Reset",
"dash_preset_standard": "Standard",
"dash_preset_wide89": "Wide desktop",
"dash_hidden_cards": "Hidden cards",
"dash_show": "Show",
"dash_hide": "Hide",
"dash_toggle_width": "Toggle width",
"dash_save_preset": "Save as preset",
"dash_preset_name_prompt": "Preset name:",
"dash_preset_delete_confirm": "Delete preset \"{name}\"?",
"settings_poll_interval_label": "Poll Interval (seconds)",
"settings_print": "Print Settings",
"settings_printer_ip": "Printer IP",
@@ -271,7 +275,9 @@
"settings_title": "Settings",
"settings_username": "MQTT Username",
"settings_vendor_filter_placeholder": "Search vendors…",
"settings_verbose_http_log": "Log every HTTP request (verbose)",
"settings_version": "Version",
"settings_vibration_compensation": "Resonance Compensation",
"settings_visible_vendors": "Visible vendors (profile dropdown)",
"settings_visible_vendors_hint": "Only these vendors appear in the slot profile dropdown. Nothing selected = show all. \"Generic\" and your own profiles are always visible.",
"settings_visible_vendors_label": "Visible vendors (profile dropdown)",
@@ -293,6 +299,7 @@
"skip_sending": "Sending …",
"skip_success": "Objects will be skipped.",
"skip_title": "✂ Skip objects",
"slot_copy_from": "Copy color from slot…",
"slot_edit_color": "Color",
"slot_edit_custom": "e.g. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Load",
@@ -316,15 +323,15 @@
"store_download": "⬇ Download",
"store_empty": "No files uploaded yet.",
"store_estimate": "Estimate",
"store_exit_select": "Cancel",
"store_never": "never printed",
"store_no_results": "No files found.",
"store_print": "▶ Print",
"store_print_confirm": "Print file?",
"store_refresh": "↻ Refresh",
"store_search_placeholder": "🔍 Search…",
"store_select_all": "Select All",
"store_selected_count": "{n} selected",
"store_exit_select": "Cancel",
"store_search_placeholder": "🔍 Search…",
"store_upload_busy": "⏳ Uploading…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "browse",
@@ -344,6 +351,5 @@
"update_docker_copied": "Copied! Run: docker compose pull && docker compose up -d",
"update_error": "Error",
"update_none": "Already up to date",
"update_restarting": "Restarting...",
"slot_copy_from": "Copy color from slot…"
}
"update_restarting": "Restarting..."
}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "p. ej. Kobra X Sala",
"apd_success": "Impresora añadida, reiniciando bridge…",
"apd_title": "Agregar impresora",
"browser_tab_printer": "En la impresora",
"browser_tab_uploaded": "Subidos",
"btn_cam_start": "▶ Cámara",
"btn_cam_start2": "▶ Iniciar",
"btn_cam_stop": "◼ Cámara",
@@ -68,6 +70,18 @@
"card_speed": "Velocidad de impresión",
"card_temps": "Temperaturas",
"confirm_cancel": "¿Realmente cancelar la impresión?",
"dash_done": "Listo",
"dash_edit": "Personalizar panel",
"dash_hidden_cards": "Tarjetas ocultas",
"dash_hide": "Ocultar",
"dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?",
"dash_preset_name_prompt": "Nombre del preset:",
"dash_preset_standard": "Estándar",
"dash_preset_wide89": "Escritorio ancho",
"dash_reset": "Restablecer",
"dash_save_preset": "Guardar como preset",
"dash_show": "Mostrar",
"dash_toggle_width": "Cambiar ancho",
"fd_cancel": "Cancelar",
"fd_no_matching_material": "No hay material compatible",
"fd_no_slots_msg": "No hay slots AMS cargados.{br}¿Iniciar impresión de todos modos?",
@@ -200,6 +214,10 @@
"panel_temps_chart": "Historial (últimas 60 lecturas)",
"panel_temps_nozzle": "Boquilla",
"print_auto_leveling": "Autonivelado para esta impresión",
"printer_store_delete_confirm": "¿Eliminar archivo de la impresora?",
"printer_store_delete_selected_confirm": "¿Eliminar {n} archivos seleccionados de la impresora?",
"printer_store_empty": "No hay archivos en la impresora.",
"printer_store_unreachable": "Impresora inaccesible o consulta fallida.",
"printers_active": "● activa",
"printers_current": "Impresora actual",
"printers_empty_hint": "Aún no hay impresora configurada.",
@@ -213,7 +231,6 @@
"progress_action_slots": "Asignar ranuras",
"settings_auto_leveling": "Autonivelado antes de imprimir",
"settings_auto_leveling_label": "Autonivelado antes de imprimir",
"settings_vibration_compensation": "Compensación de resonancia",
"settings_btn_tooltip": "Ajustes",
"settings_camera_on_print": "Encender cámara al iniciar impresión",
"settings_cat_connection": "Conexión",
@@ -247,19 +264,6 @@
"settings_password": "Contraseña MQTT",
"settings_poll": "Intervalo de sondeo (segundos)",
"settings_poll_interval_hint": "Con qué frecuencia el bridge consulta el estado de la impresora",
"settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)",
"dash_edit": "Personalizar panel",
"dash_done": "Listo",
"dash_reset": "Restablecer",
"dash_preset_standard": "Estándar",
"dash_preset_wide89": "Escritorio ancho",
"dash_hidden_cards": "Tarjetas ocultas",
"dash_show": "Mostrar",
"dash_hide": "Ocultar",
"dash_toggle_width": "Cambiar ancho",
"dash_save_preset": "Guardar como preset",
"dash_preset_name_prompt": "Nombre del preset:",
"dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?",
"settings_poll_interval_label": "Intervalo de sondeo (segundos)",
"settings_print": "Ajustes de impresión",
"settings_printer_ip": "IP de impresora",
@@ -271,7 +275,9 @@
"settings_title": "Configuración",
"settings_username": "Usuario MQTT",
"settings_vendor_filter_placeholder": "Buscar fabricantes…",
"settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)",
"settings_version": "Versión",
"settings_vibration_compensation": "Compensación de resonancia",
"settings_visible_vendors": "Fabricantes visibles (lista de perfiles)",
"settings_visible_vendors_hint": "Solo estos fabricantes aparecen en la lista de perfiles de ranura. Nada seleccionado = mostrar todos. «Generic» y tus propios perfiles siempre son visibles.",
"settings_visible_vendors_label": "Fabricantes visibles (lista de perfiles)",
@@ -293,6 +299,7 @@
"skip_sending": "Enviando …",
"skip_success": "Se omitirán los objetos.",
"skip_title": "✂ Omitir objetos",
"slot_copy_from": "Copiar color del slot…",
"slot_edit_color": "Color",
"slot_edit_custom": "p. ej. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Cargar",
@@ -316,15 +323,15 @@
"store_download": "⬇ Descargar",
"store_empty": "Aún no hay archivos subidos.",
"store_estimate": "Estimación",
"store_exit_select": "Cancelar",
"store_never": "nunca impreso",
"store_no_results": "No se encontraron archivos.",
"store_print": "▶ Imprimir",
"store_print_confirm": "¿Imprimir archivo?",
"store_refresh": "↻ Actualizar",
"store_search_placeholder": "🔍 Buscar…",
"store_select_all": "Seleccionar todo",
"store_selected_count": "{n} seleccionados",
"store_exit_select": "Cancelar",
"store_search_placeholder": "🔍 Buscar…",
"store_upload_busy": "⏳ Subiendo…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "buscar",
@@ -344,6 +351,5 @@
"update_docker_copied": "Copiado. Ejecutar: docker compose pull && docker compose up -d",
"update_error": "Error",
"update_none": "Ya actualizado",
"update_restarting": "Reiniciando...",
"slot_copy_from": "Copiar color del slot…"
}
"update_restarting": "Reiniciando..."
}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "ex. Kobra X Salon",
"apd_success": "Imprimante ajoutée, redémarrage du bridge…",
"apd_title": "Ajouter une imprimante",
"browser_tab_printer": "Sur l'imprimante",
"browser_tab_uploaded": "Téléversés",
"btn_cam_start": "▶ Caméra",
"btn_cam_start2": "▶ Démarrer",
"btn_cam_stop": "◼ Caméra",
@@ -200,6 +202,10 @@
"panel_temps_chart": "Historique (60 dernières valeurs)",
"panel_temps_nozzle": "Buse",
"print_auto_leveling": "Mise à niveau auto pour cette impression",
"printer_store_delete_confirm": "Supprimer le fichier de l'imprimante ?",
"printer_store_delete_selected_confirm": "Supprimer {n} fichiers sélectionnés de l'imprimante ?",
"printer_store_empty": "Aucun fichier sur l'imprimante.",
"printer_store_unreachable": "Imprimante injoignable ou requête échouée.",
"printers_active": "● actif",
"printers_current": "Imprimante actuelle",
"printers_empty_hint": "Aucune imprimante configurée.",
@@ -279,6 +285,7 @@
"skip_sending": "Envoi …",
"skip_success": "Les objets seront ignorés.",
"skip_title": "✂ Ignorer des objets",
"slot_copy_from": "Copier la couleur du slot…",
"slot_edit_color": "Couleur",
"slot_edit_custom": "ex. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Charger",
@@ -302,15 +309,15 @@
"store_download": "⬇ Télécharger",
"store_empty": "Aucun fichier uploadé.",
"store_estimate": "Estimation",
"store_exit_select": "Annuler",
"store_never": "jamais imprimé",
"store_no_results": "Aucun fichier trouvé.",
"store_print": "▶ Imprimer",
"store_print_confirm": "Imprimer le fichier ?",
"store_refresh": "↻ Actualiser",
"store_search_placeholder": "🔍 Rechercher…",
"store_select_all": "Tout sélectionner",
"store_selected_count": "{n} sélectionné(s)",
"store_exit_select": "Annuler",
"store_search_placeholder": "🔍 Rechercher…",
"store_upload_busy": "⏳ Envoi en cours…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "parcourir",
@@ -330,6 +337,5 @@
"update_docker_copied": "Copié ! Exécuter : docker compose pull && docker compose up -d",
"update_error": "Erreur",
"update_none": "Déjà à jour",
"update_restarting": "Redémarrage…",
"slot_copy_from": "Copier la couleur du slot…"
}
"update_restarting": "Redémarrage…"
}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "es. Kobra X Soggiorno",
"apd_success": "Stampante aggiunta, riavvio del bridge in corso…",
"apd_title": "Aggiungi stampante",
"browser_tab_printer": "Sulla stampante",
"browser_tab_uploaded": "Caricati",
"btn_cam_start": "▶ Camera",
"btn_cam_start2": "▶ Avvia",
"btn_cam_stop": "◼ Camera",
@@ -200,6 +202,10 @@
"panel_temps_chart": "Cronologia (ultime 60 letture)",
"panel_temps_nozzle": "Ugello",
"print_auto_leveling": "Livellamento automatico",
"printer_store_delete_confirm": "Eliminare il file dalla stampante?",
"printer_store_delete_selected_confirm": "Eliminare {n} file selezionati dalla stampante?",
"printer_store_empty": "Nessun file sulla stampante.",
"printer_store_unreachable": "Stampante non raggiungibile o richiesta fallita.",
"printers_active": "● attiva",
"printers_current": "Stampante corrente",
"printers_empty_hint": "Nessuna stampante ancora configurata.",
@@ -279,6 +285,7 @@
"skip_sending": "Invio in corso …",
"skip_success": "Gli oggetti verranno saltati.",
"skip_title": "✂ Salta oggetti",
"slot_copy_from": "Copia colore dallo slot…",
"slot_edit_color": "Colore",
"slot_edit_custom": "es. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Carica",
@@ -302,15 +309,15 @@
"store_download": "⬇ Scarica",
"store_empty": "Nessun file caricato.",
"store_estimate": "Stima",
"store_exit_select": "Annulla",
"store_never": "mai stampato",
"store_no_results": "Nessun file trovato.",
"store_print": "▶ Stampa",
"store_print_confirm": "Stampare il file?",
"store_refresh": "↻ Aggiorna",
"store_search_placeholder": "🔍 Cerca…",
"store_select_all": "Seleziona tutto",
"store_selected_count": "{n} selezionati",
"store_exit_select": "Annulla",
"store_search_placeholder": "🔍 Cerca…",
"store_upload_busy": "⏳ Caricamento in corso…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "sfoglia",
@@ -330,6 +337,5 @@
"update_docker_copied": "Copiato! Eseguire: docker compose pull && docker compose up -d",
"update_error": "Errore",
"update_none": "Già aggiornato",
"update_restarting": "Riavvio in corso...",
"slot_copy_from": "Copia colore dallo slot…"
}
"update_restarting": "Riavvio in corso..."
}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "例如 Kobra X 客厅",
"apd_success": "打印机已添加Bridge 正在重启…",
"apd_title": "添加打印机",
"browser_tab_printer": "打印机上",
"browser_tab_uploaded": "已上传",
"btn_cam_start": "▶ 相机",
"btn_cam_start2": "▶ 启动",
"btn_cam_stop": "◼ 相机",
@@ -68,6 +70,18 @@
"card_speed": "打印速度",
"card_temps": "温度",
"confirm_cancel": "确定要取消打印吗?",
"dash_done": "完成",
"dash_edit": "自定义仪表盘",
"dash_hidden_cards": "隐藏的卡片",
"dash_hide": "隐藏",
"dash_preset_delete_confirm": "删除预设 \"{name}\"",
"dash_preset_name_prompt": "预设名称:",
"dash_preset_standard": "标准",
"dash_preset_wide89": "宽屏桌面",
"dash_reset": "重置",
"dash_save_preset": "另存为预设",
"dash_show": "显示",
"dash_toggle_width": "切换宽度",
"fd_cancel": "取消",
"fd_no_matching_material": "无匹配材料",
"fd_no_slots_msg": "没有已装载的 AMS 槽位。{br}仍要开始打印吗?",
@@ -200,6 +214,10 @@
"panel_temps_chart": "历史 (最近 60 次读数)",
"panel_temps_nozzle": "喷嘴",
"print_auto_leveling": "本次打印自动调平",
"printer_store_delete_confirm": "从打印机删除文件?",
"printer_store_delete_selected_confirm": "从打印机删除选中的 {n} 个文件?",
"printer_store_empty": "打印机上没有文件。",
"printer_store_unreachable": "无法连接打印机或查询失败。",
"printers_active": "● 活动",
"printers_current": "当前打印机",
"printers_empty_hint": "尚未设置打印机。",
@@ -213,7 +231,6 @@
"progress_action_slots": "分配槽位",
"settings_auto_leveling": "打印前自动调平",
"settings_auto_leveling_label": "打印前自动调平",
"settings_vibration_compensation": "打印前共振补偿",
"settings_btn_tooltip": "设置",
"settings_camera_on_print": "打印开始时开启相机",
"settings_cat_connection": "连接",
@@ -247,19 +264,6 @@
"settings_password": "MQTT 密码",
"settings_poll": "轮询间隔(秒)",
"settings_poll_interval_hint": "Bridge 查询打印机状态的频率",
"settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)",
"dash_edit": "自定义仪表盘",
"dash_done": "完成",
"dash_reset": "重置",
"dash_preset_standard": "标准",
"dash_preset_wide89": "宽屏桌面",
"dash_hidden_cards": "隐藏的卡片",
"dash_show": "显示",
"dash_hide": "隐藏",
"dash_toggle_width": "切换宽度",
"dash_save_preset": "另存为预设",
"dash_preset_name_prompt": "预设名称:",
"dash_preset_delete_confirm": "删除预设 \"{name}\"",
"settings_poll_interval_label": "轮询间隔(秒)",
"settings_print": "打印设置",
"settings_printer_ip": "打印机 IP",
@@ -271,7 +275,9 @@
"settings_title": "设置",
"settings_username": "MQTT 用户名",
"settings_vendor_filter_placeholder": "搜索厂商…",
"settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)",
"settings_version": "版本",
"settings_vibration_compensation": "打印前共振补偿",
"settings_visible_vendors": "可见厂商(配置下拉框)",
"settings_visible_vendors_hint": "仅这些厂商会出现在槽位配置下拉框中。未选择 = 显示全部。“Generic”和您自己的配置始终可见。",
"settings_visible_vendors_label": "可见厂商(配置下拉框)",
@@ -293,6 +299,7 @@
"skip_sending": "发送中 …",
"skip_success": "对象将被跳过。",
"skip_title": "✂ 跳过对象",
"slot_copy_from": "从插槽复制颜色…",
"slot_edit_color": "颜色",
"slot_edit_custom": "例如 PLA, PETG, ABS…",
"slot_edit_load": "⬇ 进料",
@@ -316,15 +323,15 @@
"store_download": "⬇ 下载",
"store_empty": "尚未上传文件。",
"store_estimate": "估算",
"store_exit_select": "取消",
"store_never": "从未打印",
"store_no_results": "未找到文件。",
"store_print": "▶ 打印",
"store_print_confirm": "打印文件?",
"store_refresh": "↻ 刷新",
"store_search_placeholder": "🔍 搜索…",
"store_select_all": "全选",
"store_selected_count": "已选择 {n} 个",
"store_exit_select": "取消",
"store_search_placeholder": "🔍 搜索…",
"store_upload_busy": "⏳ 上传中…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "浏览",
@@ -344,6 +351,5 @@
"update_docker_copied": "已复制执行docker compose pull && docker compose up -d",
"update_error": "错误",
"update_none": "已是最新版本",
"update_restarting": "重启中...",
"slot_copy_from": "从插槽复制颜色…"
}
"update_restarting": "重启中..."
}