diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index 9b9818c..a3d95f5 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -174,3 +174,20 @@ jobs: --data-binary @/tmp/release_body.json \ "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases" rm -f "$BODY_FILE" /tmp/release_body.json + + - name: Reset NIGHTLY_CHANGELOG.md for the next build + env: + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + . /tmp/nightly_version.env + # The changelog just consumed above must not carry over into the + # next nightly - otherwise every build re-lists all prior entries + # since the last manual reset instead of just what's new. Not in + # nightly.yml's own push-trigger paths, so this commit does not + # re-trigger this workflow. + printf '## Changes in this build\n\n' > NIGHTLY_CHANGELOG.md + git config user.name "gitea-actions" + git config user.email "actions@gitea.it-drui.de" + git add NIGHTLY_CHANGELOG.md + git commit -m "chore: reset NIGHTLY_CHANGELOG.md after nightly-${VERSION} release" || exit 0 + git push https://gitea-actions:${GITEA_TOKEN}@gitea.it-drui.de/viewit/KX-Bridge-Release.git HEAD:nightly diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index f3f3667..f1eff73 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,4 +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) diff --git a/README.de.md b/README.de.md index 2cbdfb1..e7ae143 100644 --- a/README.de.md +++ b/README.de.md @@ -23,15 +23,13 @@ Feedback willkommen.   [![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)   -[![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) Gefällt dir KX-Bridge? Ein Kaffee auf Ko-fi hält das Projekt am Leben. ☕ -> [!CAUTION] -> **Laufende Wartungsarbeiten** — Wir strukturieren das Repository um (Branch-Modell, CI-Workflows, Beitragsprozess). Es kann zu Änderungen bei Branch-Namen, PR-Templates und der Art der Veröffentlichungen kommen. Wir entschuldigen uns für etwaige Unannehmlichkeiten. Handhabung, Workflow und langfristige Wartbarkeit werden dadurch deutlich verbessert. -> + > 👉 Möchtest du beitragen? Bitte zuerst [CONTRIBUTING.md](CONTRIBUTING.md) lesen. --- @@ -43,12 +41,16 @@ Feedback willkommen. | 🖨️ | **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//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 | @@ -70,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 @@ -112,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) --- diff --git a/README.es.md b/README.es.md index ba0d32f..21b202f 100644 --- a/README.es.md +++ b/README.es.md @@ -22,7 +22,7 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.   [![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)   -[![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) ¿Te gusta KX-Bridge? Un café en Ko-fi mantiene el proyecto vivo. ☕ @@ -42,12 +42,16 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback. | 🖨️ | **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//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) --- diff --git a/README.md b/README.md index b7a9861..8f56b8d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ officially tested or supported. Feedback welcome.   [![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)   -[![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) Like KX-Bridge? A coffee on Ko-fi keeps the project alive. ☕ @@ -42,12 +42,16 @@ officially tested or supported. Feedback welcome. | 🖨️ | **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//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) --- diff --git a/docker-compose-KX.yml b/docker-compose-KX.yml index 12752d0..6423220 100644 --- a/docker-compose-KX.yml +++ b/docker-compose-KX.yml @@ -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// +# Persistent data: /mnt/dockerdata/KobraXStack// # # 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=' - # → 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 diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index bf321da..9edd842 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -631,7 +631,16 @@ class CameraCache: self._fail_count_mjpeg: int = 0 def set_url(self, url: str): + # A changed URL means the printer rotated its stream token (typically + # after a reboot). Running ffmpeg processes still hold the stale URL + # and will never pick it up on their own - they only re-read self._url + # at the top of their outer loop, which they never reach while blocked + # in a stdout read on the old, now-silent connection. Tear them down; + # the next ensure_running() respawns them against the new URL. + changed = bool(url and self._url and url != self._url) self._url = url + if changed: + self.reset() def reset(self): """Reset backoff counters and forcefully tear down any running @@ -679,7 +688,12 @@ class CameraCache: self._task_mjpeg = asyncio.create_task(self._run_mjpeg_loop()) def _input_args(self, url: str) -> list[str]: - args = ["-fflags", "nobuffer", "-flags", "low_delay"] + args = ["-fflags", "nobuffer", "-flags", "low_delay", + # Bail out if the source goes silent. A printer reboot or + # network loss leaves the TCP connection ESTABLISHED with no + # data and no FIN, so a passive stdout read blocks forever + # without this (Issue #99). Value is microseconds. + "-timeout", "10000000"] if url.lower().startswith("rtsp://"): args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"] else: @@ -989,6 +1003,7 @@ class KobraXBridge: except Exception: self._visible_vendors = [] self._last_state: dict = {} + self._last_ams_set_request: dict | None = None self._state = { "nozzle_temp": 0.0, "nozzle_target": 0.0, @@ -1036,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 = "" @@ -1569,34 +1595,12 @@ class KobraXBridge: self._spoolman_notify_end() self._current_job_id = "" - # Benachrichtigungen bei Statuswechsel (filename vor Cleanup holen) - if kobra_state and kobra_state != self._prev_kobra_state: - _notif_filename = d.get("filename", self._state.get("filename", "")) - if kobra_state == "printing": - if not self._print_active: - self._print_active = True - self._notify("started", _notif_filename) - self._last_progress_notif_time = time.monotonic() - self._last_progress_notif_layer = self._state.get("curr_layer", 0) - elif kobra_state == "finished": - self._print_active = False - self._notify("finished", _notif_filename) - elif kobra_state == "failed": - self._print_active = False - self._notify("failed", _notif_filename) - elif kobra_state in ("stoped", "canceled"): - self._print_active = False - self._notify("cancelled", _notif_filename) - elif kobra_state == "paused": - self._notify("paused", _notif_filename) - self._prev_kobra_state = kobra_state - - # Hide the upload banner after the print finishes (Issue #29): the - # printer reports "finished" after a successful print - file_ready used to be - # cleared only on stoped/canceled, which brought the banner back. - if kobra_state == "finished": - self._state["file_ready"] = "" - if kobra_state in ("stoped", "canceled"): + # Terminal states (successful finish AND stop/cancel) must leave the + # same clean end state - a "finished" print used to only clear + # file_ready (Issue #29), leaving progress/filename/duration/layer + # fields stuck at the last job's values until the *next* print + # happened to overwrite them (Issue #102). + if kobra_state in ("finished", "stoped", "canceled"): self._state["progress"] = 0.0 self._state["filename"] = "" self._state["file_ready"] = "" @@ -1606,9 +1610,20 @@ class KobraXBridge: self._state["layer_height"] = 0.0 self._state["first_layer_height"] = 0.0 self._state["supplies_usage"] = 0 + self._state["curr_layer"] = 0 + self._state["total_layers"] = 0 self._thumbnail_b64 = "" - self._state["filename"] = d.get("filename", self._state["filename"]) - if "progress" in d: + else: + # Only adopt the payload's filename outside terminal states - the + # printer often still reports the just-finished job's filename in + # the same "finished"/"stoped"/"canceled" message that triggered + # the reset above, which would otherwise immediately undo it. + self._state["filename"] = d.get("filename", self._state["filename"]) + # Pre-print phases (leveling/preheating/checking) report their own + # "progress" - passing it through would make display_status.progress/ + # virtual_sdcard.progress jump non-monotonically once real printing + # starts and the value resets (Issue #102). + if "progress" in d and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"): self._state["progress"] = float(d["progress"]) / 100.0 if "print_time" in d: self._state["print_duration"] = int(d["print_time"]) * 60 @@ -1644,8 +1659,13 @@ class KobraXBridge: self._state["kobra_state"] = kobra_state # Hide the upload banner after the print ends (Issue #29) - the state also # arrives via info/report (project.state) depending on the printer, not only print/report. + # Layer fields must reset here too (Issue #102) - info/report is the + # only source for curr_layer/total_layers on some printers, and they + # otherwise stay stuck at the last job's values indefinitely. if kobra_state in ("finished", "stoped", "canceled"): self._state["file_ready"] = "" + self._state["curr_layer"] = 0 + self._state["total_layers"] = 0 # Camera auto-start here as well (OrcaSlicer often reports the start via info/report). # The _camera_autostarted guard prevents a double start with _on_print. if kobra_state == "printing": @@ -1665,7 +1685,8 @@ class KobraXBridge: if project: if "filename" in project: self._state["filename"] = project["filename"] - if "progress" in project: + # Same non-monotonic-progress guard as _on_print (Issue #102). + if "progress" in project and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"): self._state["progress"] = float(project["progress"]) / 100.0 if "print_time" in project: self._state["print_duration"] = int(project["print_time"]) * 60 @@ -1740,7 +1761,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 "" @@ -1975,6 +2030,14 @@ class KobraXBridge: max_idx = max(loaded_map.keys()) # The printer interprets ams_box_mapping as an ordered list (entry N = TN). # Missing slots must be inserted as placeholders, otherwise everything shifts. + # A placeholder must NOT reference a physically empty tray: the printer + # rejects such an entry even for a tool the GCode never calls (printing + # Filament 4 with the slot below it empty fails; all-full works). Point + # gap placeholders at a definitely-loaded tray instead of the gap's own + # (empty) index. + fallback_gidx = max_idx # highest loaded slot -> loaded + printable + fallback_slot = loaded_map[fallback_gidx] + fallback_ams = self._slot_to_print_ams_index(fallback_gidx) result = [] for i in range(max_idx + 1): if i in loaded_map: @@ -1989,10 +2052,10 @@ class KobraXBridge: else: result.append({ "paint_index": i, - "ams_index": self._slot_to_print_ams_index(i), + "ams_index": fallback_ams, "paint_color": [255, 255, 255, 255], - "ams_color": [255, 255, 255, 255], - "material_type": "PLA", + "ams_color": self._slot_color_rgba(fallback_slot), + "material_type": fallback_slot.get("type", "PLA"), }) return result @@ -2084,10 +2147,21 @@ class KobraXBridge: return activity def _on_multicolor_box(self, payload: dict): + if payload.get("state") == "failed": + req = getattr(self, "_last_ams_set_request", None) + log.warning( + f"multiColorBox setInfo rejected by printer: request={req} raw_response={payload.get('data')}" + ) + self._state["last_ams_set_error"] = True + return data = payload.get("data") or {} + if not isinstance(data, dict): + log.warning(f"multiColorBox/report: unexpected data shape: {data!r}") + return boxes = data.get("multi_color_box") or [] if not boxes: return + self._state["last_ams_set_error"] = False self._head_tools_model = int(data.get("head_tools_model", self._head_tools_model)) self._filament_mode = self._detect_filament_mode(boxes, self._head_tools_model) self._state["filament_mode"] = self._filament_mode @@ -2302,6 +2376,51 @@ class KobraXBridge: return fam return m + def _parse_combined_rfid_type(self, raw_type: str) -> tuple[str, str]: + """Split a combined ACE-RFID "VENDOR TYPE SERIAL" string (e.g. + "GEEETECH PLA Bas", written via third-party RFID tools) into + (vendor, material_family). + + Anycubic's ACE RFID system concatenates vendor + material + a + truncated serial/variant into one `type` string for custom tags - + unlike a normal spool report where `type` is just "PLA"/"PETG"/etc. + Returns ("", "") when the first token isn't a known vendor (from the + merged system+user filament library), which leaves plain type + strings like "PLA" completely unaffected (Issue #101). + """ + tokens = raw_type.split() + if len(tokens) < 2: + return "", "" + first = tokens[0].strip().lower() + vendors = {p.get("vendor", "").lower(): p.get("vendor", "") for p in self._load_orca_filaments()} + vendor = vendors.get(first) + if not vendor: + return "", "" + family = self._material_family(" ".join(tokens[1:])) + if not family: + return "", "" + return vendor, family + + def _match_profile_by_vendor_family(self, vendor: str, family: str) -> dict: + """Find an imported/system filament profile by (vendor, material + family) - used to auto-resolve a combined ACE-RFID type string to + the user's already-imported OrcaSlicer profile (Issue #101), since + the exact profile `name` never appears verbatim in the truncated + RFID string.""" + matches = [ + p for p in self._load_orca_filaments() + if p.get("vendor", "").lower() == vendor.lower() + and self._material_family(p.get("type", "")) == family + ] + if not matches: + return {} + if len(matches) > 1: + log.debug( + f"_match_profile_by_vendor_family: {len(matches)} profiles match " + f"vendor={vendor!r} family={family!r}, using first: {matches[0].get('name')}" + ) + return matches[0] + def _profile_material(self, profile: dict) -> str: """Material type (e.g. "PETG") of a saved slot profile, resolved by (vendor, name) from the Orca filament library. Returns "" when the @@ -2385,6 +2504,21 @@ class KobraXBridge: # family still matches the loaded filament (PETG profile + PLA # loaded -> dropped). user_profile = self._effective_slot_profile(slot_index, material) + if not user_profile.get("name"): + # Third layer: auto-resolve a combined ACE-RFID "VENDOR TYPE + # SERIAL" string (e.g. "GEEETECH PLA Bas", from third-party + # RFID tools) against the user's already-imported profile + # library, instead of falling through to the neutral Generic + # fallback (Issue #101). Not persisted to config.ini - this + # re-derives on every _build_lane_data() call, so a + # differently-tagged spool loaded later isn't stuck with a + # stale match. + vendor_guess, family_guess = self._parse_combined_rfid_type(slot.get("type", "")) + if vendor_guess: + auto_profile = self._match_profile_by_vendor_family(vendor_guess, family_guess) + if auto_profile.get("name"): + user_profile = auto_profile + material = family_guess if user_profile.get("name"): vendor = user_profile.get("vendor", "") fila_name = user_profile.get("name", "") @@ -2878,6 +3012,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) @@ -3524,10 +3745,16 @@ class KobraXBridge: `modified` are non-nullable in GCodeFile; `print_start_time` and the Slicer-Felder optional.""" 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) + # Live _state values are only relevant for the currently/last tracked + # job's own file - using them as a starting point for a DIFFERENT + # filename leaked the tracked job's layer count/time into unrelated + # metadata queries (Issue #102). For any other filename, rely solely + # on that file's own GCodeStore row. + is_tracked_file = bool(filename) and filename == s.get("filename") + layer_h = float(s.get("layer_height") or 0.0) if is_tracked_file else 0.0 + first_h = float(s.get("first_layer_height") or 0.0) if is_tracked_file else 0.0 + total_layers = int(s.get("total_layers") or 0) if is_tracked_file else 0 + est_time = int(s.get("slicer_time") or 0) if is_tracked_file else 0 size_bytes = 0 try: gf = self._store.get_file_by_name(filename) or {} @@ -4242,6 +4469,11 @@ 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() + self._state["last_ams_set_error"] = False + # Remembered so a later state="failed" report (which carries no slot + # info of its own, see _on_multicolor_box) can be logged alongside the + # request that triggered it - otherwise the failure is unattributable. + self._last_ams_set_request = {"global": index, "box": box_id, "local_slot": local_slot, "type": mat, "color": color} # setInfo goes via the web/printer topic (like tempature/set). Verified via # Workbench-Vue mqtt_setInfo verifiziert — via slicer/printer/ wurden # slot changes are ignored by the printer and overwritten with the old @@ -4556,6 +4788,16 @@ class KobraXBridge: q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=8) self.camera_cache.mjpeg_subscribers.add(q) + # Wait for the first frame BEFORE resp.prepare() - once prepare() sends + # the response headers the status is committed to 200, so a stalled + # source (Issue #99) must be caught here to actually return a 503 + # instead of hanging the client forever with no frame ever arriving. + try: + first_frame = await asyncio.wait_for(q.get(), timeout=5.0) + except asyncio.TimeoutError: + self.camera_cache.mjpeg_subscribers.discard(q) + return web.Response(status=503, text="No frame in cache yet") + boundary = "kobraxframe" resp = web.StreamResponse(headers={ "Content-Type": f"multipart/x-mixed-replace;boundary={boundary}", @@ -4564,8 +4806,8 @@ class KobraXBridge: }) await resp.prepare(request) try: + frame = first_frame while True: - frame = await q.get() header = ( f"--{boundary}\r\n" f"Content-Type: image/jpeg\r\n" @@ -4577,6 +4819,7 @@ class KobraXBridge: break except Exception: break + frame = await q.get() except Exception as e: log.warning(f"Camera stream interrupted: {e}") finally: @@ -5909,6 +6152,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) diff --git a/tests/test_ace_rfid_vendor_matching.py b/tests/test_ace_rfid_vendor_matching.py new file mode 100644 index 0000000..88b9135 --- /dev/null +++ b/tests/test_ace_rfid_vendor_matching.py @@ -0,0 +1,111 @@ +"""Auto-matching for custom ACE-RFID filament tags (Issue #101). + +Anycubic's ACE RFID system concatenates vendor + material + a truncated +serial into one `type` string for custom (third-party) tags, e.g. +"GEEETECH PLA Bas" (vendor "Geeetech", material "PLA", serial "Bas" for +"Basic"). Previously the bridge treated this whole string as an unknown +material and fell back to a neutral "Generic " profile, even though +the user had already imported a matching OrcaSlicer profile via the ZIP +import feature (Issue #41) - requiring a manual per-slot reassignment every +time that spool was loaded. + +_parse_combined_rfid_type() + _match_profile_by_vendor_family() resolve this +automatically against the merged system+user filament library. +""" +import argparse +import tempfile +from unittest.mock import MagicMock + +from kobrax_moonraker_bridge import KobraXBridge + +USER_PROFILES = [ + {"id": "OGFL99", "name": "Generic PLA", "vendor": "Generic", "type": "PLA", "color": ""}, + {"id": "GTPLA01", "name": "Geeetech PLA Basic", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True}, +] + + +def _bridge(profiles=USER_PROFILES): + c = MagicMock(); c.callbacks = {}; c.connected = False + args = argparse.Namespace( + printer_ip="", mqtt_port=9883, username="", password="", + mode_id="20030", device_id="", host="127.0.0.1", port=7125, + data_dir=tempfile.mkdtemp(prefix="kxrfid-"), + ) + b = KobraXBridge(c, args=args) + b._orca_filaments_cache = profiles + return b + + +def test_combined_rfid_type_parses_known_vendor_and_family(): + b = _bridge() + vendor, family = b._parse_combined_rfid_type("GEEETECH PLA Bas") + assert vendor == "Geeetech" + assert family == "PLA" + + +def test_plain_type_string_is_unaffected(): + """Regression guard: a normal type="PLA" report (no vendor prefix) must + not be mistaken for a combined RFID string.""" + b = _bridge() + vendor, family = b._parse_combined_rfid_type("PLA") + assert vendor == "" + assert family == "" + + +def test_unknown_vendor_prefix_returns_no_match(): + b = _bridge() + vendor, family = b._parse_combined_rfid_type("TOTALLYUNKNOWNBRAND PLA Bas") + assert vendor == "" + assert family == "" + + +def test_match_profile_by_vendor_family_finds_imported_profile(): + b = _bridge() + profile = b._match_profile_by_vendor_family("Geeetech", "PLA") + assert profile.get("name") == "Geeetech PLA Basic" + + +def test_match_profile_by_vendor_family_no_match_returns_empty(): + b = _bridge() + profile = b._match_profile_by_vendor_family("Geeetech", "PETG") + assert profile == {} + + +def test_match_profile_by_vendor_family_ambiguous_picks_first_without_crashing(): + profiles = USER_PROFILES + [ + {"id": "GTPLA02", "name": "Geeetech PLA Silk", "vendor": "Geeetech", "type": "PLA SILK", "color": "", "is_user": True}, + ] + b = _bridge(profiles) + profile = b._match_profile_by_vendor_family("Geeetech", "PLA") + assert profile.get("name") in ("Geeetech PLA Basic", "Geeetech PLA Silk") + + +def test_build_lane_data_auto_resolves_combined_rfid_slot(): + """End-to-end: a slot reporting the combined RFID string should surface + the imported Geeetech profile in lane_data instead of the Generic + fallback.""" + b = _bridge() + b._filament_profiles = {} # no manual per-slot override - isolate the auto-match path + b._filament_mode = "ace_hub" + b._ams_slots = [ + {"global_index": 0, "box_id": 0, "status": 5, "type": "GEEETECH PLA Bas", "color": [238, 190, 152]}, + ] + lane = b._build_lane_data() + tray = lane["ams"][0]["tray"][0] + assert tray["vendor_name"] == "Geeetech" + assert tray["name"] == "Geeetech PLA Basic" + + +def test_build_lane_data_plain_type_still_uses_generic_fallback(): + """Regression guard: everyday type="PLA" slots must keep using the + existing Generic-library fallback, unaffected by the new matching path.""" + b = _bridge() + b._filament_profiles = {} # no manual per-slot override - isolate the fallback path + b._filament_mode = "ace_hub" + b._ams_slots = [ + {"global_index": 0, "box_id": 0, "status": 5, "type": "PLA", "color": [255, 255, 255]}, + ] + lane = b._build_lane_data() + tray = lane["ams"][0]["tray"][0] + assert tray["name"] == "Generic PLA" + assert tray["vendor_name"] == "Generic" diff --git a/tests/test_auto_ams_box_mapping_empty_slot.py b/tests/test_auto_ams_box_mapping_empty_slot.py new file mode 100644 index 0000000..7c90466 --- /dev/null +++ b/tests/test_auto_ams_box_mapping_empty_slot.py @@ -0,0 +1,85 @@ +"""Empty-tray placeholder bug in the OrcaSlicer "upload and print" path. + +Real KX1 bug (confirmed 2026-07-22): printing Filament 4 with the slot below it +EMPTY fails; with all slots full it works. `_start_print` -> `_build_auto_ams_box_mapping` +inserts a positional placeholder at each gap whose `ams_index` points at the +gap's own (physically EMPTY) tray. The printer rejects a mapping entry that +references an empty tray, even for a tool the GCode never calls. + +Invariant the fix must hold: EVERY entry's ams_index references a LOADED tray. +Positional alignment (entry N = TN, from a16062f) must be preserved. +""" +import argparse +import tempfile +from unittest.mock import MagicMock + +from kobrax_moonraker_bridge import KobraXBridge + +# Real KX1 AMS state: slot 2 (idx 2) EMPTY, below the used Filament 4 (idx 3). +AMS_SLOTS = [ + {"global_index": 0, "box_id": -1, "status": 5, "type": "PLA", "color": [101, 88, 177]}, + {"global_index": 1, "box_id": -1, "status": 5, "type": "PLA SILK", "color": [239, 96, 163]}, + {"global_index": 2, "box_id": -1, "status": 4, "type": "PLA", "color": [223, 221, 220]}, # EMPTY + {"global_index": 3, "box_id": -1, "status": 5, "type": "PLA", "color": [117, 120, 123]}, +] + + +def _bridge(slots=AMS_SLOTS): + c = MagicMock(); c.callbacks = {}; c.connected = False + args = argparse.Namespace( + printer_ip="", mqtt_port=9883, username="", password="", + mode_id="20030", device_id="", host="127.0.0.1", port=7125, + data_dir=tempfile.mkdtemp(prefix="kxauto-"), + ) + b = KobraXBridge(c, args=args) + b._filament_mode = "toolhead" + b._ams_slots = [dict(s) for s in slots] + return b + + +def _loaded_ams_indices(b): + return {b._slot_to_print_ams_index(int(s["global_index"])) + for s in b._ams_slots if s["status"] == 5} + + +def test_no_entry_points_at_an_empty_tray_when_only_used_slot_loaded(): + """_start_print filters loaded to the used slot only: loaded=[(3, slot3)]. + Positions 0..2 are placeholders and must NOT reference empty tray idx 2.""" + b = _bridge() + loaded = [(3, b._ams_slots[3])] + + mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded) + + assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3] # positional alignment kept + loaded_ams = _loaded_ams_indices(b) + bad = [e for e in mapping if e["ams_index"] not in loaded_ams] + assert not bad, f"entries reference an empty/non-loaded tray: {bad}" + + +def test_no_entry_points_at_an_empty_tray_with_gap_in_loaded_set(): + """All occupied slots mapped (0,1,3 loaded, 2 empty). The placeholder at + position 2 must not reference the empty tray idx 2.""" + b = _bridge() + loaded = [(0, b._ams_slots[0]), (1, b._ams_slots[1]), (3, b._ams_slots[3])] + + mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded) + + assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3] + loaded_ams = _loaded_ams_indices(b) + bad = [e for e in mapping if e["ams_index"] not in loaded_ams] + assert not bad, f"entries reference an empty/non-loaded tray: {bad}" + # Real loaded slots keep their own ams_index. + assert mapping[0]["ams_index"] == 0 + assert mapping[1]["ams_index"] == 1 + assert mapping[3]["ams_index"] == 3 + + +def test_all_full_is_unchanged(): + """All slots loaded -> no placeholders, identity mapping (the working case).""" + slots = [dict(s, status=5) for s in AMS_SLOTS] + b = _bridge(slots) + loaded = [(i, b._ams_slots[i]) for i in range(4)] + + mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded) + + assert [e["ams_index"] for e in mapping] == [0, 1, 2, 3] diff --git a/tests/test_camera_cache_url_rotation.py b/tests/test_camera_cache_url_rotation.py new file mode 100644 index 0000000..8cb4caf --- /dev/null +++ b/tests/test_camera_cache_url_rotation.py @@ -0,0 +1,51 @@ +"""Camera stream hangs forever after printer reboot (Issue #99). + +The printer rotates its stream token on reboot, changing the camera URL. +CameraCache.set_url() used to be a bare assignment - the running ffmpeg loops +never noticed since they only re-read self._url at the top of their outer +loop, which they never reach while permanently blocked reading stdout from +the now-silent, stale-token connection. set_url() must detect the change and +tear the loops down so the next ensure_running() respawns them against the +new URL. +""" +from kobrax_moonraker_bridge import CameraCache + + +def test_set_url_first_time_does_not_reset(): + """No prior URL - nothing stale to tear down.""" + c = CameraCache() + calls = [] + c.reset = lambda: calls.append(True) + c.set_url("http://printer/live/tokenA") + assert c._url == "http://printer/live/tokenA" + assert not calls + + +def test_set_url_same_value_does_not_reset(): + c = CameraCache() + calls = [] + c.set_url("http://printer/live/tokenA") + c.reset = lambda: calls.append(True) + c.set_url("http://printer/live/tokenA") + assert not calls + + +def test_set_url_changed_triggers_reset(): + """The actual bug scenario: token rotates after a printer reboot.""" + c = CameraCache() + calls = [] + c.set_url("http://printer/live/tokenA") + c.reset = lambda: calls.append(True) + c.set_url("http://printer/live/tokenB") + assert c._url == "http://printer/live/tokenB" + assert calls == [True] + + +def test_set_url_empty_to_value_does_not_reset(): + """Startup case: no URL known yet, first status push sets it - nothing + running to tear down.""" + c = CameraCache() + calls = [] + c.reset = lambda: calls.append(True) + c.set_url("http://printer/live/tokenA") + assert not calls diff --git a/tests/test_metadata_and_print_state_reset.py b/tests/test_metadata_and_print_state_reset.py new file mode 100644 index 0000000..7a9b70d --- /dev/null +++ b/tests/test_metadata_and_print_state_reset.py @@ -0,0 +1,151 @@ +"""server/files/metadata state-leak + terminal-state reset gaps (Issue #102). + +Reported by @fmontagna via moonraker-obico: +1. Querying metadata for a file OTHER than the currently/last tracked job + leaked that job's live layer count / estimated time into the response, + because _build_file_metadata() read from self._state first and only fell + back to the file's own GCodeStore row when the state value was falsy. +2. curr_layer/total_layers (and, for a successful "finished" print, every + other per-job field) were never reset at print end - they stayed at the + last job's values until the next print happened to overwrite them. +3. The printer reports its own "progress" during pre-print phases + (preheating/auto_leveling/checking/...), which used to pass straight + through to display_status.progress/virtual_sdcard.progress and then jump + non-monotonically once real printing started and progress reset. +""" +import argparse +import tempfile +from unittest.mock import MagicMock + +from kobrax_moonraker_bridge import KobraXBridge + + +def _bridge(): + c = MagicMock(); c.callbacks = {}; c.connected = False + args = argparse.Namespace( + printer_ip="", mqtt_port=9883, username="", password="", + mode_id="20030", device_id="", host="127.0.0.1", port=7125, + data_dir=tempfile.mkdtemp(prefix="kxmeta-"), + ) + return KobraXBridge(c, args=args) + + +def _insert_store_row(b, filename, layer_count=None, est_time=None, size_bytes=0): + with b._store._lock: + b._store._conn.execute( + "INSERT OR REPLACE INTO gcode_files (id, filename, path, size_bytes, uploaded_at, layer_count, est_print_time_sec) " + "VALUES (?,?,?,?,?,?,?)", + (filename, filename, "/tmp/" + filename, size_bytes, "2026-01-01T00:00:00Z", layer_count, est_time), + ) + b._store._conn.commit() + + +# --- Fix 1: metadata state-leak ------------------------------------------- + +def test_metadata_for_untracked_file_does_not_leak_running_job_state(): + b = _bridge() + # A job is "running": live state has its own layer/time values. + b._state["filename"] = "running.gcode" + b._state["total_layers"] = 999 + b._state["slicer_time"] = 12345 + b._state["layer_height"] = 0.3 + + # Querying a DIFFERENT, unrelated file must use ITS OWN store row, not + # the running job's live state. + _insert_store_row(b, "other.gcode", layer_count=42, est_time=600, size_bytes=1000) + meta = b._build_file_metadata("other.gcode") + assert meta["layer_count"] == 42 + assert meta["estimated_time"] == 600 + assert meta["size"] == 1000 + + +def test_metadata_for_tracked_file_still_uses_live_state(): + """The currently-tracked file's OWN metadata query should still prefer + live state (fresher than what was known at upload time).""" + b = _bridge() + b._state["filename"] = "running.gcode" + b._state["total_layers"] = 55 + b._state["slicer_time"] = 999 + _insert_store_row(b, "running.gcode", layer_count=1, est_time=1) + meta = b._build_file_metadata("running.gcode") + assert meta["layer_count"] == 55 + assert meta["estimated_time"] == 999 + + +def test_metadata_for_nonexistent_file_falls_back_cleanly(): + b = _bridge() + b._state["filename"] = "running.gcode" + b._state["total_layers"] = 999 + meta = b._build_file_metadata("DOES_NOT_EXIST.gcode") + assert meta["layer_count"] is None + assert meta["size"] == 1 # documented fallback, unrelated to this fix + + +# --- Fix 2: terminal-state reset ------------------------------------------- + +def _print_payload(state, **extra): + """print/report envelope: `state` is top-level, everything else is under + `data` (see _on_print: kobra_state = payload.get("state", "")). """ + d = {"filename": "job.gcode"} + d.update(extra) + return {"state": state, "data": d} + + +def test_finished_resets_layer_fields_like_stoped_canceled(): + b = _bridge() + b._state["curr_layer"] = 10 + b._state["total_layers"] = 20 + b._state["progress"] = 0.5 + b._state["filename"] = "job.gcode" + b._on_print(_print_payload("finished")) + assert b._state["curr_layer"] == 0 + assert b._state["total_layers"] == 0 + assert b._state["progress"] == 0.0 + assert b._state["filename"] == "" + + +def test_canceled_resets_layer_fields(): + b = _bridge() + b._state["curr_layer"] = 7 + b._state["total_layers"] = 20 + b._on_print(_print_payload("canceled")) + assert b._state["curr_layer"] == 0 + assert b._state["total_layers"] == 0 + + +def test_on_info_resets_layer_fields_on_terminal_state(): + b = _bridge() + b._state["curr_layer"] = 7 + b._state["total_layers"] = 20 + b._on_info({"data": {"project": {"state": "finished"}}}) + assert b._state["curr_layer"] == 0 + assert b._state["total_layers"] == 0 + + +# --- Fix 3: progress clamping during pre-print phases ---------------------- + +def test_progress_not_updated_during_auto_leveling(): + b = _bridge() + b._state["progress"] = 0.0 + b._on_print(_print_payload("auto_leveling", progress=87)) + assert b._state["progress"] == 0.0 + + +def test_progress_not_updated_during_preheating(): + b = _bridge() + b._state["progress"] = 0.0 + b._on_print(_print_payload("preheating", progress=42)) + assert b._state["progress"] == 0.0 + + +def test_progress_updates_normally_once_printing(): + b = _bridge() + b._on_print(_print_payload("printing", progress=33)) + assert b._state["progress"] == 0.33 + + +def test_on_info_progress_clamped_during_checking(): + b = _bridge() + b._state["progress"] = 0.0 + b._on_info({"data": {"project": {"state": "checking", "progress": 55}}}) + assert b._state["progress"] == 0.0 diff --git a/tests/test_multicolor_box_failed_state.py b/tests/test_multicolor_box_failed_state.py new file mode 100644 index 0000000..3e3654e --- /dev/null +++ b/tests/test_multicolor_box_failed_state.py @@ -0,0 +1,81 @@ +"""AttributeError crash on multiColorBox/report failure (Issue #100). + +Real KX2-Pro bug: manually assigning a filament profile to an ACE slot +(custom-RFID / third-party filament) gets rejected by the printer. Instead of +echoing the normal success shape, the printer replies with `state: "failed"` +and `data` as a 2-element LIST (`["multi_color_box", [...]]`) instead of the +usual dict (`{"multi_color_box": [...]}`). `_on_multicolor_box` called +`data.get(...)` unconditionally, crashing with +`AttributeError: 'list' object has no attribute 'get'` and silently dropping +the report (including the slot-state update it would otherwise have done). +""" +import argparse +import tempfile +from unittest.mock import MagicMock + +from kobrax_moonraker_bridge import KobraXBridge + +# Exact failure payload from the Issue #100 log. +FAILED_PAYLOAD = { + "state": "failed", + "data": ["multi_color_box", [{"filaments": {"id": 2}, "id": 0}]], +} + +SUCCESS_PAYLOAD = { + "state": "success", + "data": { + "head_tools_model": 1, + "multi_color_box": [ + {"id": -1, "slots": []}, + { + "id": 0, + "slots": [ + {"index": 0, "type": "PLA", "color": [0, 156, 189], "status": 5}, + ], + }, + ], + }, +} + + +def _bridge(): + c = MagicMock(); c.callbacks = {}; c.connected = False + args = argparse.Namespace( + printer_ip="", mqtt_port=9883, username="", password="", + mode_id="20030", device_id="", host="127.0.0.1", port=7125, + data_dir=tempfile.mkdtemp(prefix="kxmcb-"), + ) + return KobraXBridge(c, args=args) + + +def test_failed_state_does_not_crash(): + b = _bridge() + b._on_multicolor_box(FAILED_PAYLOAD) # must not raise + assert b._state["last_ams_set_error"] is True + + +def test_success_after_failure_clears_error_flag(): + b = _bridge() + b._on_multicolor_box(FAILED_PAYLOAD) + assert b._state["last_ams_set_error"] is True + b._on_multicolor_box(SUCCESS_PAYLOAD) + assert b._state["last_ams_set_error"] is False + + +def test_non_dict_data_without_failed_state_does_not_crash(): + """Defensive guard: any future non-dict `data` shape must not crash, + even if the printer doesn't set state="failed" for it.""" + b = _bridge() + b._on_multicolor_box({"state": "success", "data": ["multi_color_box", []]}) + + +def test_failed_report_is_logged_with_the_triggering_request(caplog): + """The failure payload alone carries no slot/type/color info - the log + must correlate it with the setInfo request that triggered it, otherwise + the failure reason can't be diagnosed from bridge logs alone.""" + import logging + b = _bridge() + b._last_ams_set_request = {"global": 6, "box": 0, "local_slot": 3, "type": "PLA", "color": [33, 39, 33]} + with caplog.at_level(logging.WARNING): + b._on_multicolor_box(FAILED_PAYLOAD) + assert any("global" in r.message and "6" in r.message for r in caplog.records) diff --git a/tests/test_printer_files_endpoint.py b/tests/test_printer_files_endpoint.py new file mode 100644 index 0000000..6550f89 --- /dev/null +++ b/tests/test_printer_files_endpoint.py @@ -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 diff --git a/web/themes/default/app.js b/web/themes/default/app.js index dbbbe61..9b75615 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -424,6 +424,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); @@ -764,13 +769,22 @@ function showPanel(id) { } // Settings-Kategorie umschalten (Master-Detail) -function showSettingsCat(name) { - document.querySelectorAll(".set-group").forEach((g) => g.classList.remove("active")); - document.querySelectorAll(".set-cat").forEach((b) => b.classList.remove("active")); - var g = document.getElementById("setgrp-" + name); - if (g) g.classList.add("active"); - var c = document.getElementById("setcat-" + name); - if (c) c.classList.add("active"); +function showSettingsCat(name){ + document.querySelectorAll('.set-group').forEach(g=>g.classList.remove('active')); + document.querySelectorAll('.set-cat').forEach(b=>b.classList.remove('active')); + var g=document.getElementById('setgrp-'+name);if(g)g.classList.add('active'); + 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 ── @@ -4066,16 +4080,198 @@ function storeDeleteSelected(){ }); } -var _gcodeFilaments = []; +// ── 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 _setGcodeFilamentsFromFileObj(fileObj) { - try { - if (fileObj && Array.isArray(fileObj.gcode_filaments)) { - _gcodeFilaments = fileObj.gcode_filaments; - } else if (fileObj && typeof fileObj.gcode_filaments === "string" && fileObj.gcode_filaments) { - _gcodeFilaments = JSON.parse(fileObj.gcode_filaments); - } else { - _gcodeFilaments = []; +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=''+ + ''; + 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 + ? '' + : '
🖨
'; + return '
'+ + checkbox+ + thumbHtml+ + '
'+name+'
'+ + '
💾 '+sizeKb+'
'+ + '
📅 '+date+'
'+ + '
'+ + ''+ + '
'+ + '
'; + }).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&&n0?(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){ + try{ + if(fileObj&&Array.isArray(fileObj.gcode_filaments)){ + _gcodeFilaments=fileObj.gcode_filaments; + }else if(fileObj&&typeof fileObj.gcode_filaments==='string'&&fileObj.gcode_filaments){ + _gcodeFilaments=JSON.parse(fileObj.gcode_filaments); + }else{ + _gcodeFilaments=[]; } } catch (e) { _gcodeFilaments = []; diff --git a/web/themes/default/index.html b/web/themes/default/index.html index b79b6b8..50ffb0e 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -537,49 +537,81 @@ 🗂 Datei-Browser -
- - - +
+ +
-
- - - GCode hierher ziehen oder durchsuchen - + +
+
+ + + +
+
+ + + GCode hierher ziehen oder durchsuchen + +
+ + +
-
diff --git a/web/themes/default/style.css b/web/themes/default/style.css index bffa01d..dc2c69a 100644 --- a/web/themes/default/style.css +++ b/web/themes/default/style.css @@ -258,7 +258,7 @@ main { GridStack's resize-handle hit area and drag listeners. */ /* Doc pattern: the card fills its cell; content scrolls if the user resizes the cell smaller than the content needs. */ -.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow:auto;box-sizing:border-box} +.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow-y:auto;overflow-x:hidden;box-sizing:border-box} /* .card:hover{transform:translateY(-1px)} creates a new containing block right as the user starts dragging, throwing off GridStack's position math. Kill the hover transform for dashboard cards specifically. */ @@ -314,59 +314,22 @@ main { .cam-toggle:hover{background:rgba(0,0,0,.7)} /* ── PROGRESS ── */ -.hero-info { - display: flex; - flex-direction: column; - gap: 12px; -} -.pct-big { - font-size: 52px; - font-weight: 700; - line-height: 1; - color: var(--txt); -} -.pct-big small { - font-size: 20px; - font-weight: 400; - color: var(--txt2); -} -.progress-bar { - height: 8px; - background: var(--raised); - border-radius: 4px; - overflow: hidden; - margin: 4px 0; -} -.progress-fill { - height: 100%; - background: linear-gradient(90deg, var(--accent), #0080cc); - border-radius: 4px; - transition: width 0.6s ease; -} -.meta-row { - display: flex; - justify-content: space-between; - font-size: 12px; - color: var(--txt2); -} -.layer-badge { - background: var(--raised); - border-radius: 6px; - padding: 4px 8px; - font-family: var(--mono); - font-size: 12px; - color: var(--txt); -} -.fname { - font-size: 12px; - color: var(--txt2); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - background: var(--raised); - border-radius: 6px; - padding: 6px 8px; -} +/* Same pattern as #card-camera above: without this, shrinking the tile's + height just clips the lower content (time-grid/filename/buttons) outside + the visible area instead of making it scrollable (Issue #97). */ +.grid-stack-item-content>#card-progress{display:flex;flex-direction:column;min-height:0} +.grid-stack-item-content>#card-progress>*{flex-shrink:0} +.hero-info{display:flex;flex-direction:column;gap:12px} +.pct-big{font-size:52px;font-weight:700;line-height:1;color:var(--txt)} +.pct-big small{font-size:20px;font-weight:400;color:var(--txt2)} +.progress-bar{height:8px;background:var(--raised);border-radius:4px;overflow:hidden;margin:4px 0} +.progress-fill{height:100%;background:linear-gradient(90deg,var(--accent),#0080cc); + border-radius:4px;transition:width .6s ease} +.meta-row{display:flex;justify-content:space-between;font-size:12px;color:var(--txt2)} +.layer-badge{background:var(--raised);border-radius:6px;padding:4px 8px; + font-family:var(--mono);font-size:12px;color:var(--txt)} +.fname{font-size:12px;color:var(--txt2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; + background:var(--raised);border-radius:6px;padding:6px 8px} /* ── PRINT CONTROLS ── */ .ctrl-btns { @@ -891,6 +854,12 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background: } } +/* ── 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; diff --git a/web/translations/de.json b/web/translations/de.json index 5505208..30c3bfe 100644 --- a/web/translations/de.json +++ b/web/translations/de.json @@ -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?", @@ -202,6 +216,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.", @@ -215,7 +233,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", @@ -270,19 +287,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", @@ -294,7 +298,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)", @@ -315,6 +321,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", @@ -340,15 +347,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", @@ -368,6 +375,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…" -} \ No newline at end of file + "update_restarting": "Starte neu..." +} diff --git a/web/translations/en.json b/web/translations/en.json index cb65742..2fd133c 100644 --- a/web/translations/en.json +++ b/web/translations/en.json @@ -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_stop": "◼ Camera", "btn_cam_stop2": "◼ Stop", @@ -67,6 +69,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?", @@ -201,6 +215,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.", @@ -214,7 +232,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", @@ -267,19 +284,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", @@ -291,7 +295,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)", @@ -310,6 +316,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", @@ -335,15 +342,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", @@ -363,6 +370,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…" -} \ No newline at end of file + "update_restarting": "Restarting..." +} diff --git a/web/translations/es.json b/web/translations/es.json index 243e2bd..9e3372b 100644 --- a/web/translations/es.json +++ b/web/translations/es.json @@ -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?", @@ -202,6 +216,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.", @@ -215,7 +233,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", @@ -270,19 +287,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", @@ -294,7 +298,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)", @@ -315,6 +321,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", @@ -340,15 +347,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", @@ -368,6 +375,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…" -} \ No newline at end of file + "update_restarting": "Reiniciando..." +} diff --git a/web/translations/fr.json b/web/translations/fr.json index 2a731d2..447bc9c 100644 --- a/web/translations/fr.json +++ b/web/translations/fr.json @@ -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", @@ -203,6 +205,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.", @@ -302,6 +308,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", @@ -327,15 +334,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", @@ -355,6 +362,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…" -} \ No newline at end of file + "update_restarting": "Redémarrage…" +} diff --git a/web/translations/it.json b/web/translations/it.json index 9a63d90..3797bd1 100644 --- a/web/translations/it.json +++ b/web/translations/it.json @@ -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", @@ -203,6 +205,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.", @@ -301,6 +307,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", @@ -326,15 +333,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", @@ -354,6 +361,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…" -} \ No newline at end of file + "update_restarting": "Riavvio in corso..." +} diff --git a/web/translations/zh-cn.json b/web/translations/zh-cn.json index b0d7c98..b4541c8 100644 --- a/web/translations/zh-cn.json +++ b/web/translations/zh-cn.json @@ -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}仍要开始打印吗?", @@ -202,6 +216,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": "尚未设置打印机。", @@ -215,7 +233,6 @@ "progress_action_slots": "分配槽位", "settings_auto_leveling": "打印前自动调平", "settings_auto_leveling_label": "打印前自动调平", - "settings_vibration_compensation": "打印前共振补偿", "settings_btn_tooltip": "设置", "settings_camera_on_print": "打印开始时开启相机", "settings_cat_connection": "连接", @@ -270,19 +287,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", @@ -294,7 +298,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": "可见厂商(配置下拉框)", @@ -315,6 +321,7 @@ "skip_sending": "发送中 …", "skip_success": "对象将被跳过。", "skip_title": "✂ 跳过对象", + "slot_copy_from": "从插槽复制颜色…", "slot_edit_color": "颜色", "slot_edit_custom": "例如 PLA, PETG, ABS…", "slot_edit_load": "⬇ 进料", @@ -340,15 +347,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": "浏览", @@ -368,6 +375,5 @@ "update_docker_copied": "已复制!执行:docker compose pull && docker compose up -d", "update_error": "错误", "update_none": "已是最新版本", - "update_restarting": "重启中...", - "slot_copy_from": "从插槽复制颜色…" -} \ No newline at end of file + "update_restarting": "重启中..." +}