Просмотр исходного кода

Merge pull request #1045 from maziggy/0.2.3.1

v0.2.3.1
MartinNYHC 1 месяц назад
Родитель
Сommit
66fd37b132
40 измененных файлов с 1473 добавлено и 147 удалено
  1. 4 0
      CHANGELOG.md
  2. 1 1
      Dockerfile
  3. 7 46
      backend/app/api/routes/smart_plugs.py
  4. 1 1
      backend/app/core/config.py
  5. 29 11
      backend/app/main.py
  6. 71 4
      backend/app/services/archive.py
  7. 47 24
      backend/app/services/bambu_ftp.py
  8. 8 1
      backend/app/services/bambu_mqtt.py
  9. 15 6
      backend/app/services/firmware_check.py
  10. 35 0
      backend/app/services/mqtt_smart_plug.py
  11. 4 0
      backend/app/services/obico_detection.py
  12. 140 0
      backend/tests/integration/test_print_queue_api.py
  13. 133 0
      backend/tests/unit/services/test_archive_copy.py
  14. 56 0
      backend/tests/unit/services/test_bambu_ftp.py
  15. 17 5
      backend/tests/unit/services/test_bambu_mqtt.py
  16. 145 0
      backend/tests/unit/services/test_mqtt_smart_plug_subscribe.py
  17. 47 0
      backend/tests/unit/test_firmware_versions.py
  18. 39 0
      backend/tests/unit/test_obico_detection.py
  19. 86 0
      frontend/src/__tests__/hooks/useCameraStreamToken.test.ts
  20. 74 1
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  21. 195 0
      frontend/src/__tests__/pages/PrintersPage.test.tsx
  22. 1 1
      frontend/src/components/ColorCatalogSettings.tsx
  23. 51 0
      frontend/src/components/icons/PlateClearedIcon.tsx
  24. 40 4
      frontend/src/hooks/useCameraStreamToken.ts
  25. 10 1
      frontend/src/i18n/locales/de.ts
  26. 10 1
      frontend/src/i18n/locales/en.ts
  27. 10 1
      frontend/src/i18n/locales/fr.ts
  28. 10 1
      frontend/src/i18n/locales/it.ts
  29. 10 1
      frontend/src/i18n/locales/ja.ts
  30. 10 1
      frontend/src/i18n/locales/pt-BR.ts
  31. 10 1
      frontend/src/i18n/locales/zh-CN.ts
  32. 10 1
      frontend/src/i18n/locales/zh-TW.ts
  33. 3 3
      frontend/src/pages/CameraPage.tsx
  34. 44 19
      frontend/src/pages/FileManagerPage.tsx
  35. 97 9
      frontend/src/pages/PrintersPage.tsx
  36. 1 1
      frontend/src/pages/SettingsPage.tsx
  37. 0 0
      static/assets/index-3s5orqQ4.css
  38. 0 0
      static/assets/index-CFcQzo54.js
  39. 0 0
      static/assets/index-CkAOuJaW.css
  40. 2 2
      static/index.html

Разница между файлами не показана из-за своего большого размера
+ 4 - 0
CHANGELOG.md


+ 1 - 1
Dockerfile

@@ -14,7 +14,7 @@ COPY frontend/ ./
 RUN npm run build
 
 # Production image
-FROM python:3.13-slim
+FROM python:3.13-slim-trixie
 
 WORKDIR /app
 

+ 7 - 46
backend/app/api/routes/smart_plugs.py

@@ -33,6 +33,7 @@ from backend.app.schemas.smart_plug import (
 from backend.app.services.discovery import tasmota_scanner
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.mqtt_relay import mqtt_relay
+from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
@@ -124,30 +125,9 @@ async def create_smart_plug(
 
     # Subscribe MQTT plugs to their topics
     if plug.plug_type == "mqtt":
-        # Determine effective topics (new fields take priority, fall back to legacy)
-        power_topic = plug.mqtt_power_topic or plug.mqtt_topic
-        energy_topic = plug.mqtt_energy_topic
-        state_topic = plug.mqtt_state_topic
-
-        # Only subscribe if at least one topic is configured
-        if power_topic or energy_topic or state_topic:
-            mqtt_relay.smart_plug_service.subscribe(
-                plug_id=plug.id,
-                # Power source (path is optional)
-                power_topic=power_topic,
-                power_path=plug.mqtt_power_path,
-                power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0,
-                # Energy source (path is optional)
-                energy_topic=energy_topic,
-                energy_path=plug.mqtt_energy_path,
-                energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0,
-                # State source (path is optional)
-                state_topic=state_topic,
-                state_path=plug.mqtt_state_path,
-                state_on_value=plug.mqtt_state_on_value,
-            )
-            topics = [t for t in [power_topic, energy_topic, state_topic] if t]
-            logger.info("Created MQTT plug '%s' subscribed to %s", plug.name, ", ".join(set(topics)))
+        topics = subscribe_plug_to_mqtt(mqtt_relay.smart_plug_service, plug)
+        if topics:
+            logger.info("Created MQTT plug '%s' subscribed to %s", plug.name, ", ".join(topics))
     elif plug.plug_type == "homeassistant":
         logger.info("Created Home Assistant plug '%s' (%s)", plug.name, plug.ha_entity_id)
     else:
@@ -505,28 +485,9 @@ async def update_smart_plug(
             if old_plug_type == "mqtt":
                 mqtt_relay.smart_plug_service.unsubscribe(plug.id)
 
-            # Subscribe to new topics
-            power_topic = plug.mqtt_power_topic or plug.mqtt_topic
-            energy_topic = plug.mqtt_energy_topic
-            state_topic = plug.mqtt_state_topic
-
-            # Only subscribe if at least one topic is configured
-            if power_topic or energy_topic or state_topic:
-                mqtt_relay.smart_plug_service.subscribe(
-                    plug_id=plug.id,
-                    # Power source (path is optional)
-                    power_topic=power_topic,
-                    power_path=plug.mqtt_power_path,
-                    power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0,
-                    # Energy source (path is optional)
-                    energy_topic=energy_topic,
-                    energy_path=plug.mqtt_energy_path,
-                    energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0,
-                    # State source (path is optional)
-                    state_topic=state_topic,
-                    state_path=plug.mqtt_state_path,
-                    state_on_value=plug.mqtt_state_on_value,
-                )
+            # Subscribe via the shared helper (matches startup restore and
+            # create route) — keeps all three paths in lock-step.
+            subscribe_plug_to_mqtt(mqtt_relay.smart_plug_service, plug)
 
     logger.info("Updated smart plug '%s'", plug.name)
     return plug

+ 1 - 1
backend/app/core/config.py

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.2.3"
+APP_VERSION = "0.2.3.1"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 

+ 29 - 11
backend/app/main.py

@@ -431,6 +431,23 @@ def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | No
     return stored_ams_mapping
 
 
+async def _bump_library_file_usage_if_completed(db, item, queue_status: str) -> None:
+    """Increment LibraryFile.print_count and stamp last_printed_at when a queued
+    print completes successfully. Gated to status=='completed': failed, cancelled
+    and aborted prints do not count as usage. Caller is responsible for committing
+    the session. No-op when the queue item has no linked library file (e.g. reprints
+    from an archive). See #1008."""
+    if queue_status != "completed" or item.library_file_id is None:
+        return
+    from backend.app.models.library import LibraryFile
+
+    lib_file = await db.scalar(select(LibraryFile).where(LibraryFile.id == item.library_file_id))
+    if lib_file is None:
+        return
+    lib_file.print_count = (lib_file.print_count or 0) + 1
+    lib_file.last_printed_at = datetime.now(timezone.utc)
+
+
 def mark_printer_stopped_by_user(printer_id: int) -> None:
     """Mark that the active print on this printer was stopped by the user from the queue UI.
 
@@ -2649,6 +2666,12 @@ async def on_print_complete(printer_id: int, data: dict):
                     queue_status = "cancelled"
                 item.status = queue_status
                 item.completed_at = datetime.now(timezone.utc)
+
+                # Bump usage counters on the source library file so admins can
+                # sort by "last printed" and (eventually) auto-purge stale
+                # files — #1008.
+                await _bump_library_file_usage_if_completed(db, item, queue_status)
+
                 await db.commit()
                 queue_item_id = item.id
                 queue_auto_off = item.auto_off_after
@@ -4097,21 +4120,16 @@ async def lifespan(app: FastAPI):
         # Restore MQTT smart plug subscriptions
         if mqtt_settings.get("mqtt_enabled"):
             from backend.app.models.smart_plug import SmartPlug
+            from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
 
             result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt"))
             mqtt_plugs = result.scalars().all()
+            restored = 0
             for plug in mqtt_plugs:
-                if plug.mqtt_topic:
-                    mqtt_relay.smart_plug_service.subscribe(
-                        plug_id=plug.id,
-                        topic=plug.mqtt_topic,
-                        power_path=plug.mqtt_power_path,
-                        energy_path=plug.mqtt_energy_path,
-                        state_path=plug.mqtt_state_path,
-                        multiplier=plug.mqtt_multiplier or 1.0,
-                    )
-            if mqtt_plugs:
-                logging.info("Restored %s MQTT smart plug subscriptions", len(mqtt_plugs))
+                if subscribe_plug_to_mqtt(mqtt_relay.smart_plug_service, plug):
+                    restored += 1
+            if restored:
+                logging.info("Restored %s MQTT smart plug subscriptions", restored)
 
     # Connect to all active printers
     async with async_session() as db:

+ 71 - 4
backend/app/services/archive.py

@@ -1,6 +1,7 @@
 import hashlib
 import json
 import logging
+import os
 import re
 import shutil
 import zipfile
@@ -19,6 +20,27 @@ from backend.app.models.printer import Printer
 logger = logging.getLogger(__name__)
 
 
+def _copy_and_fsync(src: Path, dst: Path, chunk_size: int = 1024 * 1024) -> None:
+    """Copy src to dst with an explicit chunked read/write and fsync the dst.
+
+    Replacement for shutil.copy2 in the archive pipeline. shutil.copy2 uses
+    Linux sendfile(), which on some kernels/filesystems has returned a short
+    count on the first call and truncated the destination for larger 3MF
+    uploads (#1032, observed on Raspberry Pi OS bookworm / armv7l). An
+    explicit loop with fsync avoids that path and guarantees the dest bytes
+    are on disk before the caller inspects them as a ZIP.
+    """
+    with src.open("rb") as rf, dst.open("wb") as wf:
+        while True:
+            buf = rf.read(chunk_size)
+            if not buf:
+                break
+            wf.write(buf)
+        wf.flush()
+        os.fsync(wf.fileno())
+    shutil.copystat(src, dst)
+
+
 class ThreeMFParser:
     """Parser for Bambu Lab 3MF files."""
 
@@ -56,8 +78,16 @@ class ThreeMFParser:
                 self.metadata.pop("_slice_filament_type", None)
                 self.metadata.pop("_slice_filament_color", None)
                 self.metadata.pop("_plate_index", None)
-        except Exception:
-            pass  # Return whatever metadata was extracted before the error
+        except Exception as e:
+            # Return whatever metadata was extracted before the error, but
+            # surface the failure so corrupted / truncated 3MF archives are
+            # visible in support bundles (#1032).
+            logger.warning(
+                "ThreeMFParser: failed to parse %s: %s(%s) — returning partial metadata",
+                self.file_path,
+                type(e).__name__,
+                e,
+            )
         return self.metadata
 
     def _parse_slice_info(self, zf: zipfile.ZipFile):
@@ -888,9 +918,46 @@ class ArchiveService:
         archive_dir = settings.archive_dir / printer_folder / archive_name
         archive_dir.mkdir(parents=True, exist_ok=True)
 
-        # Copy 3MF file
+        # Copy 3MF file with an explicit fsync'd loop (avoids a sendfile
+        # short-read quirk that silently truncated 3MF archives on some
+        # platforms — see _copy_and_fsync and #1032).
         dest_file = archive_dir / source_file.name
-        shutil.copy2(source_file, dest_file)
+        _copy_and_fsync(source_file, dest_file)
+
+        # If we just archived a 3MF, verify the dest is a valid ZIP before
+        # going any further. Staying quiet here is how #1032 escaped review —
+        # the archive row was written but every later zipfile.ZipFile() call
+        # on the dest failed with "File is not a zip file".
+        if (
+            source_file.suffix.lower() == ".3mf"
+            and zipfile.is_zipfile(source_file)
+            and not zipfile.is_zipfile(dest_file)
+        ):
+            try:
+                src_size = source_file.stat().st_size
+                dst_size = dest_file.stat().st_size
+            except OSError:
+                src_size = dst_size = -1
+            logger.error(
+                "Archive copy corrupted 3MF: src=%s (%s bytes, valid ZIP) -> dst=%s (%s bytes, NOT a ZIP). Refusing to create archive row.",
+                source_file,
+                src_size,
+                dest_file,
+                dst_size,
+            )
+            # Narrow cleanup: remove only the truncated file and the archive
+            # directory if it's now empty. archive_dir was created with
+            # exist_ok=True so it could in theory pre-date this call (e.g.
+            # same-second same-filename collision); rmtree would be too broad.
+            try:
+                dest_file.unlink()
+            except OSError:
+                pass
+            try:
+                archive_dir.rmdir()
+            except OSError:
+                pass  # directory not empty — leave untouched
+            return None
 
         # Compute content hash for duplicate detection
         content_hash = self.compute_file_hash(dest_file)

+ 47 - 24
backend/app/services/bambu_ftp.py

@@ -4,6 +4,7 @@ import logging
 import os
 import socket
 import ssl
+import threading
 import time
 from collections.abc import Awaitable, Callable
 from ftplib import FTP, FTP_TLS  # nosec B402
@@ -719,46 +720,68 @@ async def download_file_async(
     # the download after we stop waiting. The thread flips `success` to True
     # ONLY after the file is fully written — a post-timeout check lets us
     # salvage the download without mistaking an in-progress partial write
-    # for a completed one. Each attempt gets its own dict so a zombie from
-    # an earlier attempt can't flip the flag for a later one.
+    # for a completed one. Each attempt gets its own dict and event so a
+    # zombie from an earlier attempt can't flip the flag for a later one.
+    # The event is set in `_download`'s finally block so the post-timeout
+    # path can wait for genuine thread completion instead of a fixed sleep.
 
-    def _download(force_prot_c: bool, completion: dict) -> bool:
+    def _download(force_prot_c: bool, completion: dict, done: threading.Event) -> bool:
         mode_str = "prot_c" if force_prot_c else "prot_p"
-        client = BambuFTPClient(
-            ip_address, access_code, timeout=socket_timeout, printer_model=printer_model, force_prot_c=force_prot_c
-        )
-        if client.connect():
-            try:
-                result = client.download_to_file(remote_path, local_path)
-                if result:
-                    BambuFTPClient.cache_mode(ip_address, mode_str)
-                    completion["success"] = True
-                return result
-            finally:
-                client.disconnect()
-        return False
+        try:
+            client = BambuFTPClient(
+                ip_address,
+                access_code,
+                timeout=socket_timeout,
+                printer_model=printer_model,
+                force_prot_c=force_prot_c,
+            )
+            if client.connect():
+                try:
+                    result = client.download_to_file(remote_path, local_path)
+                    if result:
+                        BambuFTPClient.cache_mode(ip_address, mode_str)
+                        completion["success"] = True
+                    return result
+                finally:
+                    client.disconnect()
+            return False
+        finally:
+            done.set()
 
     async def _run(force_prot_c: bool) -> bool:
         completion = {"success": False}
+        done = threading.Event()
         try:
             return await asyncio.wait_for(
-                loop.run_in_executor(None, lambda: _download(force_prot_c, completion)), timeout=timeout
+                loop.run_in_executor(None, _download, force_prot_c, completion, done), timeout=timeout
             )
         except TimeoutError:
-            # Give the zombie executor thread a brief moment to finish if it
-            # was already close to done. Only salvage when the thread has
-            # signalled genuine success — checking file size alone would
-            # mistake an in-progress partial write for a completed download.
-            await asyncio.sleep(0.5)
+            # Slow WiFi links commonly overshoot ftp_timeout by 10–30 s without
+            # actually being stuck, so starting attempt 2 now would just contend
+            # with the still-progressing RETR on attempt 1 and produce the
+            # zombie-write race reported in #1014 (file landed on disk minutes
+            # after the retry loop had already given up). Wait for the worker
+            # thread to genuinely finish — capped at 30 s so a truly stuck
+            # connection can't stall a whole attempt indefinitely, with a 0.5 s
+            # floor so artificially small test timeouts still give zombies a
+            # realistic window to finish.
+            grace = max(min(timeout, 30.0), 0.5)
+            await loop.run_in_executor(None, done.wait, grace)
             if completion["success"] and local_path.exists() and local_path.stat().st_size > 0:
                 logger.info(
-                    "FTP download wait_for timed out after %ss for %s, but thread completed (%s bytes) — salvaging",
+                    "FTP download wait_for timed out after %ss for %s, but thread completed within %ss grace (%s bytes) — salvaging",
                     timeout,
                     remote_path,
+                    grace,
                     local_path.stat().st_size,
                 )
                 return True
-            logger.warning("FTP download timed out after %ss for %s", timeout, remote_path)
+            logger.warning(
+                "FTP download timed out after %ss (plus %ss grace) for %s",
+                timeout,
+                grace,
+                remote_path,
+            )
             return False
 
     # Check if we have a cached mode for this printer

+ 8 - 1
backend/app/services/bambu_mqtt.py

@@ -3020,7 +3020,14 @@ class BambuMQTTClient:
             # validation" (unlike Studio, we don't have the file's real md5 here
             # without re-reading the upload, and sending a synthetic wrong digest
             # risks activation of md5 verification on some firmwares).
-            submission_id = str(int(time.time() * 1000))
+            # Cap at signed int32 max: P1S firmware (01.10.00.00) clamps oversized
+            # task identity fields to 2**31-1, so raw epoch-ms (13 digits, ~1.7e12)
+            # overflows and every submission ends up with the same task_id from
+            # the printer's perspective — the printer then treats a fresh dispatch
+            # as a continuation of the last FAILED job and never leaves IDLE (#1042).
+            # Modulo keeps uniqueness within a ~24-day wrap window; `or 1` guards
+            # the (astronomically unlikely) zero case since task_id=0 is rejected.
+            submission_id = str(int(time.time() * 1000) % 2_147_483_647 or 1)
 
             command = {
                 "print": {

+ 15 - 6
backend/app/services/firmware_check.py

@@ -161,8 +161,11 @@ class FirmwareCheckService:
         Fetch all firmware versions from the wiki release history page.
 
         Only extracts versions that appear in section-heading anchors
-        (e.g. `id="h-01030000-20260303"`) — this excludes version-like
-        numbers mentioned incidentally in release-note text.
+        (e.g. `id="h-01030000-20260303"` or `id="h-0102000020260409"`) —
+        this excludes version-like numbers mentioned incidentally in
+        release-note text. The dash separator between version and date is
+        optional: H2D/X1/H2C/H2S still use it, but P2S and X2D publish
+        anchors without the dash.
 
         Returns list of (version, release_date_YYYYMMDD | None) tuples, newest first.
         """
@@ -176,8 +179,9 @@ class FirmwareCheckService:
             if response.status_code != 200:
                 return []
 
-            # Primary: heading anchor ids like id="h-01030000-20260303"
-            anchor_matches = re.findall(r'id="h-(\d{2})(\d{2})(\d{2})(\d{2})-(\d{8})"', response.text)
+            # Primary: heading anchor ids like id="h-01030000-20260303" (dash)
+            # or id="h-0102000020260409" (no dash, P2S/X2D-style).
+            anchor_matches = re.findall(r'id="h-(\d{2})(\d{2})(\d{2})(\d{2})-?(\d{8})"', response.text)
             seen: set[str] = set()
             versions: list[tuple[str, str | None]] = []
             for a, b, c, d, date in anchor_matches:
@@ -190,8 +194,13 @@ class FirmwareCheckService:
             if versions:
                 return versions
 
-            # Fallback: heading text with "XX.XX.XX.XX (YYYYMMDD)"
-            text_matches = re.findall(r"(\d{2}\.\d{2}\.\d{2}\.\d{2})\s*\((\d{8})\)", response.text)
+            # Fallback: heading text with "XX.XX.XX.XX (YYYYMMDD)" —
+            # accept both ASCII "()" and full-width "()" (U+FF08/U+FF09)
+            # which some pages (A1, A1-mini, P2S) use.
+            text_matches = re.findall(
+                r"(\d{2}\.\d{2}\.\d{2}\.\d{2})\s*[(\uff08](\d{8})[)\uff09]",
+                response.text,
+            )
             for v, date in text_matches:
                 if v in seen:
                     continue

+ 35 - 0
backend/app/services/mqtt_smart_plug.py

@@ -490,5 +490,40 @@ class MQTTSmartPlugService:
                 self.connected = False
 
 
+def subscribe_plug_to_mqtt(service: "MQTTSmartPlugService", plug: Any) -> list[str]:
+    """Resolve per-type topic fields on a SmartPlug and register it with the service.
+
+    The SmartPlug model carries both a legacy single `mqtt_topic` and newer
+    per-type `mqtt_{power,energy,state}_topic` fields. Three code paths used
+    to open-code this resolution (startup restore, create, update) and they
+    drifted — the startup path skipped plugs that only had per-type topics
+    set, leaving them unsubscribed after every restart (#1010). Funnelling
+    all three through this helper keeps them in sync.
+
+    Returns the list of topics subscribed (empty if nothing was configured).
+    """
+    power_topic = plug.mqtt_power_topic or plug.mqtt_topic
+    energy_topic = plug.mqtt_energy_topic or plug.mqtt_topic
+    state_topic = plug.mqtt_state_topic or plug.mqtt_topic
+
+    if not (power_topic or energy_topic or state_topic):
+        return []
+
+    legacy_mult = plug.mqtt_multiplier or 1.0
+    service.subscribe(
+        plug_id=plug.id,
+        power_topic=power_topic,
+        power_path=plug.mqtt_power_path,
+        power_multiplier=plug.mqtt_power_multiplier or legacy_mult,
+        energy_topic=energy_topic,
+        energy_path=plug.mqtt_energy_path,
+        energy_multiplier=plug.mqtt_energy_multiplier or legacy_mult,
+        state_topic=state_topic,
+        state_path=plug.mqtt_state_path,
+        state_on_value=plug.mqtt_state_on_value,
+    )
+    return [t for t in {power_topic, energy_topic, state_topic} if t]
+
+
 # Global instance
 mqtt_smart_plug_service = MQTTSmartPlugService()

+ 4 - 0
backend/app/services/obico_detection.py

@@ -253,6 +253,10 @@ class ObicoDetectionService:
         score = state.update(current_p)
         verdict = classify(score, settings["sensitivity"])
         self._last_class[printer_id] = verdict
+        # A successful capture + ML call clears any transient error from previous
+        # polls (typical case: cold-start RTSP timeout on first frame after startup,
+        # followed by healthy polls that otherwise leave the banner stuck in the UI).
+        self._last_error = None
 
         # Log every non-safe sample — safe samples would flood history
         if verdict != "safe" or detections:

+ 140 - 0
backend/tests/integration/test_print_queue_api.py

@@ -1482,6 +1482,146 @@ class TestAbortedStatusNormalisation:
 
         assert item.status == "completed"
 
+    # ========================================================================
+    # Library file usage tracking on print completion (#1008)
+    #
+    # These exercise the _bump_library_file_usage_if_completed helper directly
+    # rather than invoking the whole on_print_complete handler — that path
+    # spawns background asyncio tasks (notifications, MQTT relay, smart-plug)
+    # that are expensive to mock and have nothing to do with the bump logic.
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bump_library_file_usage_on_completed(self, printer_factory, db_session):
+        """Successful completion increments print_count and stamps last_printed_at."""
+        from datetime import datetime, timezone
+
+        from backend.app.main import _bump_library_file_usage_if_completed
+        from backend.app.models.library import LibraryFile
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        lib_file = LibraryFile(
+            filename="benchy.gcode.3mf",
+            file_path="/data/library/benchy.gcode.3mf",
+            file_type="gcode.3mf",
+            file_size=1024,
+            print_count=0,
+            last_printed_at=None,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        item = PrintQueueItem(
+            printer_id=printer.id,
+            library_file_id=lib_file.id,
+            status="printing",
+            position=1,
+        )
+
+        before = datetime.now(timezone.utc).replace(tzinfo=None)
+        await _bump_library_file_usage_if_completed(db_session, item, "completed")
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        assert lib_file.print_count == 1
+        assert lib_file.last_printed_at is not None
+        assert lib_file.last_printed_at >= before
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bump_library_file_usage_repeated_prints_increment_count(self, printer_factory, db_session):
+        """Each successful completion bumps print_count cumulatively."""
+        from backend.app.main import _bump_library_file_usage_if_completed
+        from backend.app.models.library import LibraryFile
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        lib_file = LibraryFile(
+            filename="repeat.gcode.3mf",
+            file_path="/data/library/repeat.gcode.3mf",
+            file_type="gcode.3mf",
+            file_size=1024,
+            print_count=0,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        item = PrintQueueItem(
+            printer_id=printer.id,
+            library_file_id=lib_file.id,
+            status="printing",
+            position=1,
+        )
+
+        for _ in range(3):
+            await _bump_library_file_usage_if_completed(db_session, item, "completed")
+
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+        assert lib_file.print_count == 3
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("terminal_status", ["failed", "cancelled"])
+    async def test_bump_library_file_usage_skips_non_completed(self, printer_factory, db_session, terminal_status):
+        """Failed and cancelled prints must NOT count as usage."""
+        from backend.app.main import _bump_library_file_usage_if_completed
+        from backend.app.models.library import LibraryFile
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        lib_file = LibraryFile(
+            filename="broken.gcode.3mf",
+            file_path="/data/library/broken.gcode.3mf",
+            file_type="gcode.3mf",
+            file_size=1024,
+            print_count=0,
+            last_printed_at=None,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        item = PrintQueueItem(
+            printer_id=printer.id,
+            library_file_id=lib_file.id,
+            status="printing",
+            position=1,
+        )
+
+        await _bump_library_file_usage_if_completed(db_session, item, terminal_status)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        assert lib_file.print_count == 0
+        assert lib_file.last_printed_at is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bump_library_file_usage_skips_when_no_library_file_id(
+        self, printer_factory, archive_factory, db_session
+    ):
+        """Queue items without library_file_id (e.g. archive reprints) are a no-op."""
+        from backend.app.main import _bump_library_file_usage_if_completed
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        archive = await archive_factory()
+        item = PrintQueueItem(
+            printer_id=printer.id,
+            library_file_id=None,
+            archive_id=archive.id,
+            status="printing",
+            position=1,
+        )
+
+        # Must not raise.
+        await _bump_library_file_usage_if_completed(db_session, item, "completed")
+
     # ========================================================================
     # Batch quantity tests
     # ========================================================================

+ 133 - 0
backend/tests/unit/services/test_archive_copy.py

@@ -0,0 +1,133 @@
+"""
+Tests for the 3MF archive copy path.
+
+Regression guards for #1032 where large 3MF files were silently truncated
+during archiving on Raspberry Pi OS / armv7l, leaving the archive row in
+place but the on-disk file no longer a valid ZIP.
+"""
+
+import io
+import logging
+import os
+import zipfile
+from pathlib import Path
+
+import pytest
+
+from backend.app.services.archive import ThreeMFParser, _copy_and_fsync
+
+
+def _make_3mf(path: Path, payload_size: int = 0) -> None:
+    """Write a minimal valid 3MF (ZIP) file with an optional large payload."""
+    with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
+        zf.writestr("Metadata/slice_info.config", "<config/>")
+        if payload_size:
+            # Uncompressible payload forces real bytes on disk.
+            zf.writestr("blob.bin", os.urandom(payload_size))
+
+
+class TestCopyAndFsync:
+    def test_copies_small_file_byte_for_byte(self, tmp_path: Path) -> None:
+        src = tmp_path / "src.bin"
+        dst = tmp_path / "dst.bin"
+        src.write_bytes(b"hello world")
+
+        _copy_and_fsync(src, dst)
+
+        assert dst.read_bytes() == b"hello world"
+
+    def test_copies_large_file_byte_for_byte(self, tmp_path: Path) -> None:
+        """Spans multiple 1 MiB chunks to exercise the copy loop."""
+        src = tmp_path / "src.bin"
+        dst = tmp_path / "dst.bin"
+        payload = os.urandom(5 * 1024 * 1024 + 123)  # 5 MiB + change
+        src.write_bytes(payload)
+
+        _copy_and_fsync(src, dst)
+
+        assert dst.stat().st_size == len(payload)
+        assert dst.read_bytes() == payload
+
+    def test_preserves_mtime_via_copystat(self, tmp_path: Path) -> None:
+        src = tmp_path / "src.bin"
+        dst = tmp_path / "dst.bin"
+        src.write_bytes(b"x")
+        os.utime(src, (1_700_000_000, 1_700_000_000))
+
+        _copy_and_fsync(src, dst)
+
+        assert int(dst.stat().st_mtime) == 1_700_000_000
+
+    def test_overwrites_existing_destination(self, tmp_path: Path) -> None:
+        src = tmp_path / "src.bin"
+        dst = tmp_path / "dst.bin"
+        src.write_bytes(b"new")
+        dst.write_bytes(b"old old old")
+
+        _copy_and_fsync(src, dst)
+
+        assert dst.read_bytes() == b"new"
+
+    def test_produces_valid_zip_on_3mf(self, tmp_path: Path) -> None:
+        """The whole point of #1032: copy of a valid 3MF stays a valid ZIP."""
+        src = tmp_path / "src.3mf"
+        dst = tmp_path / "dst.3mf"
+        _make_3mf(src, payload_size=2 * 1024 * 1024)  # 2 MiB, multi-chunk
+        assert zipfile.is_zipfile(src)
+
+        _copy_and_fsync(src, dst)
+
+        assert zipfile.is_zipfile(dst)
+
+
+class TestThreeMFParserErrorVisibility:
+    def test_parse_logs_warning_on_corrupted_zip(
+        self,
+        tmp_path: Path,
+        caplog: pytest.LogCaptureFixture,
+    ) -> None:
+        """Silent `except Exception: pass` was how #1032 escaped detection;
+        parse() must now surface the failure at WARNING."""
+        corrupted = tmp_path / "bad.3mf"
+        corrupted.write_bytes(b"not a zip")
+
+        with caplog.at_level(logging.WARNING, logger="backend.app.services.archive"):
+            result = ThreeMFParser(corrupted).parse()
+
+        assert result == {}
+        assert any("failed to parse" in rec.message and str(corrupted) in rec.message for rec in caplog.records), (
+            "Expected a WARNING mentioning the failed parse and file path"
+        )
+
+    def test_parse_returns_partial_metadata_without_raising(
+        self,
+        tmp_path: Path,
+    ) -> None:
+        """A valid-but-minimal 3MF must still parse without raising."""
+        p = tmp_path / "ok.3mf"
+        with zipfile.ZipFile(p, "w") as zf:
+            zf.writestr("Metadata/slice_info.config", "<config/>")
+
+        result = ThreeMFParser(p).parse()
+
+        # No assertions about which keys are present — just that it didn't blow up.
+        assert isinstance(result, dict)
+
+
+class TestZipFileSentinel:
+    """Sanity check the sentinel the archive pipeline relies on."""
+
+    def test_is_zipfile_on_truncated_zip_returns_false(self, tmp_path: Path) -> None:
+        """Truncating a valid ZIP mid-stream must flip is_zipfile() to False.
+        This is the exact post-condition archive_print now trusts."""
+        src = tmp_path / "src.3mf"
+        _make_3mf(src, payload_size=1024 * 1024)
+        full = src.read_bytes()
+        assert zipfile.is_zipfile(io.BytesIO(full))
+
+        truncated = tmp_path / "truncated.3mf"
+        # Strip the trailing end-of-central-directory record — exactly what a
+        # short sendfile return would leave behind.
+        truncated.write_bytes(full[: len(full) // 2])
+
+        assert not zipfile.is_zipfile(truncated)

+ 56 - 0
backend/tests/unit/services/test_bambu_ftp.py

@@ -819,6 +819,62 @@ class TestAsyncWrappers:
         )
         assert result is False
 
+    @pytest.mark.asyncio
+    async def test_download_file_async_timeout_waits_for_slow_zombie(self, tmp_path, monkeypatch):
+        """A zombie that completes within the 30s grace window is salvaged.
+
+        Regression for #1014: on slow WiFi, download_to_file can overshoot the
+        user's ftp_timeout by 10–30 s without being stuck. The old fixed 0.5 s
+        post-timeout sleep was too short — it gave up and started attempt 2
+        while attempt 1's zombie thread kept running, and by the time the zombie
+        wrote the file to disk with a success flag, attempt 2 had already
+        reported failure (its own completion dict was still False). The async
+        wrapper now waits up to min(timeout, 30 s) for the worker thread to
+        finish before returning, so a slow-but-progressing download salvages.
+        """
+        from backend.app.services import bambu_ftp
+
+        bambu_ftp.BambuFTPClient._mode_cache.pop("127.0.0.1", None)
+
+        local = tmp_path / "slow_zombie.bin"
+        expected_content = b"finished during grace window"
+
+        class FakeClient:
+            """Mimics a slow FTP: wait_for gives up at 1.0 s but RETR takes
+            1.5 s total. Old 0.5 s fixed sleep would have bailed (0.5 < 0.5
+            extra); new grace = max(min(1.0, 30), 0.5) = 1.0 s covers the
+            remaining 0.5 s so salvage succeeds."""
+
+            def __init__(self, *args, **kwargs):
+                pass
+
+            def connect(self):
+                return True
+
+            def download_to_file(self, remote_path, local_path):
+                time.sleep(1.5)  # wait_for times out at 1.0 s; zombie finishes 0.5 s later
+                local_path.write_bytes(expected_content)
+                return True
+
+            def disconnect(self):
+                pass
+
+        monkeypatch.setattr(bambu_ftp, "BambuFTPClient", FakeClient)
+        monkeypatch.setattr(FakeClient, "_mode_cache", {}, raising=False)
+        monkeypatch.setattr(FakeClient, "A1_MODELS", set(), raising=False)
+        monkeypatch.setattr(FakeClient, "cache_mode", staticmethod(lambda ip, mode: None), raising=False)
+
+        result = await download_file_async(
+            "127.0.0.1",
+            "12345678",
+            "/cache/slow_zombie.bin",
+            local,
+            timeout=1.0,
+            printer_model="X1C",
+        )
+        assert result is True
+        assert local.read_bytes() == expected_content
+
     @pytest.mark.asyncio
     async def test_download_file_try_paths_first_succeeds(self, patch_ftp_port, tmp_path):
         """download_file_try_paths_async succeeds on first path."""

+ 17 - 5
backend/tests/unit/services/test_bambu_mqtt.py

@@ -3480,17 +3480,29 @@ class TestStartPrintUniqueIdentityFields:
         assert first["project_id"] != second["project_id"]
 
     def test_submission_id_is_numeric_string(self, mqtt_client):
-        """ID format: digits-only string (epoch millis). Studio uses cloud
-        task IDs that are also numeric-looking strings; the DB column is
-        VARCHAR(64) and Bambuddy's own subtask_id parser treats '0'/'' as
-        absent — any valid digit string that isn't '0' is fine."""
+        """ID format: digits-only string. Studio uses cloud task IDs that are
+        also numeric-looking strings; the DB column is VARCHAR(64) and
+        Bambuddy's own subtask_id parser treats '0'/'' as absent — any valid
+        digit string that isn't '0' is fine."""
         mqtt_client.start_print("test.3mf")
         cmd = self._get_published_command(mqtt_client)
         assert cmd["task_id"].isdigit()
         assert int(cmd["task_id"]) > 0
-        # Must fit in VARCHAR(64); epoch-ms is 13 digits
         assert len(cmd["task_id"]) <= 64
 
+    def test_submission_id_fits_signed_int32(self, mqtt_client):
+        """Regression for #1042: P1S firmware clamps oversized task identity
+        fields to signed int32 max (2**31-1 = 2147483647). If we send raw
+        epoch-ms (~1.7e12), the printer sees a saturated constant on every
+        submission and treats fresh dispatches as continuations of the last
+        FAILED job — never leaves IDLE. Keep below 2**31.
+        """
+        mqtt_client.start_print("test.3mf")
+        cmd = self._get_published_command(mqtt_client)
+        assert int(cmd["task_id"]) < 2**31
+        assert int(cmd["project_id"]) < 2**31
+        assert int(cmd["subtask_id"]) < 2**31
+
     def test_unrelated_payload_fields_untouched(self, mqtt_client):
         """Regression guard: fix only touches identity fields; everything else
         (sequence_id, command verb, calibration defaults, profile_id) must be

+ 145 - 0
backend/tests/unit/services/test_mqtt_smart_plug_subscribe.py

@@ -0,0 +1,145 @@
+"""
+Tests for subscribe_plug_to_mqtt — the shared helper that resolves a
+SmartPlug row's per-type topic fields (with legacy fallback) and calls
+MQTTSmartPlugService.subscribe().
+
+Regression guard for #1010, where the startup-restore code path had
+drifted from the create/update routes: it only looked at the legacy
+`mqtt_topic` field and silently skipped plugs whose topics were set
+only in the newer per-type fields, so the MQTT smart-plug subscription
+was lost on every Bambuddy restart until the user re-saved the plug.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
+
+
+def _plug(**overrides):
+    """Build a SmartPlug-shaped record. All fields default to None/defaults."""
+    defaults = {
+        "id": 1,
+        "mqtt_topic": None,
+        "mqtt_power_topic": None,
+        "mqtt_power_path": None,
+        "mqtt_power_multiplier": None,
+        "mqtt_energy_topic": None,
+        "mqtt_energy_path": None,
+        "mqtt_energy_multiplier": None,
+        "mqtt_state_topic": None,
+        "mqtt_state_path": None,
+        "mqtt_state_on_value": None,
+        "mqtt_multiplier": None,
+    }
+    defaults.update(overrides)
+    return SimpleNamespace(**defaults)
+
+
+def test_per_type_topics_restored_without_legacy_mqtt_topic():
+    """#1010: plug configured only with per-type topics must still subscribe."""
+    service = MagicMock()
+    plug = _plug(
+        id=42,
+        mqtt_power_topic="shellies/plug-living/power",
+        mqtt_power_path="value",
+        mqtt_state_topic="shellies/plug-living/relay/0",
+        mqtt_state_on_value="on",
+    )
+
+    topics = subscribe_plug_to_mqtt(service, plug)
+
+    service.subscribe.assert_called_once()
+    kwargs = service.subscribe.call_args.kwargs
+    assert kwargs["plug_id"] == 42
+    assert kwargs["power_topic"] == "shellies/plug-living/power"
+    assert kwargs["power_path"] == "value"
+    assert kwargs["state_topic"] == "shellies/plug-living/relay/0"
+    assert kwargs["state_on_value"] == "on"
+    # energy wasn't configured, so no per-type topic
+    assert kwargs["energy_topic"] is None
+    assert set(topics) == {"shellies/plug-living/power", "shellies/plug-living/relay/0"}
+
+
+def test_legacy_single_topic_falls_back_for_all_data_types():
+    """Backward-compat: a plug with only the legacy mqtt_topic must still work."""
+    service = MagicMock()
+    plug = _plug(
+        id=7,
+        mqtt_topic="zigbee2mqtt/shelly-office",
+        mqtt_power_path="power",
+        mqtt_energy_path="energy",
+        mqtt_state_path="state",
+        mqtt_state_on_value="ON",
+        mqtt_multiplier=0.001,  # legacy
+    )
+
+    topics = subscribe_plug_to_mqtt(service, plug)
+
+    kwargs = service.subscribe.call_args.kwargs
+    assert kwargs["power_topic"] == "zigbee2mqtt/shelly-office"
+    assert kwargs["energy_topic"] == "zigbee2mqtt/shelly-office"
+    assert kwargs["state_topic"] == "zigbee2mqtt/shelly-office"
+    # Legacy multiplier flows through for both power and energy.
+    assert kwargs["power_multiplier"] == 0.001
+    assert kwargs["energy_multiplier"] == 0.001
+    assert topics == ["zigbee2mqtt/shelly-office"]
+
+
+def test_per_type_multipliers_override_legacy():
+    service = MagicMock()
+    plug = _plug(
+        mqtt_power_topic="t/power",
+        mqtt_power_multiplier=0.5,
+        mqtt_energy_topic="t/energy",
+        mqtt_energy_multiplier=0.25,
+        mqtt_multiplier=9.0,  # should be overridden by per-type values
+    )
+
+    subscribe_plug_to_mqtt(service, plug)
+
+    kwargs = service.subscribe.call_args.kwargs
+    assert kwargs["power_multiplier"] == 0.5
+    assert kwargs["energy_multiplier"] == 0.25
+
+
+def test_per_type_topics_beat_legacy_topic_when_both_set():
+    """If both legacy and per-type topic are set, per-type wins."""
+    service = MagicMock()
+    plug = _plug(
+        mqtt_topic="old/topic",
+        mqtt_power_topic="new/power",
+        mqtt_energy_topic="new/energy",
+    )
+
+    subscribe_plug_to_mqtt(service, plug)
+
+    kwargs = service.subscribe.call_args.kwargs
+    assert kwargs["power_topic"] == "new/power"
+    assert kwargs["energy_topic"] == "new/energy"
+    # state has no per-type topic set, so it falls back to legacy
+    assert kwargs["state_topic"] == "old/topic"
+
+
+def test_no_topics_configured_skips_subscribe():
+    """Nothing to subscribe to means the service is not touched."""
+    service = MagicMock()
+    plug = _plug(id=99)  # all fields None
+
+    topics = subscribe_plug_to_mqtt(service, plug)
+
+    service.subscribe.assert_not_called()
+    assert topics == []
+
+
+def test_returns_unique_topic_list_when_same_topic_used_for_multiple_types():
+    service = MagicMock()
+    plug = _plug(
+        mqtt_power_topic="shared/topic",
+        mqtt_energy_topic="shared/topic",
+        mqtt_state_topic="shared/topic",
+    )
+
+    topics = subscribe_plug_to_mqtt(service, plug)
+
+    assert topics == ["shared/topic"]

+ 47 - 0
backend/tests/unit/test_firmware_versions.py

@@ -50,6 +50,53 @@ async def test_wiki_extraction_returns_empty_for_unknown_api_key():
     assert await svc._fetch_all_versions_from_wiki("no-such-key") == []
 
 
+# P2S and X2D wiki pages publish anchor ids without a dash between the
+# version bytes and the date (e.g. h-0102000020260409). Regression for #1030
+# where the anchor regex required a dash and silently returned no versions,
+# causing the UI to fall back to the stale download-page "Latest" value.
+P2S_NODASH_ANCHOR_SAMPLE = """
+<h2 id="h-0102000020260409" class="toc-header">01.02.00.00(20260409)</h2>
+<h2 id="h-0101030020260209" class="toc-header">01.01.03.00(20260209)</h2>
+<h2 id="h-0101010020251208" class="toc-header">01.01.01.00(20251208)</h2>
+"""
+
+
+@pytest.mark.asyncio
+async def test_wiki_extraction_accepts_nodash_anchors():
+    """P2S/X2D anchors concatenate version+date with no dash — must still parse."""
+    svc = FirmwareCheckService()
+    mock_resp = AsyncMock()
+    mock_resp.status_code = 200
+    mock_resp.text = P2S_NODASH_ANCHOR_SAMPLE
+    with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
+        versions = await svc._fetch_all_versions_from_wiki("p2s")
+
+    assert [v for v, _ in versions] == ["01.02.00.00", "01.01.03.00", "01.01.01.00"]
+    assert versions[0][1] == "20260409"
+
+
+# A1, A1-mini and P2S pages render dates in full-width parens (YYYYMMDD)
+# rather than ASCII parens (YYYYMMDD). Pages without version-anchors fall
+# through to the text-based regex, so it must accept both paren styles.
+FULLWIDTH_PAREN_FALLBACK_SAMPLE = """
+<h2>01.04.00.01 (20260401)</h2>
+<h2>01.03.00.00 (20260101)</h2>
+"""
+
+
+@pytest.mark.asyncio
+async def test_wiki_extraction_fallback_accepts_fullwidth_parens():
+    svc = FirmwareCheckService()
+    mock_resp = AsyncMock()
+    mock_resp.status_code = 200
+    mock_resp.text = FULLWIDTH_PAREN_FALLBACK_SAMPLE
+    with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
+        versions = await svc._fetch_all_versions_from_wiki("a1")
+
+    assert [v for v, _ in versions] == ["01.04.00.01", "01.03.00.00"]
+    assert versions[0][1] == "20260401"
+
+
 @pytest.mark.asyncio
 async def test_get_available_versions_merges_sources():
     """

+ 39 - 0
backend/tests/unit/test_obico_detection.py

@@ -430,3 +430,42 @@ class TestCheckPrinterUsesCachedFrameUrl:
         mock_client.get.assert_not_called()
         assert svc._last_error is not None
         assert "external_url" in svc._last_error
+
+    @pytest.mark.asyncio
+    async def test_successful_cycle_clears_previous_error(self):
+        """A cold-start RTSP timeout sets _last_error; the next successful poll must clear it.
+
+        Regression for #172: the Status card banner ("Failed to capture snapshot for
+        printer 1") stuck around after a one-off cold-start failure even though every
+        subsequent poll captured + detected successfully.
+        """
+        svc = ObicoDetectionService()
+        settings = {
+            "enabled": True,
+            "ml_url": "http://obico:3333",
+            "sensitivity": "medium",
+            "action": "notify",
+            "poll_interval": 10,
+            "enabled_printers": None,
+            "external_url": "http://bambuddy:8000",
+        }
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        # Seed a prior transient error, as would be left by a cold-start capture timeout.
+        svc._last_error = "Failed to capture snapshot for printer 1"
+
+        mock_response = MagicMock()
+        mock_response.json.return_value = {"detections": []}
+        mock_response.raise_for_status = MagicMock()
+        mock_client = MagicMock()
+        mock_client.get = AsyncMock(return_value=mock_response)
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+
+        with (
+            patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
+            patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
+        ):
+            await svc._check_printer(1, status, settings)
+
+        assert svc._last_error is None

+ 86 - 0
frontend/src/__tests__/hooks/useCameraStreamToken.test.ts

@@ -0,0 +1,86 @@
+/**
+ * Unit tests for rewriteMediaSrcWithToken — the DOM walker that retrofits a
+ * camera stream token onto <img>/<video> src URLs that rendered before the
+ * token arrived (regression guard for the post-login blank-thumbnails bug).
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { rewriteMediaSrcWithToken } from '../../hooks/useCameraStreamToken';
+
+describe('rewriteMediaSrcWithToken', () => {
+  let root: HTMLDivElement;
+
+  beforeEach(() => {
+    root = document.createElement('div');
+    document.body.appendChild(root);
+  });
+
+  afterEach(() => {
+    root.remove();
+  });
+
+  const addImg = (src: string) => {
+    const img = document.createElement('img');
+    img.setAttribute('src', src);
+    root.appendChild(img);
+    return img;
+  };
+
+  const addVideo = (src: string) => {
+    const v = document.createElement('video');
+    v.setAttribute('src', src);
+    root.appendChild(v);
+    return v;
+  };
+
+  it('appends token to /api/v1/ images that have no query string', () => {
+    const img = addImg('/api/v1/library/files/42/thumbnail');
+    const count = rewriteMediaSrcWithToken(root, 'abc123');
+    expect(count).toBe(1);
+    expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=abc123');
+  });
+
+  it('appends token to URLs that already have a query string using & separator', () => {
+    const img = addImg('/api/v1/archives/5/thumbnail?v=1700000000000');
+    rewriteMediaSrcWithToken(root, 'abc123');
+    expect(img.getAttribute('src')).toBe('/api/v1/archives/5/thumbnail?v=1700000000000&token=abc123');
+  });
+
+  it('leaves images alone that already carry the current token', () => {
+    const img = addImg('/api/v1/library/files/42/thumbnail?token=abc123');
+    const count = rewriteMediaSrcWithToken(root, 'abc123');
+    expect(count).toBe(0);
+    expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=abc123');
+  });
+
+  it('replaces a stale token with the current one', () => {
+    const img = addImg('/api/v1/library/files/42/thumbnail?token=OLD');
+    rewriteMediaSrcWithToken(root, 'NEW');
+    expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=NEW');
+  });
+
+  it('replaces a stale token that sits in the middle of the query string', () => {
+    const img = addImg('/api/v1/archives/5/thumbnail?token=OLD&v=1700000000000');
+    rewriteMediaSrcWithToken(root, 'NEW');
+    // Old token stripped, v preserved, new token appended.
+    expect(img.getAttribute('src')).toBe('/api/v1/archives/5/thumbnail?v=1700000000000&token=NEW');
+  });
+
+  it('ignores images that do not point at /api/v1/', () => {
+    const img = addImg('https://cdn.example.com/static/logo.png');
+    rewriteMediaSrcWithToken(root, 'abc123');
+    expect(img.getAttribute('src')).toBe('https://cdn.example.com/static/logo.png');
+  });
+
+  it('updates <video> elements as well', () => {
+    const v = addVideo('/api/v1/printers/7/camera/stream?fps=10');
+    rewriteMediaSrcWithToken(root, 'abc123');
+    expect(v.getAttribute('src')).toBe('/api/v1/printers/7/camera/stream?fps=10&token=abc123');
+  });
+
+  it('url-encodes tokens containing special characters', () => {
+    const img = addImg('/api/v1/library/files/42/thumbnail');
+    rewriteMediaSrcWithToken(root, 'a b/c=d');
+    expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=a%20b%2Fc%3Dd');
+  });
+});

+ 74 - 1
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -2,7 +2,7 @@
  * Tests for the FileManagerPage component.
  */
 
-import { describe, it, expect, beforeEach } from 'vitest';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
 import { screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
@@ -859,4 +859,77 @@ describe('FileManagerPage', () => {
       expect(screen.getByText('testuser')).toBeInTheDocument();
     });
   });
+
+  describe('folder tree collapse preference (#996)', () => {
+    // localStorage is globally mocked in setup.ts (returns undefined by default),
+    // so we program each test's getItem return value explicitly.
+    const getItemMock = localStorage.getItem as ReturnType<typeof vi.fn>;
+    const setItemMock = localStorage.setItem as ReturnType<typeof vi.fn>;
+
+    beforeEach(() => {
+      getItemMock.mockReset();
+      setItemMock.mockReset();
+    });
+
+    it('defaults to expanded (nested folders visible) when library-collapse-folders is unset', async () => {
+      getItemMock.mockReturnValue(null);
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Functional Parts')).toBeInTheDocument();
+      });
+      expect(screen.getByText('Brackets')).toBeInTheDocument();
+    });
+
+    it('honors library-collapse-folders=true on load (nested folders hidden)', async () => {
+      getItemMock.mockImplementation((key: string) =>
+        key === 'library-collapse-folders' ? 'true' : null
+      );
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Functional Parts')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Brackets')).not.toBeInTheDocument();
+    });
+
+    it('collapses nested folders and persists preference when Collapse is clicked', async () => {
+      getItemMock.mockReturnValue(null);
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Brackets')).toBeInTheDocument();
+      });
+
+      // The Collapse button sits next to Wrap in the sidebar header.
+      // Its text content is "Collapse" (from fileManager.collapse).
+      await user.click(screen.getByRole('button', { name: 'Collapse' }));
+
+      await waitFor(() => {
+        expect(screen.queryByText('Brackets')).not.toBeInTheDocument();
+      });
+      expect(setItemMock).toHaveBeenCalledWith('library-collapse-folders', 'true');
+    });
+
+    it('re-expands nested folders and persists preference when Collapse is toggled off', async () => {
+      getItemMock.mockImplementation((key: string) =>
+        key === 'library-collapse-folders' ? 'true' : null
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Functional Parts')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Brackets')).not.toBeInTheDocument();
+
+      await user.click(screen.getByRole('button', { name: 'Collapse' }));
+
+      await waitFor(() => {
+        expect(screen.getByText('Brackets')).toBeInTheDocument();
+      });
+      expect(setItemMock).toHaveBeenCalledWith('library-collapse-folders', 'false');
+    });
+  });
 });

+ 195 - 0
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -45,6 +45,7 @@ const mockPrinters = [
 const mockPrinterStatus = {
   connected: true,
   state: 'IDLE',
+  awaiting_plate_clear: false,
   progress: 0,
   layer_num: 0,
   total_layers: 0,
@@ -61,6 +62,8 @@ const mockPrinterStatus = {
 
 describe('PrintersPage', () => {
   beforeEach(() => {
+    localStorage.removeItem('printerCardSize');
+
     server.use(
       http.get('/api/v1/printers/', () => {
         return HttpResponse.json(mockPrinters);
@@ -68,6 +71,23 @@ describe('PrintersPage', () => {
       http.get('/api/v1/printers/:id/status', () => {
         return HttpResponse.json(mockPrinterStatus);
       }),
+      http.post('/api/v1/printers/:id/clear-plate', () => {
+        return HttpResponse.json({ success: true, message: 'Plate cleared' });
+      }),
+      http.get('/api/v1/settings/', () => {
+        return HttpResponse.json({
+          auto_archive: true,
+          save_thumbnails: true,
+          capture_finish_photo: true,
+          default_filament_cost: 25.0,
+          currency: 'USD',
+          ams_humidity_good: 40,
+          ams_humidity_fair: 60,
+          ams_temp_good: 30,
+          ams_temp_fair: 35,
+          require_plate_clear: true,
+        });
+      }),
       http.get('/api/v1/queue/', () => {
         return HttpResponse.json([]);
       })
@@ -173,6 +193,181 @@ describe('PrintersPage', () => {
       const buttons = screen.getAllByRole('button');
       expect(buttons.length).toBeGreaterThan(0);
     });
+
+    it('shows plate clear status and action on finished printers when not cleared', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: true });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);
+      });
+
+      expect(screen.getAllByRole('button', { name: 'Mark plate as cleared' }).length).toBeGreaterThan(0);
+    });
+
+    it('shows plate clear status and action on failed printers when not cleared', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrinterStatus, state: 'FAILED', awaiting_plate_clear: true });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);
+      });
+
+      expect(screen.getAllByRole('button', { name: 'Mark plate as cleared' }).length).toBeGreaterThan(0);
+    });
+
+    it('keeps the clear action available when an idle printer is still awaiting acknowledgment', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrinterStatus, state: 'IDLE', awaiting_plate_clear: true });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);
+      });
+
+      expect(screen.getAllByRole('button', { name: 'Mark plate as cleared' }).length).toBeGreaterThan(0);
+    });
+
+    it('updates the plate clear status after using the printer card action', async () => {
+      let awaitingPlateClear = true;
+
+      server.use(
+        http.get('/api/v1/printers/', () => {
+          return HttpResponse.json([mockPrinters[0]]);
+        }),
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: awaitingPlateClear });
+        }),
+        http.post('/api/v1/printers/:id/clear-plate', () => {
+          awaitingPlateClear = false;
+          return HttpResponse.json({ success: true, message: 'Plate cleared' });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);
+      });
+
+      fireEvent.click(screen.getAllByRole('button', { name: 'Mark plate as cleared' })[0]);
+
+      await waitFor(() => {
+        expect(screen.queryByText('Plate not Clear')).not.toBeInTheDocument();
+      });
+
+      expect(screen.getAllByText('Plate Clear').length).toBeGreaterThan(0);
+    });
+
+    it('shows an icon-only plate clear action in small card view', async () => {
+      let awaitingPlateClear = true;
+
+      server.use(
+        http.get('/api/v1/printers/', () => {
+          return HttpResponse.json([mockPrinters[0]]);
+        }),
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: awaitingPlateClear });
+        }),
+        http.post('/api/v1/printers/:id/clear-plate', () => {
+          awaitingPlateClear = false;
+          return HttpResponse.json({ success: true, message: 'Plate cleared' });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'S' }));
+
+      await waitFor(() => {
+        expect(screen.queryByText('Mark plate as cleared')).not.toBeInTheDocument();
+      });
+
+      const clearButton = screen.getByRole('button', { name: 'Mark plate as cleared' });
+
+      fireEvent.click(clearButton);
+
+      await waitFor(() => {
+        expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();
+      });
+    });
+
+    it('shows plate clear status but no action while idle', async () => {
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Plate Clear').length).toBeGreaterThan(0);
+      });
+
+      expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();
+    });
+
+    it('shows plate in use status while printing and hides the clear action', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrinterStatus, state: 'RUNNING', awaiting_plate_clear: false });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Plate in Use').length).toBeGreaterThan(0);
+      });
+
+      expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();
+    });
+
+    it('hides plate status and action when plate-clear confirmation is disabled', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({
+            auto_archive: true,
+            save_thumbnails: true,
+            capture_finish_photo: true,
+            default_filament_cost: 25.0,
+            currency: 'USD',
+            ams_humidity_good: 40,
+            ams_humidity_fair: 60,
+            ams_temp_good: 30,
+            ams_temp_fair: 35,
+            require_plate_clear: false,
+          });
+        }),
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: true });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      expect(screen.queryByText('Plate not Clear')).not.toBeInTheDocument();
+      expect(screen.queryByText('Plate Clear')).not.toBeInTheDocument();
+      expect(screen.queryByText('Plate in Use')).not.toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();
+    });
   });
 
   describe('disabled printer', () => {

+ 1 - 1
frontend/src/components/ColorCatalogSettings.tsx

@@ -13,7 +13,7 @@ export function ColorCatalogSettings() {
   const [catalog, setCatalog] = useState<ColorCatalogEntry[]>([]);
   const [loading, setLoading] = useState(true);
   const [search, setSearch] = useState('');
-  const [filterManufacturer, setFilterManufacturer] = useState<string>('Bambu Lab');
+  const [filterManufacturer, setFilterManufacturer] = useState<string>('');
   const fileInputRef = useRef<HTMLInputElement>(null);
 
   // Add/Edit form state

+ 51 - 0
frontend/src/components/icons/PlateClearedIcon.tsx

@@ -0,0 +1,51 @@
+interface PlateClearedIconProps {
+  className?: string;
+}
+
+export function PlateClearedIcon({ className = "w-4 h-4" }: PlateClearedIconProps) {
+  return (
+    <svg
+      viewBox="0 0 1945 1370"
+      fill="none"
+      className={className}
+      aria-hidden="true"
+    >
+      <g transform="translate(-754.293 -471.685)">
+        <g transform="translate(0.18191 255.976)">
+          <g transform="matrix(1.05469 0 0 0.241063 -153.484 1120.2)">
+            <rect
+              x="922.048"
+              y="1195.15"
+              width="1721.5"
+              height="470.135"
+              stroke="currentColor"
+              strokeOpacity="0.99"
+              strokeWidth="168.84"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </g>
+          <g transform="matrix(0.983656 0 0 1.0767 -62.2035 141.539)">
+            <path
+              d="M2741.42,1175.93L895.832,1175.93L1125.16,621.902L2512.09,621.902L2741.42,1175.93Z"
+              fill="currentColor"
+              fillOpacity="0.05"
+              stroke="currentColor"
+              strokeOpacity="0.99"
+              strokeWidth="125.26"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </g>
+        </g>
+        <g transform="translate(21.1916 0.684817)">
+          <path
+            d="M1981.31,567.518C1954.86,567.518 1933.39,546.047 1933.39,519.601C1933.39,493.156 1954.86,471.685 1981.31,471.685L2146.61,471.685C2173.07,471.685 2194.53,493.138 2194.53,519.601L2194.53,688.741C2194.53,715.187 2173.05,736.658 2146.61,736.658C2120.16,736.658 2098.69,715.187 2098.69,688.741L2098.69,567.518L1981.31,567.518ZM2098.69,1252.54C2098.69,1226.1 2120.16,1204.62 2146.61,1204.62C2173.05,1204.62 2194.53,1226.1 2194.53,1252.54L2194.53,1421.68C2194.53,1448.14 2173.07,1469.6 2146.61,1469.6L1981.31,1469.6C1954.86,1469.6 1933.39,1448.13 1933.39,1421.68C1933.39,1395.24 1954.86,1373.76 1981.31,1373.76L2098.69,1373.76L2098.69,1252.54ZM1430.29,1373.76C1456.74,1373.76 1478.21,1395.24 1478.21,1421.68C1478.21,1448.13 1456.74,1469.6 1430.29,1469.6L1264.99,1469.6C1238.53,1469.6 1217.07,1448.14 1217.07,1421.68L1217.07,1252.54C1217.07,1226.1 1238.55,1204.62 1264.99,1204.62C1291.44,1204.62 1312.91,1226.1 1312.91,1252.54L1312.91,1373.76L1430.29,1373.76ZM1312.91,688.741C1312.91,715.187 1291.44,736.658 1264.99,736.658C1238.55,736.658 1217.07,715.187 1217.07,688.741L1217.07,519.601C1217.07,493.138 1238.53,471.685 1264.99,471.685L1430.29,471.685C1456.74,471.685 1478.21,493.156 1478.21,519.601C1478.21,546.047 1456.74,567.518 1430.29,567.518L1312.91,567.518L1312.91,688.741Z"
+            fill="currentColor"
+            fillOpacity="0.99"
+          />
+        </g>
+      </g>
+    </svg>
+  );
+}

+ 40 - 4
frontend/src/hooks/useCameraStreamToken.ts

@@ -3,6 +3,31 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { api, setStreamToken, getStreamToken, withStreamToken } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 
+/**
+ * Walks the DOM and updates every <img>/<video> pointing at /api/v1/ so its
+ * src carries the current stream token. Exported for unit testing; called
+ * from useStreamTokenSync when the token arrives after first render.
+ */
+export function rewriteMediaSrcWithToken(root: ParentNode, token: string): number {
+  const tokenParam = `token=${encodeURIComponent(token)}`;
+  let updated = 0;
+  root
+    .querySelectorAll<HTMLImageElement | HTMLVideoElement>(
+      'img[src*="/api/v1/"], video[src*="/api/v1/"]'
+    )
+    .forEach((el) => {
+      const src = el.getAttribute('src') || '';
+      if (src.includes(tokenParam)) return;
+      const withoutToken = src.replace(/([?&])token=[^&]*(&|$)/, (_m, pre, post) =>
+        post === '&' ? pre : pre === '?' ? '' : ''
+      );
+      const sep = withoutToken.includes('?') ? '&' : '?';
+      el.src = `${withoutToken}${sep}${tokenParam}`;
+      updated += 1;
+    });
+  return updated;
+}
+
 /**
  * Fetches and caches a stream token for <img>/<video> src URLs.
  * Stores the token globally via setStreamToken() so URL generators
@@ -16,20 +41,31 @@ import { useAuth } from '../contexts/AuthContext';
  * Components that need token-protected URLs can import withStreamToken directly.
  */
 export function useStreamTokenSync() {
-  const { authEnabled } = useAuth();
+  const { authEnabled, user } = useAuth();
   const queryClient = useQueryClient();
   const refreshingRef = useRef(false);
 
+  // Key the token by user id so a login/logout invalidates the cache
+  // automatically — otherwise a failed anonymous fetch on the login page
+  // would be cached and never retried after sign-in.
   const { data } = useQuery({
-    queryKey: ['camera-stream-token'],
+    queryKey: ['camera-stream-token', user?.id ?? null],
     queryFn: () => api.getCameraStreamToken(),
-    enabled: authEnabled,
+    enabled: authEnabled ? !!user : true,
     staleTime: 50 * 60 * 1000, // refresh at 50 min (tokens expire at 60)
     refetchInterval: 50 * 60 * 1000,
   });
 
   useEffect(() => {
-    setStreamToken(data?.token ?? null);
+    const newToken = data?.token ?? null;
+    setStreamToken(newToken);
+
+    // Images/videos that rendered before the token arrived have src URLs
+    // without ?token=…; update them in place so they reload with auth.
+    if (newToken) {
+      rewriteMediaSrcWithToken(document, newToken);
+    }
+
     return () => setStreamToken(null);
   }, [data?.token]);
 

+ 10 - 1
frontend/src/i18n/locales/de.ts

@@ -316,6 +316,12 @@ export default {
       connected: 'Verbunden',
       offline: 'Offline',
     },
+    plateStatus: {
+      markCleared: 'Platte als freigegeben markieren',
+      cleared: 'Platte freigegeben',
+      notCleared: 'Platte nicht freigegeben',
+      inUse: 'Platte in Benutzung',
+    },
     // Queue info
     queue: {
       inQueue: '{{count}} Druck in Warteschlange',
@@ -1738,7 +1744,7 @@ export default {
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     plateClear: 'Druckplatte-Bestätigung',
     requirePlateClear: 'Druckplatte-Bestätigung erforderlich',
-    requirePlateClearDescription: 'Wenn aktiviert, wartet der Scheduler auf eine Druckplatte-Bestätigung pro Drucker, bevor geplante Drucke auf Druckern mit abgeschlossenen Aufträgen gestartet werden. Deaktivieren Sie dies für Farm-Workflows, bei denen die Platten physisch überprüft werden.',
+    requirePlateClearDescription: 'Wenn aktiviert, wartet der Scheduler auf eine Druckplatten-Bestätigung pro Drucker, bevor geplante Drucke auf Druckern mit abgeschlossenen Aufträgen gestartet werden. Wenn dies deaktiviert ist, werden auch das Druckplatten-Status-Badge und die Schaltfläche "Druckplatte als freigegeben markieren" auf den Druckerkarten ausgeblendet.',
     gcodeInjection: 'G-code Injection',
     gcodeInjectionDescription: 'Konfigurieren Sie benutzerdefinierten G-code, der am Anfang und/oder Ende von Drucken für Auto-Print-Systeme wie Farmloop, SwapMod, AutoClear und Printflow 3D eingefügt wird. Snippets werden pro Druckermodell konfiguriert und angewendet, wenn "G-code einfügen" bei einem Warteschlangen-Element aktiviert ist.',
     gcodeInjectionNoPrinters: 'Keine Drucker gefunden. Fügen Sie Drucker hinzu, um G-code-Snippets zu konfigurieren.',
@@ -2894,6 +2900,9 @@ export default {
     wrap: 'Umbrechen',
     enableTextWrapping: 'Textumbruch aktivieren',
     disableTextWrapping: 'Textumbruch deaktivieren',
+    collapse: 'Einklappen',
+    collapseFoldersByDefault: 'Ordner standardmäßig einklappen',
+    expandFoldersByDefault: 'Ordner standardmäßig ausklappen',
     dragToResizeTooltip: 'Ziehen zum Ändern der Größe, Doppelklick zum Zurücksetzen',
     searchFiles: 'Dateien suchen...',
     allTypes: 'Alle Typen',

+ 10 - 1
frontend/src/i18n/locales/en.ts

@@ -316,6 +316,12 @@ export default {
       connected: 'Connected',
       offline: 'Offline',
     },
+    plateStatus: {
+      markCleared: 'Mark plate as cleared',
+      cleared: 'Plate Clear',
+      notCleared: 'Plate not Clear',
+      inUse: 'Plate in Use',
+    },
     // Queue info
     queue: {
       inQueue: '{{count}} print in queue',
@@ -1741,7 +1747,7 @@ export default {
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     plateClear: 'Plate-Clear Confirmation',
     requirePlateClear: 'Require plate-clear confirmation',
-    requirePlateClearDescription: 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disable for farm workflows where plates are verified physically.',
+    requirePlateClearDescription: 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disabling this also hides the plate status badge and the "Mark plate as cleared" button on printer cards.',
     gcodeInjection: 'G-code Injection',
     gcodeInjectionDescription: 'Configure custom G-code to inject at the start and/or end of prints for auto-print systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. Snippets are configured per printer model and applied when "Inject G-code" is enabled on a queue item.',
     gcodeInjectionNoPrinters: 'No printers found. Add printers to configure G-code snippets.',
@@ -2897,6 +2903,9 @@ export default {
     wrap: 'Wrap',
     enableTextWrapping: 'Enable text wrapping',
     disableTextWrapping: 'Disable text wrapping',
+    collapse: 'Collapse',
+    collapseFoldersByDefault: 'Collapse folders by default',
+    expandFoldersByDefault: 'Expand folders by default',
     dragToResizeTooltip: 'Drag to resize, double-click to reset',
     searchFiles: 'Search files...',
     allTypes: 'All types',

+ 10 - 1
frontend/src/i18n/locales/fr.ts

@@ -316,6 +316,12 @@ export default {
       connected: 'Connecté',
       offline: 'Hors ligne',
     },
+    plateStatus: {
+      markCleared: 'Marquer le plateau comme dégagé',
+      cleared: 'Plateau dégagé',
+      notCleared: 'Plateau non dégagé',
+      inUse: 'Plateau en cours d\'utilisation',
+    },
     // Queue info
     queue: {
       inQueue: '{{count}} impression en file',
@@ -1687,7 +1693,7 @@ export default {
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     plateClear: 'Confirmation de plateau libre',
     requirePlateClear: 'Exiger la confirmation de plateau libre',
-    requirePlateClearDescription: 'Lorsque activé, le planificateur attend la confirmation de plateau libre par imprimante avant de lancer les impressions en file d\'attente sur les imprimantes ayant terminé. Désactivez pour les workflows de ferme où les plateaux sont vérifiés physiquement.',
+    requirePlateClearDescription: 'Lorsque cette option est activée, le planificateur attend une confirmation de plateau libre par imprimante avant de lancer les impressions en file d\'attente sur les imprimantes ayant terminé. La désactiver masque également le badge d\'état du plateau et le bouton « Marquer le plateau comme dégagé » sur les cartes d\'imprimante.',
     gcodeInjection: 'Injection de G-code',
     gcodeInjectionDescription: 'Configurez du G-code personnalisé à injecter au début et/ou à la fin des impressions pour les systèmes d\'auto-impression comme Farmloop, SwapMod, AutoClear et Printflow 3D. Les snippets sont configurés par modèle d\'imprimante et appliqués lorsque « Injecter le G-code » est activé sur un élément de file d\'attente.',
     gcodeInjectionNoPrinters: 'Aucune imprimante trouvée. Ajoutez des imprimantes pour configurer les snippets G-code.',
@@ -2816,6 +2822,9 @@ export default {
     wrap: 'Retour ligne',
     enableTextWrapping: 'Activer retour ligne',
     disableTextWrapping: 'Désactiver retour ligne',
+    collapse: 'Réduire',
+    collapseFoldersByDefault: 'Réduire les dossiers par défaut',
+    expandFoldersByDefault: 'Développer les dossiers par défaut',
     dragToResizeTooltip: 'Glisser pour redimensionner, double-clic reset',
     searchFiles: 'Chercher fichiers...',
     allTypes: 'Tous types',

+ 10 - 1
frontend/src/i18n/locales/it.ts

@@ -316,6 +316,12 @@ export default {
       connected: 'Connesso',
       offline: 'Offline',
     },
+    plateStatus: {
+      markCleared: 'Segna il piatto come liberato',
+      cleared: 'Piatto libero',
+      notCleared: 'Piatto non libero',
+      inUse: 'Piatto in uso',
+    },
     // Queue info
     queue: {
       inQueue: '{{count}} stampa in coda',
@@ -1687,7 +1693,7 @@ export default {
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     plateClear: 'Conferma piatto libero',
     requirePlateClear: 'Richiedi conferma piatto libero',
-    requirePlateClearDescription: 'Quando abilitato, lo scheduler attende la conferma per stampante che il piatto è libero prima di avviare le stampe in coda su stampanti con lavori completati. Disabilitare per flussi di lavoro in farm dove i piatti vengono verificati fisicamente.',
+    requirePlateClearDescription: 'Quando questa opzione è abilitata, lo scheduler attende una conferma per stampante che il piatto sia libero prima di avviare le stampe in coda su stampanti con lavori completati. Disabilitandola vengono nascosti anche il badge di stato del piatto e il pulsante "Segna il piatto come liberato" sulle schede stampante.',
     gcodeInjection: 'Iniezione G-code',
     gcodeInjectionDescription: 'Configura G-code personalizzato da iniettare all\'inizio e/o alla fine delle stampe per sistemi di stampa automatica come Farmloop, SwapMod, AutoClear e Printflow 3D. Gli snippet sono configurati per modello di stampante e applicati quando "Inietta G-code" è abilitato su un elemento della coda.',
     gcodeInjectionNoPrinters: 'Nessuna stampante trovata. Aggiungi stampanti per configurare gli snippet G-code.',
@@ -2815,6 +2821,9 @@ export default {
     wrap: 'A capo',
     enableTextWrapping: 'Abilita a capo testo',
     disableTextWrapping: 'Disabilita a capo testo',
+    collapse: 'Comprimi',
+    collapseFoldersByDefault: 'Comprimi le cartelle per impostazione predefinita',
+    expandFoldersByDefault: 'Espandi le cartelle per impostazione predefinita',
     dragToResizeTooltip: 'Trascina per ridimensionare, doppio clic per reset',
     searchFiles: 'Cerca file...',
     allTypes: 'Tutti i tipi',

+ 10 - 1
frontend/src/i18n/locales/ja.ts

@@ -315,6 +315,12 @@ export default {
       connected: '接続中',
       offline: 'オフライン',
     },
+    plateStatus: {
+      markCleared: 'プレートをクリア済みにする',
+      cleared: 'プレートクリア済み',
+      notCleared: 'プレート未クリア',
+      inUse: 'プレート使用中',
+    },
     // Queue info
     queue: {
       inQueue: 'キュー内',
@@ -1712,7 +1718,7 @@ export default {
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     plateClear: 'プレートクリア確認',
     requirePlateClear: 'プレートクリア確認を必須にする',
-    requirePlateClearDescription: '有効にすると、スケジューラーは完了したプリンターでキューの印刷を開始する前に、プリンターごとのプレートクリア確認を待ちます。プレートを物理的に確認するファームワークフローでは無効にしてください。',
+    requirePlateClearDescription: '有効にすると、スケジューラーは完了したプリンターでキューの印刷を開始する前に、プリンターごとのプレートクリア確認を待ちます。無効にすると、プリンターカード上のプレート状態バッジと「プレートをクリア済みにする」ボタンも非表示になります。',
     gcodeInjection: 'G-codeインジェクション',
     gcodeInjectionDescription: 'Farmloop、SwapMod、AutoClear、Printflow 3Dなどの自動印刷システム用に、印刷の開始と終了時にカスタムG-codeを挿入します。スニペットはプリンターモデルごとに設定し、キューアイテム��「G-codeを挿入」を有効にすると適用されます。',
     gcodeInjectionNoPrinters: 'プリンターが見つかりません。G-codeスニペットを設定するにはプリンターを追加してください。',
@@ -2854,6 +2860,9 @@ export default {
     wrap: '折り返し',
     enableTextWrapping: 'テキスト折り返しを有効化',
     disableTextWrapping: 'テキスト折り返しを無効化',
+    collapse: '折りたたむ',
+    collapseFoldersByDefault: 'フォルダをデフォルトで折りたたむ',
+    expandFoldersByDefault: 'フォルダをデフォルトで展開する',
     dragToResizeTooltip: 'ドラッグしてリサイズ、ダブルクリックでリセット',
     searchFiles: 'ファイルを検索...',
     allTypes: 'すべての種類',

+ 10 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -316,6 +316,12 @@ export default {
       connected: 'Conectado',
       offline: 'Offline',
     },
+    plateStatus: {
+      markCleared: 'Marcar placa como liberada',
+      cleared: 'Placa liberada',
+      notCleared: 'Placa não liberada',
+      inUse: 'Placa em uso',
+    },
     // Queue info
     queue: {
       inQueue: '{{count}} impressão na fila',
@@ -1687,7 +1693,7 @@ export default {
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     plateClear: 'Confirmação de placa livre',
     requirePlateClear: 'Exigir confirmação de placa livre',
-    requirePlateClearDescription: 'Quando ativado, o agendador aguarda a confirmação de placa livre por impressora antes de iniciar impressões na fila em impressoras com trabalhos concluídos. Desative para fluxos de trabalho de fazenda onde as placas são verificadas fisicamente.',
+    requirePlateClearDescription: 'Quando ativado, o agendador aguarda uma confirmação de placa livre por impressora antes de iniciar impressões na fila em impressoras com trabalhos concluídos. Desativar isso também oculta o indicador de status da placa e o botão "Marcar placa como liberada" nos cartões das impressoras.',
     gcodeInjection: 'Injeção de G-code',
     gcodeInjectionDescription: 'Configure G-code personalizado para injetar no início e/ou no final das impressões para sistemas de impressão automática como Farmloop, SwapMod, AutoClear e Printflow 3D. Os snippets são configurados por modelo de impressora e aplicados quando "Injetar G-code" está ativado em um item da fila.',
     gcodeInjectionNoPrinters: 'Nenhuma impressora encontrada. Adicione impressoras para configurar snippets de G-code.',
@@ -2829,6 +2835,9 @@ export default {
     wrap: 'Quebrar texto',
     enableTextWrapping: 'Ativar quebra de texto',
     disableTextWrapping: 'Desativar quebra de texto',
+    collapse: 'Recolher',
+    collapseFoldersByDefault: 'Recolher pastas por padrão',
+    expandFoldersByDefault: 'Expandir pastas por padrão',
     dragToResizeTooltip: 'Arraste para redimensionar, clique duas vezes para redefinir',
     searchFiles: 'Pesquisar arquivos...',
     allTypes: 'Todos os tipos',

+ 10 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -316,6 +316,12 @@ export default {
       connected: '已连接',
       offline: '离线',
     },
+    plateStatus: {
+      markCleared: '将打印板标记为已清理',
+      cleared: '打印板已清理',
+      notCleared: '打印板未清理',
+      inUse: '打印板使用中',
+    },
     // Queue info
     queue: {
       inQueue: '队列中有 {{count}} 个打印任务',
@@ -1739,7 +1745,7 @@ export default {
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     plateClear: '热床清空确认',
     requirePlateClear: '需要热床清空确认',
-    requirePlateClearDescription: '启用后,调度器会在已完成打印的打印机上启动排队打印之前,等待每台打印机的热床清空确认。对于物理验证热床的农场工作流,请禁用此选项。',
+    requirePlateClearDescription: '启用后,调度器会在已完成打印的打印机上启动排队打印之前,等待每台打印机的热床清空确认。禁用后,也会隐藏打印机卡片上的打印板状态标记和“将打印板标记为已清理”按钮。',
     gcodeInjection: 'G-code注入',
     gcodeInjectionDescription: '为Farmloop、SwapMod、AutoClear和Printflow 3D等自动打印系统配置自定义G-code,在打印开始和/或结束时注入。代码片段按打印机型号配置,在队列项目上启用"注入G-code"时应用。',
     gcodeInjectionNoPrinters: '未找到打印机。添加打印机以配置G-code代码片段。',
@@ -2881,6 +2887,9 @@ export default {
     wrap: '换行',
     enableTextWrapping: '启用文本换行',
     disableTextWrapping: '禁用文本换行',
+    collapse: '折叠',
+    collapseFoldersByDefault: '默认折叠文件夹',
+    expandFoldersByDefault: '默认展开文件夹',
     dragToResizeTooltip: '拖动调整大小,双击重置',
     searchFiles: '搜索文件...',
     allTypes: '所有类型',

+ 10 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -316,6 +316,12 @@ export default {
       connected: '已連線',
       offline: '離線',
     },
+    plateStatus: {
+      markCleared: '將列印板標記為已清理',
+      cleared: '列印板已清理',
+      notCleared: '列印板未清理',
+      inUse: '列印板使用中',
+    },
     // Queue info
     queue: {
       inQueue: '佇列中有 {{count}} 個列印任務',
@@ -1739,7 +1745,7 @@ export default {
     staggeredStartDescription: '多台印表機批次啟動時的預設群組大小與間隔。可在列印對話框中逐批覆寫。',
     plateClear: '熱床清空確認',
     requirePlateClear: '需要熱床清空確認',
-    requirePlateClearDescription: '啟用後,排程器會在已完成列印的印表機上啟動佇列列印之前,等待每臺印表機的熱床清空確認。對於物理驗證熱床的農場工作流,請停用此選項。',
+    requirePlateClearDescription: '啟用後,排程器會在已完成列印的印表機上啟動佇列列印之前,等待每臺印表機的熱床清空確認。停用後,也會隱藏印表機卡片上的列印板狀態標記和「將列印板標記為已清理」按鈕。',
     gcodeInjection: 'G-code注入',
     gcodeInjectionDescription: '為Farmloop、SwapMod、AutoClear和Printflow 3D等自動列印系統設定自訂G-code,在列印開始和/或結束時注入。程式碼片段按印表機型號設定,在佇列項目上啟用"注入G-code"時套用。',
     gcodeInjectionNoPrinters: '未找到印表機。新增印表機以設定G-code程式碼片段。',
@@ -2881,6 +2887,9 @@ export default {
     wrap: '換行',
     enableTextWrapping: '啟用文字換行',
     disableTextWrapping: '停用文字換行',
+    collapse: '折疊',
+    collapseFoldersByDefault: '預設折疊資料夾',
+    expandFoldersByDefault: '預設展開資料夾',
     dragToResizeTooltip: '拖曳調整大小,雙擊重設',
     searchFiles: '搜尋檔案...',
     allTypes: '所有類型',

+ 3 - 3
frontend/src/pages/CameraPage.tsx

@@ -19,7 +19,7 @@ export function CameraPage() {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
-  const { hasPermission, authEnabled } = useAuth();
+  const { hasPermission, authEnabled, user } = useAuth();
   const { printerId } = useParams<{ printerId: string }>();
   const id = parseInt(printerId || '0', 10);
 
@@ -28,9 +28,9 @@ export function CameraPage() {
   // useQuery call dedupes via the shared key and just reads the cached value.
   useStreamTokenSync();
   const { data: streamTokenData } = useQuery({
-    queryKey: ['camera-stream-token'],
+    queryKey: ['camera-stream-token', user?.id ?? null],
     queryFn: () => api.getCameraStreamToken(),
-    enabled: authEnabled,
+    enabled: authEnabled ? !!user : true,
     staleTime: 50 * 60 * 1000,
   });
   const streamTokenValue = streamTokenData?.token ?? getStreamToken();

+ 44 - 19
frontend/src/pages/FileManagerPage.tsx

@@ -525,12 +525,13 @@ interface FolderTreeItemProps {
   onRename: (folder: LibraryFolderTree) => void;
   depth?: number;
   wrapNames?: boolean;
+  defaultExpanded?: boolean;
   hasPermission: (permission: Permission) => boolean;
   t: TFunction;
 }
 
-function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, hasPermission, t }: FolderTreeItemProps) {
-  const [expanded, setExpanded] = useState(true);
+function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, defaultExpanded = true, hasPermission, t }: FolderTreeItemProps) {
+  const [expanded, setExpanded] = useState(defaultExpanded);
   const [showActions, setShowActions] = useState(false);
   const hasChildren = folder.children.length > 0;
   const isLinked = folder.project_id || folder.archive_id;
@@ -664,6 +665,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
               onRename={onRename}
               depth={depth + 1}
               wrapNames={wrapNames}
+              defaultExpanded={defaultExpanded}
               hasPermission={hasPermission}
               t={t}
             />
@@ -915,6 +917,9 @@ export function FileManagerPage() {
   const [wrapFolderNames, setWrapFolderNames] = useState(() => {
     return localStorage.getItem('library-wrap-folders') === 'true';
   });
+  const [collapseFoldersByDefault, setCollapseFoldersByDefault] = useState(() => {
+    return localStorage.getItem('library-collapse-folders') === 'true';
+  });
 
   // Resizable sidebar state
   const [sidebarWidth, setSidebarWidth] = useState(() => {
@@ -1532,21 +1537,38 @@ export function FileManagerPage() {
           </div>
           <div className="p-3 border-b border-bambu-dark-tertiary flex items-center justify-between">
             <h2 className="text-sm font-medium text-white">{t('fileManager.folders')}</h2>
-            <button
-              onClick={() => {
-                const newValue = !wrapFolderNames;
-                setWrapFolderNames(newValue);
-                localStorage.setItem('library-wrap-folders', String(newValue));
-              }}
-              className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
-                wrapFolderNames
-                  ? 'bg-bambu-green/20 text-bambu-green'
-                  : 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
-              }`}
-              title={wrapFolderNames ? t('fileManager.disableTextWrapping') : t('fileManager.enableTextWrapping')}
-            >
-              {t('fileManager.wrap')}
-            </button>
+            <div className="flex items-center gap-1">
+              <button
+                onClick={() => {
+                  const newValue = !collapseFoldersByDefault;
+                  setCollapseFoldersByDefault(newValue);
+                  localStorage.setItem('library-collapse-folders', String(newValue));
+                }}
+                className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
+                  collapseFoldersByDefault
+                    ? 'bg-bambu-green/20 text-bambu-green'
+                    : 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
+                }`}
+                title={collapseFoldersByDefault ? t('fileManager.expandFoldersByDefault') : t('fileManager.collapseFoldersByDefault')}
+              >
+                {t('fileManager.collapse')}
+              </button>
+              <button
+                onClick={() => {
+                  const newValue = !wrapFolderNames;
+                  setWrapFolderNames(newValue);
+                  localStorage.setItem('library-wrap-folders', String(newValue));
+                }}
+                className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
+                  wrapFolderNames
+                    ? 'bg-bambu-green/20 text-bambu-green'
+                    : 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
+                }`}
+                title={wrapFolderNames ? t('fileManager.disableTextWrapping') : t('fileManager.enableTextWrapping')}
+              >
+                {t('fileManager.wrap')}
+              </button>
+            </div>
           </div>
           <div className="flex-1 overflow-y-auto p-2">
             {/* All Files (root) */}
@@ -1562,10 +1584,12 @@ export function FileManagerPage() {
               <span className="text-sm">{t('fileManager.allFiles')}</span>
             </div>
 
-            {/* Folder tree */}
+            {/* Folder tree — re-key on the collapse toggle so flipping it
+                remounts every FolderTreeItem, which re-reads defaultExpanded
+                and makes the preference take effect immediately. */}
             {folders?.map((folder) => (
               <FolderTreeItem
-                key={folder.id}
+                key={`${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}`}
                 folder={folder}
                 selectedFolderId={selectedFolderId}
                 onSelect={setSelectedFolderId}
@@ -1573,6 +1597,7 @@ export function FileManagerPage() {
                 onLink={setLinkFolder}
                 onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
                 wrapNames={wrapFolderNames}
+                defaultExpanded={!collapseFoldersByDefault}
                 hasPermission={hasPermission}
                 t={t}
               />

+ 97 - 9
frontend/src/pages/PrintersPage.tsx

@@ -59,7 +59,7 @@ import {
 import { useNavigate } from 'react-router-dom';
 import { api, discoveryApi, firmwareApi, withStreamToken } from '../api/client';
 import { formatDateOnly, formatETA, formatDuration, parseUTCDate } from '../utils/date';
-import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError } from '../api/client';
+import type { Printer, PrinterCreate, PrinterStatus, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -76,6 +76,7 @@ import { AssignSpoolModal } from '../components/AssignSpoolModal';
 import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { useToast } from '../contexts/ToastContext';
 import { ChamberLight } from '../components/icons/ChamberLight';
+import { PlateClearedIcon } from '../components/icons/PlateClearedIcon';
 import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
 import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
@@ -1644,6 +1645,33 @@ function PrinterCard({
     enabled: status?.connected && status?.state !== 'RUNNING',
   });
   const lastPrint = lastPrints?.[0];
+  const isPrintingOrPaused = status?.state === 'RUNNING' || status?.state === 'PAUSE';
+  const needsPlateClear = requirePlateClear && status?.awaiting_plate_clear === true;
+  const showClearPlateButton = status?.connected && needsPlateClear && !isPrintingOrPaused;
+  const plateStatus = (() => {
+    if (!requirePlateClear || !status?.connected) return null;
+    if (isPrintingOrPaused) {
+      return {
+        label: t('printers.plateStatus.inUse'),
+        className: 'bg-blue-500/20 text-blue-400',
+      };
+    }
+    if (status.awaiting_plate_clear) {
+      return {
+        label: t('printers.plateStatus.notCleared'),
+        className: 'bg-yellow-500/20 text-yellow-400',
+      };
+    }
+    return {
+      label: t('printers.plateStatus.cleared'),
+      className: 'bg-status-ok/20 text-status-ok',
+    };
+  })();
+  const plateStatusPill = plateStatus ? (
+    <span className={`inline-flex flex-shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${plateStatus.className}`}>
+      {plateStatus.label}
+    </span>
+  ) : null;
 
   // Determine if this card should be hidden (use cached connected state to prevent flicker)
   const shouldHide = hideIfDisconnected && isConnected === false;
@@ -1762,6 +1790,19 @@ function PrinterCard({
     onError: (error: Error) => showToast(error.message || t('printers.toast.failedToResumePrint'), 'error'),
   });
 
+  const clearPlateMutation = useMutation({
+    mutationFn: () => api.clearPlate(printer.id),
+    onSuccess: () => {
+      showToast(t('queue.clearPlateSuccess'));
+      queryClient.setQueryData(['printerStatus', printer.id], (old: PrinterStatus | undefined) =>
+        old ? { ...old, awaiting_plate_clear: false } : old
+      );
+      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
+      queryClient.invalidateQueries({ queryKey: ['queue', printer.id] });
+    },
+    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
+  });
+
   // Chamber light mutation with optimistic update
   const chamberLightMutation = useMutation({
     mutationFn: (on: boolean) => api.setChamberLight(printer.id, on),
@@ -2604,10 +2645,34 @@ function PrinterCard({
                         style={{ width: `${status.progress || 0}%` }}
                       />
                     </div>
-                    <span className="text-xs text-white">{Math.round(status.progress || 0)}%</span>
+                    <div className="flex flex-shrink-0 items-center gap-1.5">
+                      <span className="text-xs text-white">{Math.round(status.progress || 0)}%</span>
+                      {plateStatusPill}
+                    </div>
                   </div>
                 ) : (
-                  <p className="text-xs text-bambu-gray">{getStatusDisplay(status.state, status.stg_cur_name)}</p>
+                  <div className="flex items-center justify-between gap-2">
+                    <div className="min-w-0 flex-1 flex items-center gap-1.5">
+                      <p className="min-w-0 truncate text-xs text-bambu-gray">{getStatusDisplay(status.state, status.stg_cur_name)}</p>
+                      {plateStatusPill}
+                    </div>
+                    {showClearPlateButton && (
+                      <button
+                        type="button"
+                        onClick={() => clearPlateMutation.mutate()}
+                        disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}
+                        aria-label={t('printers.plateStatus.markCleared')}
+                        className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-yellow-500/20 border border-yellow-400/40 text-yellow-400 hover:bg-yellow-500/30 transition-colors disabled:opacity-50"
+                        title={!hasPermission('printers:clear_plate') ? t('printers.permission.noControl') : t('printers.plateStatus.markCleared')}
+                      >
+                        {clearPlateMutation.isPending ? (
+                          <Loader2 className="w-3 h-3 animate-spin" />
+                        ) : (
+                          <PlateClearedIcon className="w-3 h-3" />
+                        )}
+                      </button>
+                    )}
+                  </div>
                 )}
               </div>
             ) : (
@@ -2652,7 +2717,10 @@ function PrinterCard({
                     <div className="flex-1 min-w-0">
                       {status.current_print && (status.state === 'RUNNING' || status.state === 'PAUSE') ? (
                         <>
-                          <p className="text-sm text-bambu-gray mb-1">{getStatusDisplay(status.state, status.stg_cur_name)}</p>
+                          <div className="mb-1 flex items-center gap-2">
+                            <p className="text-sm text-bambu-gray">{getStatusDisplay(status.state, status.stg_cur_name)}</p>
+                            {plateStatusPill}
+                          </div>
                           <p className="text-white text-sm mb-2 truncate">
                             {formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t)}
                           </p>
@@ -2694,9 +2762,12 @@ function PrinterCard({
                       ) : (
                         <>
                           <p className="text-sm text-bambu-gray mb-1">{t('printers.sort.status')}</p>
-                          <p className="text-white text-sm mb-2">
-                            {getStatusDisplay(status.state, status.stg_cur_name)}
-                          </p>
+                          <div className="mb-2 flex items-center gap-2">
+                            <p className="text-white text-sm">
+                              {getStatusDisplay(status.state, status.stg_cur_name)}
+                            </p>
+                            {plateStatusPill}
+                          </div>
                           <div className="flex items-center justify-between text-sm">
                             <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
                               <div className="bg-bambu-dark-tertiary h-2 rounded-full" />
@@ -2818,6 +2889,23 @@ function PrinterCard({
               );
             })()}
 
+            {viewMode === 'expanded' && showClearPlateButton && (
+              <button
+                type="button"
+                onClick={() => clearPlateMutation.mutate()}
+                disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}
+                className="mt-2 w-full inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-500/20 border border-yellow-400/40 text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs font-medium disabled:opacity-50"
+                title={!hasPermission('printers:clear_plate') ? t('printers.permission.noControl') : t('printers.plateStatus.markCleared')}
+              >
+                {clearPlateMutation.isPending ? (
+                  <Loader2 className="w-3 h-3 animate-spin" />
+                ) : (
+                  <PlateClearedIcon className="w-4 h-4" />
+                )}
+                {t('printers.plateStatus.markCleared')}
+              </button>
+            )}
+
             {/* Controls - Fans + Print Buttons */}
             {viewMode === 'expanded' && (() => {
               // Determine print state for control buttons
@@ -2841,9 +2929,9 @@ function PrinterCard({
                     <div className="flex-1 h-px bg-bambu-dark-tertiary/30" />
                   </div>
 
-                  <div className="flex items-center justify-between gap-2 max-[550px]:items-start">
+                  <div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-2">
                     {/* Left: Fan Status - always visible, dynamic coloring */}
-                    <div className="flex items-center gap-2 min-w-0 max-[550px]:flex-wrap max-[550px]:items-start max-[550px]:gap-1.5">
+                    <div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 min-w-0">
                       {/* Part Cooling Fan */}
                       <div
                         className={`flex items-center gap-1 px-1.5 py-1 rounded ${partFan && partFan > 0 ? 'bg-cyan-500/10' : 'bg-bambu-dark'}`}

+ 1 - 1
frontend/src/pages/SettingsPage.tsx

@@ -3724,7 +3724,7 @@ export function SettingsPage() {
                     {t('settings.requirePlateClear', 'Require plate-clear confirmation')}
                   </p>
                   <p className="text-xs text-bambu-gray mt-1">
-                    {t('settings.requirePlateClearDescription', 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disable for farm workflows where plates are verified physically.')}
+                    {t('settings.requirePlateClearDescription', 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disabling this also hides the plate status badge and the "Mark plate as cleared" button on printer cards.')}
                   </p>
                 </div>
                 <label className="relative inline-flex items-center cursor-pointer">

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-3s5orqQ4.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CFcQzo54.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CkAOuJaW.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-D8a3o-KR.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-3s5orqQ4.css">
+    <script type="module" crossorigin src="/assets/index-CFcQzo54.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CkAOuJaW.css">
   </head>
   <body>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов