Browse Source

Merge pull request #1082 from maziggy/0.2.3.2

v0.2.3.2
MartinNYHC 1 month ago
parent
commit
8b119adf91
53 changed files with 1529 additions and 820 deletions
  1. 1 0
      .gitignore
  2. 7 0
      CHANGELOG.md
  3. 2 1
      backend/app/api/routes/library.py
  4. 33 1
      backend/app/api/routes/printers.py
  5. 1 1
      backend/app/core/config.py
  6. 1 1
      backend/app/main.py
  7. 6 0
      backend/app/schemas/library.py
  8. 8 0
      backend/app/schemas/printer.py
  9. 6 1
      backend/app/schemas/spool.py
  10. 33 0
      backend/app/services/background_dispatch.py
  11. 42 9
      backend/app/services/print_scheduler.py
  12. 27 0
      backend/app/services/printer_manager.py
  13. 63 0
      backend/tests/integration/test_background_dispatch_api.py
  14. 44 0
      backend/tests/integration/test_printers_api.py
  15. 54 0
      backend/tests/unit/services/test_background_dispatch.py
  16. 78 0
      backend/tests/unit/services/test_printer_manager.py
  17. 260 0
      backend/tests/unit/test_scheduler_watchdog.py
  18. 30 0
      backend/tests/unit/test_slot_preset_key.py
  19. 131 0
      backend/tests/unit/test_spool_schemas_rgba.py
  20. 14 1
      frontend/scripts/check-i18n-parity.mjs
  21. 105 0
      frontend/src/__tests__/components/AssignSpoolModal.test.tsx
  22. 104 9
      frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx
  23. 102 0
      frontend/src/__tests__/components/PrintModal.test.tsx
  24. 0 654
      frontend/src/__tests__/components/PrinterQueueWidgetClearPlate.test.tsx
  25. 67 0
      frontend/src/__tests__/components/SpoolFormModal.test.tsx
  26. 119 0
      frontend/src/__tests__/components/spool-form/ColorSectionHexInput.test.tsx
  27. 46 0
      frontend/src/__tests__/pages/PrintersPageFormatPrintName.test.ts
  28. 3 0
      frontend/src/api/client.ts
  29. 28 7
      frontend/src/components/AssignSpoolModal.tsx
  30. 4 5
      frontend/src/components/ConfigureAmsSlotModal.tsx
  31. 10 0
      frontend/src/components/Layout.tsx
  32. 2 0
      frontend/src/components/PrintModal/index.tsx
  33. 3 0
      frontend/src/components/PrintModal/types.ts
  34. 8 84
      frontend/src/components/PrinterQueueWidget.tsx
  35. 1 1
      frontend/src/components/SkipObjectsModal.tsx
  36. 11 1
      frontend/src/components/SpoolFormModal.tsx
  37. 12 2
      frontend/src/components/spool-form/ColorSection.tsx
  38. 0 2
      frontend/src/i18n/locales/de.ts
  39. 0 2
      frontend/src/i18n/locales/en.ts
  40. 0 2
      frontend/src/i18n/locales/fr.ts
  41. 0 2
      frontend/src/i18n/locales/it.ts
  42. 0 2
      frontend/src/i18n/locales/ja.ts
  43. 0 2
      frontend/src/i18n/locales/pt-BR.ts
  44. 0 2
      frontend/src/i18n/locales/zh-CN.ts
  45. 0 2
      frontend/src/i18n/locales/zh-TW.ts
  46. 35 23
      frontend/src/pages/PrintersPage.tsx
  47. 1 1
      frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx
  48. 23 0
      frontend/src/utils/printName.ts
  49. 2 0
      requirements.txt
  50. 0 0
      static/assets/index-BoxU3Y8Y.css
  51. 0 0
      static/assets/index-CkAOuJaW.css
  52. 0 0
      static/assets/index-aHxaU9HU.js
  53. 2 2
      static/index.html

+ 1 - 0
.gitignore

@@ -76,3 +76,4 @@ db_backup/
 support-packages/
 backups/
 bin/
+advertisements/

File diff suppressed because it is too large
+ 7 - 0
CHANGELOG.md


+ 2 - 1
backend/app/api/routes/library.py

@@ -2236,10 +2236,11 @@ async def print_library_file(
             filename=dispatch_source_name,
             printer_id=printer_id,
             printer_name=printer.name,
-            options=body.model_dump(exclude_none=True),
+            options=body.model_dump(exclude_none=True, exclude={"cleanup_library_after_dispatch"}),
             project_id=body.project_id,
             requested_by_user_id=current_user.id if current_user else None,
             requested_by_username=current_user.username if current_user else None,
+            cleanup_library_after_dispatch=body.cleanup_library_after_dispatch,
         )
     except DispatchEnqueueRejected as e:
         raise HTTPException(status_code=409, detail=str(e)) from e

+ 33 - 1
backend/app/api/routes/printers.py

@@ -39,6 +39,7 @@ from backend.app.services.bambu_ftp import (
 )
 from backend.app.services.printer_manager import (
     get_derived_status_name,
+    parse_plate_id,
     printer_manager,
     supports_chamber_temp,
     supports_drying,
@@ -561,6 +562,26 @@ async def get_printer_status(
             k: v for k, v in temperatures.items() if k not in ("chamber", "chamber_target", "chamber_heating")
         }
 
+    # Resolve the active print's archive + plate (#881 follow-up): lets the
+    # printer card show the actual plate name for multi-plate 3MFs instead of
+    # just the 3MF filename. Only attempted for active prints, since subtask_id
+    # is only meaningful then.
+    current_archive_id: int | None = None
+    current_plate_id: int | None = None
+    if state.state in ("RUNNING", "PAUSE"):
+        current_plate_id = parse_plate_id(state.gcode_file)
+        if state.subtask_id:
+            from backend.app.models.archive import PrintArchive
+
+            archive_row = await db.execute(
+                select(PrintArchive.id)
+                .where(PrintArchive.subtask_id == state.subtask_id)
+                .where(PrintArchive.printer_id == printer_id)
+                .order_by(PrintArchive.created_at.desc())
+                .limit(1)
+            )
+            current_archive_id = archive_row.scalar_one_or_none()
+
     return PrinterStatus(
         id=printer_id,
         name=printer.name,
@@ -612,6 +633,8 @@ async def get_printer_status(
         developer_mode=state.developer_mode if state else None,
         awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
         supports_drying=supports_drying(printer.model, state.firmware_version),
+        current_archive_id=current_archive_id,
+        current_plate_id=current_plate_id,
     )
 
 
@@ -1725,6 +1748,15 @@ async def start_calibration(
 # ============================================================================
 
 
+def _slot_preset_key(ams_id: int, tray_id: int) -> int:
+    # Mirrors frontend getGlobalTrayId (amsHelpers.ts): AMS-HT (128-135) is keyed
+    # by ams_id since each unit has a single slot and shares its global ID with
+    # the unit itself. Regular AMS and external (255) use ams_id*4+tray_id.
+    if 128 <= ams_id <= 135:
+        return ams_id
+    return ams_id * 4 + tray_id
+
+
 @router.get("/{printer_id}/slot-presets")
 async def get_slot_presets(
     printer_id: int,
@@ -1736,7 +1768,7 @@ async def get_slot_presets(
     mappings = result.scalars().all()
 
     return {
-        mapping.ams_id * 4 + mapping.tray_id: {
+        _slot_preset_key(mapping.ams_id, mapping.tray_id): {
             "ams_id": mapping.ams_id,
             "tray_id": mapping.tray_id,
             "preset_id": mapping.preset_id,

+ 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.1"
+APP_VERSION = "0.2.3.2"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 

+ 1 - 1
backend/app/main.py

@@ -4341,7 +4341,7 @@ async def security_headers_middleware(request, call_next):
         "font-src 'self' data: https://fonts.gstatic.com; "
         "object-src 'none'; "
         "base-uri 'self'; "
-        "frame-src 'self' https:; "
+        "frame-src 'self' http: https:; "
         "frame-ancestors 'none';"
     )
     if request.url.scheme == "https":

+ 6 - 0
backend/app/schemas/library.py

@@ -210,6 +210,12 @@ class FilePrintRequest(BaseModel):
     use_ams: bool = True
     # Project to associate the resulting archive with
     project_id: int | None = None
+    # When true, delete the LibraryFile row + disk file after the archive has
+    # been created and the print has been dispatched. Used by the Printers-page
+    # Direct-Print flow (click / drag-drop a file onto a printer card) so the
+    # transient upload doesn't linger in File Manager. Cleanup is skipped on
+    # external library files.
+    cleanup_library_after_dispatch: bool = False
 
 
 class FileUploadResponse(BaseModel):

+ 8 - 0
backend/app/schemas/printer.py

@@ -273,3 +273,11 @@ class PrinterStatus(BaseModel):
     awaiting_plate_clear: bool = False
     # AMS drying support
     supports_drying: bool = False
+    # Linked archive for the active print (resolved via subtask_id). Frontend uses
+    # this to fetch plate metadata and show the plate name when the source 3MF is
+    # multi-plate (#881 follow-up).
+    current_archive_id: int | None = None
+    # 1-indexed plate number parsed from gcode_file (e.g. /Metadata/plate_2.gcode).
+    # Set for every active print regardless of plate count; the frontend decides
+    # whether to render it based on current_archive_id's is_multi_plate flag.
+    current_plate_id: int | None = None

+ 6 - 1
backend/app/schemas/spool.py

@@ -41,7 +41,7 @@ class SpoolUpdate(BaseModel):
     material: str | None = None
     subtype: str | None = None
     color_name: str | None = None
-    rgba: str | None = None
+    rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
     brand: str | None = None
     label_weight: int | None = None
     core_weight: int | None = None
@@ -82,6 +82,11 @@ class SpoolKProfileResponse(SpoolKProfileBase):
 
 class SpoolResponse(SpoolBase):
     id: int
+    # rgba is intentionally unconstrained on the response side: the write paths
+    # (SpoolCreate, SpoolUpdate) enforce the 8-char hex pattern, but legacy rows
+    # or data sourced from AMS firmware / backups may carry malformed values.
+    # A single bad row must not 500 the entire inventory list endpoint (#1055).
+    rgba: str | None = None
     added_full: bool | None = None
     last_used: datetime | None = None
     encode_time: datetime | None = None

+ 33 - 0
backend/app/services/background_dispatch.py

@@ -55,6 +55,7 @@ class PrintDispatchJob:
     requested_by_user_id: int | None = None
     requested_by_username: str | None = None
     project_id: int | None = None
+    cleanup_library_after_dispatch: bool = False
 
 
 @dataclass(slots=True)
@@ -162,6 +163,7 @@ class BackgroundDispatchService:
         requested_by_user_id: int | None,
         requested_by_username: str | None,
         project_id: int | None = None,
+        cleanup_library_after_dispatch: bool = False,
     ) -> dict[str, Any]:
         return await self._dispatch(
             kind="print_library_file",
@@ -173,6 +175,7 @@ class BackgroundDispatchService:
             requested_by_user_id=requested_by_user_id,
             requested_by_username=requested_by_username,
             project_id=project_id,
+            cleanup_library_after_dispatch=cleanup_library_after_dispatch,
         )
 
     async def cancel_job(self, job_id: int) -> dict[str, Any]:
@@ -261,6 +264,7 @@ class BackgroundDispatchService:
         requested_by_user_id: int | None,
         requested_by_username: str | None,
         project_id: int | None = None,
+        cleanup_library_after_dispatch: bool = False,
     ) -> dict[str, Any]:
         async with self._lock:
             has_pending_for_printer = any(job.printer_id == printer_id for job in self._queued_jobs)
@@ -284,6 +288,7 @@ class BackgroundDispatchService:
                 requested_by_user_id=requested_by_user_id,
                 requested_by_username=requested_by_username,
                 project_id=project_id,
+                cleanup_library_after_dispatch=cleanup_library_after_dispatch,
             )
             self._next_job_id += 1
             self._batch_total += 1
@@ -728,6 +733,7 @@ class BackgroundDispatchService:
                 source_file=file_path,
                 original_filename=lib_file.filename,
                 project_id=job.project_id,
+                created_by_id=job.requested_by_user_id,
             )
             if not archive:
                 raise RuntimeError("Failed to create archive")
@@ -855,7 +861,34 @@ class BackgroundDispatchService:
                 if pre_state:
                     asyncio.create_task(self._verify_print_response(job.printer_id, printer_name, pre_state))
 
+                if job.requested_by_user_id and job.requested_by_username:
+                    printer_manager.set_current_print_user(
+                        job.printer_id,
+                        job.requested_by_user_id,
+                        job.requested_by_username,
+                    )
+
+                # Direct-Print flow only: archive_print copies, so deleting the
+                # transient library row + files here leaves archive intact. Disk
+                # deletes run only after commit so a rollback leaves no orphan.
+                cleanup_disk_paths: list[Path] = []
+                if job.cleanup_library_after_dispatch and not lib_file.is_external:
+                    cleanup_disk_paths.append(file_path)
+                    if lib_file.thumbnail_path:
+                        thumb_path = Path(lib_file.thumbnail_path)
+                        if not thumb_path.is_absolute():
+                            thumb_path = Path(settings.base_dir) / lib_file.thumbnail_path
+                        cleanup_disk_paths.append(thumb_path)
+                    await db.delete(lib_file)
+
                 await db.commit()
+
+                for cleanup_path in cleanup_disk_paths:
+                    try:
+                        if cleanup_path.exists():
+                            cleanup_path.unlink()
+                    except OSError as cleanup_err:
+                        logger.warning("Failed to delete transient library file %s: %s", cleanup_path, cleanup_err)
             except DispatchJobCancelled:
                 await db.rollback()
                 await self._set_active_message(job, f"Cancelled upload on {printer_name}.")

+ 42 - 9
backend/app/services/print_scheduler.py

@@ -1833,9 +1833,12 @@ class PrintScheduler:
         logger.info("Queue item %s: Status set to 'printing', sending print command...", item.id)
 
         # Capture state before dispatch so the watchdog can detect whether the
-        # printer actually transitioned (#967).
+        # printer actually transitioned (#967). Also capture subtask_id so the
+        # watchdog can recognise "command landed but state hasn't flipped yet"
+        # on slow H2D transitions (#1078).
         pre_status = printer_manager.get_status(item.printer_id)
         pre_state = getattr(pre_status, "state", None) if pre_status else None
+        pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
 
         # Start the print with AMS mapping, plate_id and print options
         started = printer_manager.start_print(
@@ -1854,12 +1857,23 @@ class PrintScheduler:
         if started:
             logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
 
-            # Watchdog: if the printer never transitions out of pre_state, the MQTT
-            # publish was accepted locally but didn't reach the printer (half-broken
-            # session — same shape as #887/#936). Revert the queue item so the next
-            # dispatch can pick it up instead of leaving it stuck in "printing" (#967).
+            # Watchdog: if the printer never transitions out of pre_state AND
+            # never advances subtask_id, the MQTT publish was accepted locally but
+            # didn't reach the printer (half-broken session — same shape as
+            # #887/#936). Revert the queue item so the next dispatch can pick it
+            # up instead of leaving it stuck in "printing" (#967). subtask_id
+            # check avoids false reverts on slow H2D FINISH→PREPARE transitions
+            # that would otherwise cause the item to re-dispatch as a reprint
+            # of the just-finished job (#1078).
             if pre_state:
-                asyncio.create_task(self._watchdog_print_start(item.id, item.printer_id, pre_state))
+                asyncio.create_task(
+                    self._watchdog_print_start(
+                        item.id,
+                        item.printer_id,
+                        pre_state,
+                        pre_subtask_id,
+                    )
+                )
 
             # Get estimated time for notification
             estimated_time = None
@@ -1930,7 +1944,8 @@ class PrintScheduler:
         queue_item_id: int,
         printer_id: int,
         pre_state: str,
-        timeout: float = 45.0,
+        pre_subtask_id: str | None = None,
+        timeout: float = 90.0,
         poll_interval: float = 3.0,
     ) -> None:
         """Revert a queue item if the printer never acknowledges the start command.
@@ -1939,6 +1954,20 @@ class PrintScheduler:
         MQTT project_file publish succeeds locally. If the printer drops/ignores the
         command (half-broken MQTT session — #887/#936), the state never transitions
         and the item would otherwise stay stuck in "printing" forever (#967).
+
+        Exit paths (printer picked up the job — no revert):
+          - gcode_state changed from pre_state, OR
+          - subtask_id advanced past pre_subtask_id — the printer echoes our
+            per-dispatch identity back on push_status, so a subtask_id change is
+            a definitive "command landed" signal even while state is still FINISH.
+            H2D can sit at FINISH for ~50 s after accepting project_file before
+            transitioning to PREPARE, which used to trip the state-only watchdog
+            and caused the scheduler to revert + re-dispatch the item; the next
+            successful dispatch then looked like a reprint of the just-finished
+            job (#1078).
+
+        Timeout raised from 45 s → 90 s as belt-and-braces for slow transitions
+        that also don't emit an early subtask_id tick.
         """
         deadline = time.monotonic() + timeout
         while time.monotonic() < deadline:
@@ -1947,7 +1976,9 @@ class PrintScheduler:
             if not status:
                 return  # Printer disconnected — don't mess with the DB
             if status.state != pre_state:
-                return  # Printer picked up the job
+                return  # Printer picked up the job (state transition)
+            if pre_subtask_id is not None and status.subtask_id is not None and status.subtask_id != pre_subtask_id:
+                return  # Printer picked up the job (subtask_id advanced)
 
         # No transition. Revert the item so the scheduler can retry.
         async with async_session() as db:
@@ -1959,11 +1990,13 @@ class PrintScheduler:
             await db.commit()
             logger.warning(
                 "Queue item %s: printer %d did not respond to print command within "
-                "%.0fs (state still %s) — reverted to 'pending' for retry (#967)",
+                "%.0fs (state still %s, subtask_id still %s) — reverted to 'pending' "
+                "for retry (#967)",
                 queue_item_id,
                 printer_id,
                 timeout,
                 pre_state,
+                pre_subtask_id,
             )
 
         # Same half-broken-session recovery as background_dispatch: force the

+ 27 - 0
backend/app/services/printer_manager.py

@@ -1,5 +1,6 @@
 import asyncio
 import logging
+import re
 import traceback
 from collections.abc import Callable
 
@@ -622,6 +623,22 @@ def get_derived_status_name(state: PrinterState, model: str | None = None) -> st
     return None
 
 
+_PLATE_ID_RE = re.compile(r"plate_(\d+)\.gcode")
+
+
+def parse_plate_id(gcode_file: str | None) -> int | None:
+    """Extract the 1-indexed plate number from a Bambu gcode_file path.
+
+    Returns None when the path is missing or has no `plate_N.gcode` segment.
+    Shared by the REST status route and the WebSocket push path so both agree
+    on the value sent to the frontend (#881 follow-up).
+    """
+    if not gcode_file:
+        return None
+    match = _PLATE_ID_RE.search(gcode_file)
+    return int(match.group(1)) if match else None
+
+
 def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, model: str | None = None) -> dict:
     """Convert PrinterState to a JSON-serializable dict.
 
@@ -838,6 +855,16 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         ],
         # AMS drying support
         "supports_drying": supports_drying(model, state.firmware_version),
+        # 1-indexed plate number parsed from gcode_file (e.g. /Metadata/plate_2.gcode).
+        # Pushed via WebSocket so the printer card picks up plate transitions within
+        # a multi-plate 3MF without waiting for the 30 s REST poll (#881 follow-up).
+        # current_archive_id is intentionally REST-only — it's stable for the life
+        # of a print and needs a DB lookup the WebSocket path shouldn't pay for.
+        "current_plate_id": parse_plate_id(state.gcode_file),
+        # Plate-clear gate (#939). Lives on the PrinterManager rather than PrinterState,
+        # so surface it here — without this, WebSocket merges drop the flag and the
+        # "Clear Plate" button only appears when the 30 s REST fallback poll runs.
+        "awaiting_plate_clear": printer_manager.is_awaiting_plate_clear(printer_id) if printer_id else False,
     }
     # Add cover URL if there's an active print and printer_id is provided
     # Include PAUSE state so skip objects modal can show cover

+ 63 - 0
backend/tests/integration/test_background_dispatch_api.py

@@ -179,6 +179,69 @@ class TestBackgroundDispatchLibraryAPI:
         assert response.status_code == 409
         assert "queue conflict" in response.json()["detail"]
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_print_cleanup_flag_defaults_false(
+        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
+    ):
+        """Absent cleanup_library_after_dispatch in the request body ⇒ False reaches the dispatcher.
+        Guards the File Manager / Project Detail paths from accidental deletion."""
+        printer = await printer_factory()
+        lib_file = await library_file_factory(filename="filemgr_part.gcode.3mf")
+
+        disk_path = tmp_path / lib_file.file_path
+        disk_path.parent.mkdir(parents=True, exist_ok=True)
+        disk_path.write_bytes(b"library data")
+
+        with (
+            patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
+            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
+            patch(
+                "backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
+                new=AsyncMock(return_value={"dispatch_job_id": 30, "dispatch_position": 1}),
+            ) as mock_dispatch,
+        ):
+            response = await async_client.post(
+                f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
+                json={},
+            )
+
+        assert response.status_code == 200
+        mock_dispatch.assert_awaited_once()
+        assert mock_dispatch.await_args.kwargs["cleanup_library_after_dispatch"] is False
+        # cleanup flag must never leak into the print-option dict forwarded to MQTT
+        assert "cleanup_library_after_dispatch" not in mock_dispatch.await_args.kwargs["options"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_print_forwards_cleanup_flag_true(
+        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
+    ):
+        """Direct-Print flow sends cleanup_library_after_dispatch=True, which must reach the dispatcher."""
+        printer = await printer_factory()
+        lib_file = await library_file_factory(filename="transient_part.gcode.3mf")
+
+        disk_path = tmp_path / lib_file.file_path
+        disk_path.parent.mkdir(parents=True, exist_ok=True)
+        disk_path.write_bytes(b"library data")
+
+        with (
+            patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
+            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
+            patch(
+                "backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
+                new=AsyncMock(return_value={"dispatch_job_id": 31, "dispatch_position": 1}),
+            ) as mock_dispatch,
+        ):
+            response = await async_client.post(
+                f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
+                json={"cleanup_library_after_dispatch": True},
+            )
+
+        assert response.status_code == 200
+        mock_dispatch.assert_awaited_once()
+        assert mock_dispatch.await_args.kwargs["cleanup_library_after_dispatch"] is True
+
 
 class TestBackgroundDispatchCancelAPI:
     """Tests for /background-dispatch cancel endpoint."""

+ 44 - 0
backend/tests/integration/test_printers_api.py

@@ -803,6 +803,50 @@ class TestConfigureAMSSlotAPI:
             call_kwargs = mock_client.ams_set_filament_setting.call_args
             assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_pfus_preserves_setting_id_pair(self, async_client: AsyncClient, printer_factory):
+        """Both tray_info_idx=PFUS* and setting_id=PFUS* are forwarded untouched.
+
+        Pins the end-to-end contract the frontend #1053 fix relies on: when the
+        user configures a slot with a custom cloud preset whose cloud detail
+        has filament_id=null, the frontend sends the setting_id in BOTH fields
+        and the backend must not collapse either to a generic GF* ID.
+        """
+        printer = await printer_factory(name="H2D")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        mock_status = MagicMock()
+        mock_status.raw_data = {"ams": {"ams": []}}
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = mock_status
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/128/0/configure",
+                params={
+                    "tray_info_idx": "PFUSa8fb76f9733e3c",
+                    "tray_type": "ABS",
+                    "tray_sub_brands": "Sting3D ABS",
+                    "tray_color": "000000FF",
+                    "nozzle_temp_min": 240,
+                    "nozzle_temp_max": 280,
+                    "setting_id": "PFUSa8fb76f9733e3c",
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "PFUSa8fb76f9733e3c"
+            assert call_kwargs.kwargs["setting_id"] == "PFUSa8fb76f9733e3c"
+            # Explicitly assert no generic-collapse happened for this HT slot.
+            assert call_kwargs.kwargs["tray_info_idx"] != "GFB99"
+
 
 class TestSkipObjectsAPI:
     """Integration tests for skip objects endpoints."""

+ 54 - 0
backend/tests/unit/services/test_background_dispatch.py

@@ -68,6 +68,60 @@ async def test_dispatch_enqueues_job_and_broadcasts_state():
     assert payload["data"]["recent_event"]["status"] == "dispatched"
 
 
+@pytest.mark.asyncio
+async def test_dispatch_library_file_defaults_cleanup_flag_false():
+    """cleanup_library_after_dispatch defaults to False when not passed — protects
+    File Manager / Project Detail / queued-library-file paths from surprise deletion."""
+    service = BackgroundDispatchService()
+
+    with (
+        patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
+        patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock),
+    ):
+        await service.dispatch_print_library_file(
+            file_id=1,
+            filename="cube.gcode.3mf",
+            printer_id=1,
+            printer_name="Printer A",
+            options={},
+            requested_by_user_id=None,
+            requested_by_username=None,
+        )
+
+    assert len(service._queued_jobs) == 1
+    assert service._queued_jobs[0].cleanup_library_after_dispatch is False
+
+
+@pytest.mark.asyncio
+async def test_dispatch_library_file_propagates_cleanup_flag_true():
+    """cleanup_library_after_dispatch=True arrives on the queued job so the runner
+    can delete the transient LibraryFile after the print is accepted by the printer."""
+    service = BackgroundDispatchService()
+
+    with (
+        patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
+        patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock),
+    ):
+        await service.dispatch_print_library_file(
+            file_id=1,
+            filename="cube.gcode.3mf",
+            printer_id=1,
+            printer_name="Printer A",
+            options={},
+            requested_by_user_id=42,
+            requested_by_username="alice",
+            cleanup_library_after_dispatch=True,
+        )
+
+    assert len(service._queued_jobs) == 1
+    job = service._queued_jobs[0]
+    assert job.cleanup_library_after_dispatch is True
+    # Sanity: other fields still wired correctly
+    assert job.requested_by_user_id == 42
+    assert job.requested_by_username == "alice"
+    assert job.kind == "print_library_file"
+
+
 @pytest.mark.asyncio
 async def test_cancel_queued_job_removes_it_and_broadcasts():
     """Cancelling queued job removes it immediately."""

+ 78 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -13,6 +13,7 @@ from backend.app.services.printer_manager import (
     get_derived_status_name,
     has_stg_cur_idle_bug,
     init_printer_connections,
+    parse_plate_id,
     printer_state_to_dict,
     supports_chamber_temp,
     supports_drying,
@@ -834,6 +835,22 @@ class TestPrinterStateToDict:
 
         assert result["cover_url"] == "/api/v1/printers/1/cover"
 
+    def test_current_plate_id_extracted_from_gcode_file(self, mock_state):
+        """Verify current_plate_id is parsed from a Bambu plate path (#881)."""
+        mock_state.gcode_file = "/Metadata/plate_3.gcode"
+
+        result = printer_state_to_dict(mock_state)
+
+        assert result["current_plate_id"] == 3
+
+    def test_current_plate_id_none_when_no_plate_segment(self, mock_state):
+        """Verify current_plate_id stays None when gcode_file has no plate marker."""
+        mock_state.gcode_file = "/sdcard/test.gcode"
+
+        result = printer_state_to_dict(mock_state)
+
+        assert result["current_plate_id"] is None
+
     def test_cover_url_none_when_not_running(self, mock_state):
         """Verify cover_url is None when not printing."""
         mock_state.state = "IDLE"
@@ -949,6 +966,26 @@ class TestPrinterStateToDict:
         assert tray["drying_temp"] == 55
         assert tray["drying_time"] == 240
 
+    def test_awaiting_plate_clear_defaults_false(self, mock_state):
+        """Without a printer_id, awaiting_plate_clear is False (no lookup possible)."""
+        result = printer_state_to_dict(mock_state)
+        assert result["awaiting_plate_clear"] is False
+
+    def test_awaiting_plate_clear_surfaced_when_set(self, mock_state):
+        """With printer_id, awaiting_plate_clear reflects PrinterManager state.
+
+        Regression: PR #939 left this flag off the WebSocket payload, so the
+        "Clear Plate" button only appeared after the 30 s REST fallback poll.
+        """
+        from backend.app.services.printer_manager import printer_manager
+
+        printer_manager.set_awaiting_plate_clear(12345, True)
+        try:
+            result = printer_state_to_dict(mock_state, printer_id=12345)
+            assert result["awaiting_plate_clear"] is True
+        finally:
+            printer_manager.set_awaiting_plate_clear(12345, False)
+
 
 class TestStatusKeyDryingDedup:
     """Regression tests for WebSocket dedup including drying fields.
@@ -1295,3 +1332,44 @@ class TestAmsChangeCallback:
         # This tests the callback signature
         assert manager._on_ams_change is not None
         assert callable(manager._on_ams_change)
+
+
+class TestParsePlateId:
+    """Tests for parse_plate_id() — active-print plate extraction from gcode paths.
+
+    Regression coverage for #881 follow-up: the REST /status endpoint and the
+    WebSocket push path both use this helper, so they must agree on the plate
+    number the frontend sees.
+    """
+
+    def test_bambu_metadata_path(self):
+        # Canonical path that Bambu Studio / OrcaSlicer stamp into the 3MF.
+        assert parse_plate_id("/Metadata/plate_2.gcode") == 2
+
+    def test_plate_one(self):
+        assert parse_plate_id("/Metadata/plate_1.gcode") == 1
+
+    def test_double_digit_plate(self):
+        assert parse_plate_id("/Metadata/plate_12.gcode") == 12
+
+    def test_none_input(self):
+        assert parse_plate_id(None) is None
+
+    def test_empty_string(self):
+        assert parse_plate_id("") is None
+
+    def test_path_without_plate_segment(self):
+        # Some firmware / slicers report a bare filename without the plate marker.
+        assert parse_plate_id("/upload/my-model.gcode") is None
+
+    def test_similar_but_non_matching_names(self):
+        # "plate.gcode" (no number) and "nameplate_2.gcode" (substring) must not
+        # be mistaken for real plate markers. The regex anchors on `plate_<num>`.
+        assert parse_plate_id("/Metadata/plate.gcode") is None
+        assert parse_plate_id("/plates/3.gcode") is None
+
+    def test_substring_match_still_extracts(self):
+        # The regex isn't anchored to the start of a segment — any occurrence
+        # wins. This matches real Bambu paths where the segment is preceded by
+        # arbitrary directory noise, and matches the equivalent frontend regex.
+        assert parse_plate_id("/uploads/project/plate_5.gcode.md5") == 5

+ 260 - 0
backend/tests/unit/test_scheduler_watchdog.py

@@ -0,0 +1,260 @@
+"""Regression tests for ``_watchdog_print_start``.
+
+The watchdog reverts queue items to ``pending`` when a dispatched print never
+lands on the printer (half-broken MQTT session — #887/#936/#967). H2D firmware
+can sit at ``FINISH`` for 50+ seconds after accepting a ``project_file``
+command before flipping ``gcode_state`` to ``PREPARE``, which used to trip the
+state-only watchdog and cause the scheduler to revert the item; the subsequent
+successful dispatch then looked like a reprint of the just-finished job (#1078).
+
+The fix: treat ``subtask_id`` advancing past the pre-dispatch value as an
+equivalent "command landed" signal, and raise the timeout from 45 s to 90 s as
+belt-and-braces for slow transitions that also don't emit an early subtask_id
+tick.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+@pytest.fixture
+async def db_session():
+    """In-memory SQLite with one ``printing`` queue item at id=1."""
+    from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+    import backend.app.models  # noqa: F401  — populate Base.metadata
+    from backend.app.core.database import Base
+
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+
+    async with session_maker() as db:
+        db.add(PrintQueueItem(id=1, printer_id=42, archive_id=99, status="printing"))
+        await db.commit()
+
+    try:
+        yield session_maker
+    finally:
+        await engine.dispose()
+
+
+def _status(state: str, subtask_id: str | None = None):
+    """Minimal stand-in for PrinterState — only the two fields the watchdog reads."""
+    return SimpleNamespace(state=state, subtask_id=subtask_id)
+
+
+class TestWatchdogExitsEarlyOnPickup:
+    """The watchdog must NOT revert when the printer has clearly picked up the job."""
+
+    @pytest.mark.asyncio
+    async def test_exits_on_state_change(self, db_session):
+        """State transitioning away from pre_state is the primary "accepted" signal."""
+        get_status = MagicMock(return_value=_status("RUNNING", "OLD_SUBTASK"))
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
+            patch("backend.app.services.print_scheduler.async_session", db_session),
+        ):
+            await PrintScheduler._watchdog_print_start(
+                queue_item_id=1,
+                printer_id=42,
+                pre_state="FINISH",
+                pre_subtask_id="OLD_SUBTASK",
+                timeout=0.3,
+                poll_interval=0.05,
+            )
+
+        # Item should remain "printing" — watchdog recognised the pickup.
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "printing"
+
+    @pytest.mark.asyncio
+    async def test_exits_on_subtask_id_change_even_if_state_still_finish(self, db_session):
+        """Regression for #1078: H2D keeps state=FINISH for ~50 s after accepting
+        project_file, but subtask_id flips to our new submission_id almost
+        immediately. That must short-circuit the revert."""
+        get_status = MagicMock(return_value=_status("FINISH", "NEW_SUBTASK_12345"))
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
+            patch("backend.app.services.print_scheduler.async_session", db_session),
+        ):
+            await PrintScheduler._watchdog_print_start(
+                queue_item_id=1,
+                printer_id=42,
+                pre_state="FINISH",
+                pre_subtask_id="OLD_SUBTASK_99999",
+                timeout=0.3,
+                poll_interval=0.05,
+            )
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "printing", (
+                "subtask_id advanced past pre_subtask_id — the printer accepted our "
+                "project_file and the watchdog must not revert the queue item even "
+                "though state is still FINISH (#1078)"
+            )
+
+
+class TestWatchdogRevertsWhenStuck:
+    """Genuine half-broken sessions still need the revert + reconnect recovery."""
+
+    @pytest.mark.asyncio
+    async def test_reverts_when_neither_state_nor_subtask_id_changes(self, db_session):
+        """Both signals unchanged across the full timeout → revert to pending
+        and force MQTT reconnect (the #967 recovery path)."""
+        get_status = MagicMock(return_value=_status("FINISH", "OLD_SUBTASK"))
+        client = MagicMock()
+        get_client = MagicMock(return_value=client)
+
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
+            patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
+            patch("backend.app.services.print_scheduler.async_session", db_session),
+        ):
+            await PrintScheduler._watchdog_print_start(
+                queue_item_id=1,
+                printer_id=42,
+                pre_state="FINISH",
+                pre_subtask_id="OLD_SUBTASK",
+                timeout=0.2,
+                poll_interval=0.05,
+            )
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "pending"
+            assert item.started_at is None
+
+        client.force_reconnect_stale_session.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_default_timeout_is_90_seconds(self):
+        """The default timeout must cover slow H2D FINISH→PREPARE transitions
+        (~50 s observed). A 45 s default would trip on the exact scenario the
+        subtask_id check is guarding against, leaving no fallback for printers
+        that don't echo subtask_id."""
+        import inspect
+
+        sig = inspect.signature(PrintScheduler._watchdog_print_start)
+        assert sig.parameters["timeout"].default == 90.0
+
+
+class TestWatchdogFallbackBehaviour:
+    """Backwards-compat and defensive behaviour around missing data."""
+
+    @pytest.mark.asyncio
+    async def test_pre_subtask_id_none_falls_back_to_state_only(self, db_session):
+        """When we never captured a pre-dispatch subtask_id (e.g. printer just
+        connected), the watchdog must still work on the state signal alone —
+        and still revert when state stays unchanged, so half-broken sessions
+        are still recovered."""
+        get_status = MagicMock(return_value=_status("FINISH", "SOMETHING"))
+        get_client = MagicMock(return_value=None)
+
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
+            patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
+            patch("backend.app.services.print_scheduler.async_session", db_session),
+        ):
+            await PrintScheduler._watchdog_print_start(
+                queue_item_id=1,
+                printer_id=42,
+                pre_state="FINISH",
+                pre_subtask_id=None,
+                timeout=0.2,
+                poll_interval=0.05,
+            )
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "pending"
+
+    @pytest.mark.asyncio
+    async def test_current_subtask_id_none_does_not_trigger_early_exit(self, db_session):
+        """If the printer transiently reports subtask_id=None (e.g. during
+        reconnect), that must not be treated as "changed" — otherwise the
+        watchdog would exit early without a real pickup signal and leave the
+        item stuck in "printing" after a genuinely broken session."""
+        get_status = MagicMock(return_value=_status("FINISH", None))
+        get_client = MagicMock(return_value=None)
+
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
+            patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
+            patch("backend.app.services.print_scheduler.async_session", db_session),
+        ):
+            await PrintScheduler._watchdog_print_start(
+                queue_item_id=1,
+                printer_id=42,
+                pre_state="FINISH",
+                pre_subtask_id="OLD_SUBTASK",
+                timeout=0.2,
+                poll_interval=0.05,
+            )
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "pending"
+
+    @pytest.mark.asyncio
+    async def test_printer_disconnected_returns_without_reverting(self, db_session):
+        """If the printer drops during the watchdog window, don't touch the DB —
+        the reconnect path will sort the queue state out."""
+        get_status = MagicMock(return_value=None)
+
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
+            patch("backend.app.services.print_scheduler.async_session", db_session),
+        ):
+            await PrintScheduler._watchdog_print_start(
+                queue_item_id=1,
+                printer_id=42,
+                pre_state="FINISH",
+                pre_subtask_id="OLD_SUBTASK",
+                timeout=0.2,
+                poll_interval=0.05,
+            )
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "printing"
+
+    @pytest.mark.asyncio
+    async def test_no_revert_if_item_already_completed(self, db_session):
+        """If the print completed between watchdog arm-time and timeout (item is
+        no longer "printing"), the watchdog must not clobber whatever status it
+        ended up in — #967 race guard."""
+        # Move item on to "completed" before the watchdog fires.
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            item.status = "completed"
+            await db.commit()
+
+        get_status = MagicMock(return_value=_status("FINISH", "OLD_SUBTASK"))
+        get_client = MagicMock(return_value=None)
+
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
+            patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
+            patch("backend.app.services.print_scheduler.async_session", db_session),
+        ):
+            await PrintScheduler._watchdog_print_start(
+                queue_item_id=1,
+                printer_id=42,
+                pre_state="FINISH",
+                pre_subtask_id="OLD_SUBTASK",
+                timeout=0.2,
+                poll_interval=0.05,
+            )
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "completed"  # untouched

+ 30 - 0
backend/tests/unit/test_slot_preset_key.py

@@ -0,0 +1,30 @@
+"""Unit tests for slot-preset key derivation.
+
+Regression coverage for #1053: the backend's get_slot_presets response
+must use the same keying scheme as the frontend's getGlobalTrayId
+(amsHelpers.ts) so that AMS-HT mappings round-trip correctly.
+"""
+
+from backend.app.api.routes.printers import _slot_preset_key
+
+
+def test_regular_ams_uses_global_tray_id():
+    assert _slot_preset_key(0, 0) == 0
+    assert _slot_preset_key(0, 3) == 3
+    assert _slot_preset_key(1, 1) == 5
+    assert _slot_preset_key(2, 2) == 10
+    assert _slot_preset_key(3, 3) == 15
+
+
+def test_ams_ht_keyed_by_ams_id():
+    # AMS-HT is single-slot and shares its global tray id with the unit id;
+    # frontend getGlobalTrayId(amsId, 0, false) returns amsId for 128-135.
+    assert _slot_preset_key(128, 0) == 128
+    assert _slot_preset_key(129, 0) == 129
+    assert _slot_preset_key(135, 0) == 135
+
+
+def test_external_spool_uses_multiplied_id():
+    # External (ams_id=255) matches PrintersPage lookup: 255 * 4 + tray_id.
+    assert _slot_preset_key(255, 0) == 1020
+    assert _slot_preset_key(255, 1) == 1021

+ 131 - 0
backend/tests/unit/test_spool_schemas_rgba.py

@@ -0,0 +1,131 @@
+"""Schema validation tests for the spool rgba field (#1055).
+
+Three guarantees to lock in:
+1. SpoolCreate and SpoolUpdate must reject malformed rgba (short, long, non-hex)
+   on the write path — this is the "add a check" the reporter asked for.
+2. SpoolResponse must NOT validate rgba on the read path: a single legacy row
+   with a 7-char rgba (as in #1055) must not 500 the entire inventory list.
+3. Valid 8-char hex must continue to round-trip through all three schemas.
+"""
+
+import pytest
+from pydantic import ValidationError
+
+from backend.app.schemas.spool import SpoolCreate, SpoolUpdate
+
+
+class TestSpoolCreateRgbaValidation:
+    """Write-path validation on the create schema."""
+
+    def test_accepts_valid_8char_hex(self):
+        spool = SpoolCreate(material="PLA", rgba="FF00AAFF")
+        assert spool.rgba == "FF00AAFF"
+
+    def test_accepts_lowercase_hex(self):
+        spool = SpoolCreate(material="PLA", rgba="ff00aaff")
+        assert spool.rgba == "ff00aaff"
+
+    def test_accepts_null_rgba(self):
+        spool = SpoolCreate(material="PLA", rgba=None)
+        assert spool.rgba is None
+
+    def test_rejects_7char_rgba(self):
+        """#1055 repro: a 7-char 'FFFFFFF' must not be acceptable on create."""
+        with pytest.raises(ValidationError, match="rgba"):
+            SpoolCreate(material="PLA", rgba="FFFFFFF")
+
+    def test_rejects_6char_rgba(self):
+        """Plain RRGGBB without alpha must be rejected — frontend appends FF."""
+        with pytest.raises(ValidationError, match="rgba"):
+            SpoolCreate(material="PLA", rgba="FF0000")
+
+    def test_rejects_non_hex_char(self):
+        with pytest.raises(ValidationError, match="rgba"):
+            SpoolCreate(material="PLA", rgba="FFZZ00FF")
+
+
+class TestSpoolUpdateRgbaValidation:
+    """Write-path validation on the update schema — the gap that let #1055 happen.
+
+    Before the fix, SpoolUpdate.rgba was a bare `str | None` so a PATCH could
+    plant a 7-char value straight into the DB. That row then caused a 500 on
+    the next GET because SpoolResponse enforced the pattern at serialize time.
+    """
+
+    def test_accepts_valid_8char_hex(self):
+        update = SpoolUpdate(rgba="00FF00FF")
+        assert update.rgba == "00FF00FF"
+
+    def test_accepts_null_rgba(self):
+        update = SpoolUpdate(rgba=None)
+        assert update.rgba is None
+
+    def test_accepts_missing_rgba(self):
+        """Partial updates — rgba not present in payload — must still be valid."""
+        update = SpoolUpdate(material="PETG")
+        assert update.rgba is None
+
+    def test_rejects_7char_rgba(self):
+        """#1055 repro: PATCH must reject the exact pattern that bricked the reporter."""
+        with pytest.raises(ValidationError, match="rgba"):
+            SpoolUpdate(rgba="FFFFFFF")
+
+    def test_rejects_9char_rgba(self):
+        with pytest.raises(ValidationError, match="rgba"):
+            SpoolUpdate(rgba="FFFFFFFFF")
+
+    def test_rejects_non_hex_char(self):
+        with pytest.raises(ValidationError, match="rgba"):
+            SpoolUpdate(rgba="FFGG00FF")
+
+
+class TestSpoolResponseRgbaLeniency:
+    """Read-path leniency — a legacy bad row must never 500 the list endpoint.
+
+    Before the fix, SpoolResponse inherited the pattern from SpoolBase so a
+    single 7-char rgba in the DB blew up the whole inventory listing. The
+    response schema now treats rgba as an unconstrained Optional[str] — write
+    validation is where the pattern belongs; responses must tolerate whatever
+    is already persisted.
+    """
+
+    # SpoolResponse requires id + timestamps so it's easier to test via a
+    # minimal dict payload than by constructing a full instance.
+    @staticmethod
+    def _make_response_kwargs(**overrides):
+        from datetime import datetime
+
+        base = {
+            "id": 1,
+            "material": "PLA",
+            "created_at": datetime.fromisoformat("2026-01-01T00:00:00"),
+            "updated_at": datetime.fromisoformat("2026-01-01T00:00:00"),
+        }
+        base.update(overrides)
+        return base
+
+    def test_tolerates_7char_rgba_on_serialize(self):
+        """This is the #1055 bug fixed: malformed legacy rgba must serialize cleanly."""
+        from backend.app.schemas.spool import SpoolResponse
+
+        response = SpoolResponse(**self._make_response_kwargs(rgba="FFFFFFF"))
+        assert response.rgba == "FFFFFFF"
+
+    def test_tolerates_null_rgba(self):
+        from backend.app.schemas.spool import SpoolResponse
+
+        response = SpoolResponse(**self._make_response_kwargs(rgba=None))
+        assert response.rgba is None
+
+    def test_tolerates_non_hex_rgba(self):
+        """Even completely garbage rgba shouldn't crash the endpoint."""
+        from backend.app.schemas.spool import SpoolResponse
+
+        response = SpoolResponse(**self._make_response_kwargs(rgba="not-hex-at-all"))
+        assert response.rgba == "not-hex-at-all"
+
+    def test_passes_valid_rgba_through(self):
+        from backend.app.schemas.spool import SpoolResponse
+
+        response = SpoolResponse(**self._make_response_kwargs(rgba="FF00AAFF"))
+        assert response.rgba == "FF00AAFF"

+ 14 - 1
frontend/scripts/check-i18n-parity.mjs

@@ -207,7 +207,20 @@ if (isMainModule) {
   const infoReports = reports.filter((r) => !strictSet.has(codeOf(r.label)));
 
   printReports(strictReports, '=== STRICT locales (failures below fail CI) ===');
-  printReports(infoReports, '=== INFORMATIONAL locales (drift shown, does not fail CI) ===');
+  // Informational locales: show per-category drift counts only, not the
+  // full key lists — the leaf-count table below already gives the overall
+  // picture. Flip VERBOSE_INFO=1 to dump the full missing-key/placeholder
+  // reports when actually working on translations.
+  if (infoReports.length) {
+    if (process.env.VERBOSE_INFO === '1') {
+      printReports(infoReports, '=== INFORMATIONAL locales (drift shown, does not fail CI) ===');
+    } else {
+      console.error('\n=== INFORMATIONAL locales (drift summary; VERBOSE_INFO=1 for detail) ===');
+      for (const { label, items } of infoReports) {
+        console.error(`  ${label}: ${items.length}`);
+      }
+    }
+  }
 
   console.log('\nLocale leaf counts:');
   for (const [code, map] of Object.entries(locales)) {

+ 105 - 0
frontend/src/__tests__/components/AssignSpoolModal.test.tsx

@@ -134,4 +134,109 @@ describe('AssignSpoolModal', () => {
       expect(screen.getByText(/No manually added spools/i)).toBeInTheDocument();
     });
   });
+
+  it('lists spool with no slicer profile when material matches the tray (#1047)', async () => {
+    const spoolWithoutSlicerProfile = {
+      id: 10,
+      material: 'PLA',
+      subtype: 'Basic',
+      brand: 'Devil Design',
+      color_name: 'Red',
+      rgba: 'FF0000FF',
+      label_weight: 1000,
+      weight_used: 0,
+      tag_uid: null,
+      tray_uuid: null,
+      slicer_filament_name: null,
+      slicer_filament: null,
+    };
+    (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolWithoutSlicerProfile]);
+
+    render(
+      <AssignSpoolModal
+        {...defaultProps}
+        trayInfo={{
+          type: 'PLA',
+          material: 'PLA',
+          profile: 'Devil Design PLA Basic',
+          color: 'FF0000',
+          location: 'AMS 1 - Slot 1',
+        }}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText(/Devil Design/)).toBeInTheDocument();
+    });
+  });
+
+  it('lists spool with shorter material when tray advertises a qualified variant (#1047)', async () => {
+    // Spool.material = "PLA", tray material = "PLA Basic" — partial match in either direction.
+    const shortMaterialSpool = {
+      id: 11,
+      material: 'PLA',
+      subtype: 'Basic',
+      brand: 'Devil Design',
+      color_name: 'Red',
+      rgba: 'FF0000FF',
+      label_weight: 1000,
+      weight_used: 0,
+      tag_uid: null,
+      tray_uuid: null,
+      slicer_filament_name: null,
+      slicer_filament: null,
+    };
+    (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([shortMaterialSpool]);
+
+    render(
+      <AssignSpoolModal
+        {...defaultProps}
+        trayInfo={{
+          type: 'PLA Basic',
+          material: 'PLA Basic',
+          color: 'FF0000',
+          location: 'AMS 1 - Slot 1',
+        }}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText(/Devil Design/)).toBeInTheDocument();
+    });
+  });
+
+  it('lists spool whose slicer profile has an @printer qualifier that strips to the tray profile (#1047)', async () => {
+    const qualifiedProfileSpool = {
+      id: 12,
+      material: 'PLA',
+      subtype: 'Basic',
+      brand: 'Devil Design',
+      color_name: 'Red',
+      rgba: 'FF0000FF',
+      label_weight: 1000,
+      weight_used: 0,
+      tag_uid: null,
+      tray_uuid: null,
+      slicer_filament_name: 'Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)',
+    };
+    // Use a non-matching material to force the filter to rely on the profile path only.
+    (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([qualifiedProfileSpool]);
+
+    render(
+      <AssignSpoolModal
+        {...defaultProps}
+        trayInfo={{
+          type: 'ABS',
+          material: 'ABS',
+          profile: 'Devil Design PLA Basic',
+          color: 'FF0000',
+          location: 'AMS 1 - Slot 1',
+        }}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText(/Devil Design/)).toBeInTheDocument();
+    });
+  });
 });

+ 104 - 9
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx

@@ -185,24 +185,119 @@ describe('ConfigureAmsSlotModal', () => {
     expect(colorInput).toHaveValue('Red');
   });
 
-  it('derives tray_info_idx from base_id when filament_id is null', async () => {
-    // Mock the detail API to return base_id but no filament_id
+  it('sends PFUS setting_id as tray_info_idx when cloud detail has filament_id: null (#1053)', async () => {
+    // Cloud returns a user preset that inherits from a generic Bambu base and
+    // has no distinct filament_id of its own — this is how Bambu Cloud responds
+    // for custom presets built on top of "Generic ABS @BBL H2D" etc.
     (api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockResolvedValue({
       filament_id: null,
-      base_id: 'GFSL05_09',
+      base_id: 'GFSB99_07',
       name: '# Overture Matte PLA @BBL H2D',
     });
 
-    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      savedPresetId: 'PFUScd84f663d2c2ef',
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('# Overture Matte PLA @BBL H2D')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+
+    await waitFor(() => {
+      expect(api.configureAmsSlot).toHaveBeenCalled();
+    });
+
+    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
+    // Before the fix, this collapsed to 'GFB99' (Generic ABS's filament_id),
+    // which made OrcaSlicer/BambuStudio Sync Filaments resolve to "Generic ABS".
+    expect(payload.tray_info_idx).toBe('PFUScd84f663d2c2ef');
+    expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
+  });
+
+  it('uses cloud detail filament_id when present', async () => {
+    (api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockResolvedValue({
+      filament_id: 'P285e239',
+      base_id: 'GFSB99_07',
+      name: '# Overture Matte PLA @BBL H2D',
+    });
+
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      savedPresetId: 'PFUScd84f663d2c2ef',
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('# Overture Matte PLA @BBL H2D')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+
+    await waitFor(() => {
+      expect(api.configureAmsSlot).toHaveBeenCalled();
+    });
+
+    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
+    expect(payload.tray_info_idx).toBe('P285e239');
+    expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
+  });
+
+  it('sends short GF filament_id for Bambu GFS* presets (cloud detail not consulted)', async () => {
+    // Bambu-provided presets (GFS*) convert the setting_id → filament_id locally.
+    // The cloud detail endpoint must NOT be consulted for them; the rewrite that
+    // fixed #1053 preserves this pre-existing shortcut.
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      savedPresetId: 'GFSL05_09',
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Bambu PLA Basic @BBL X1C')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+
+    await waitFor(() => {
+      expect(api.configureAmsSlot).toHaveBeenCalled();
+    });
+
+    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
+    expect(payload.tray_info_idx).toBe('GFL05');
+    expect(payload.setting_id).toBe('GFSL05_09');
+    expect(api.getCloudSettingDetail).not.toHaveBeenCalled();
+  });
+
+  it('keeps default PFUS tray_info_idx when cloud detail fetch fails', async () => {
+    // Network/5xx from /cloud/settings/{id} must not abort the configure flow
+    // nor leave tray_info_idx empty — we fall back to the setting_id default.
+    (api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockRejectedValue(
+      new Error('cloud unreachable')
+    );
+
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      savedPresetId: 'PFUScd84f663d2c2ef',
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('# Overture Matte PLA @BBL H2D')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
 
-    // Wait for presets to load
     await waitFor(() => {
-      expect(api.getCloudSettings).toHaveBeenCalled();
+      expect(api.configureAmsSlot).toHaveBeenCalled();
     });
 
-    // Select a user preset (one without filament_id)
-    // Find and click the preset - this would require the preset to be in the list
-    // The actual tray_info_idx derivation happens during the configure mutation
+    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
+    expect(payload.tray_info_idx).toBe('PFUScd84f663d2c2ef');
+    expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
   });
 
   it('renders configure slot button', async () => {

+ 102 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -1244,4 +1244,106 @@ describe('PrintModal', () => {
       });
     });
   });
+
+  describe('cleanup_library_after_dispatch forwarding (#730)', () => {
+    // The Printers-page Direct-Print flow passes cleanupLibraryAfterDispatch so the
+    // transient LibraryFile created by FileUploadModal is deleted once the archive
+    // owns its own copy. File Manager / Project Detail flows leave the prop unset so
+    // their deliberately-added library entries survive the print.
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/library/files/:id', () => {
+          return HttpResponse.json({
+            id: 5,
+            filename: 'benchy.gcode.3mf',
+            file_type: '3mf',
+            folder_id: null,
+            project_id: null,
+            file_hash: null,
+            file_size_bytes: 1024,
+            thumbnail_path: null,
+            created_at: '2024-01-01T00:00:00Z',
+            updated_at: '2024-01-01T00:00:00Z',
+          });
+        }),
+        http.get('/api/v1/library/files/:id/plates', () => {
+          return HttpResponse.json({ is_multi_plate: false, plates: [] });
+        }),
+        http.get('/api/v1/library/files/:id/filament-requirements', () => {
+          return HttpResponse.json({ file_id: 5, filename: 'benchy.gcode.3mf', filaments: [] });
+        }),
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
+        }),
+      );
+    });
+
+    it('forwards cleanup_library_after_dispatch=true when the Direct-Print prop is set', async () => {
+      let capturedBody: Record<string, unknown> | null = null;
+      server.use(
+        http.post('/api/v1/library/files/:id/print', async ({ request }) => {
+          capturedBody = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 'abc', dispatch_position: 0 });
+        })
+      );
+      const user = userEvent.setup();
+
+      render(
+        <PrintModal
+          mode="reprint"
+          libraryFileId={5}
+          archiveName="Benchy"
+          cleanupLibraryAfterDispatch
+          initialSelectedPrinterIds={[1]}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByRole('button', { name: /^print$/i }));
+
+      await waitFor(() => {
+        expect(capturedBody).not.toBeNull();
+        expect(capturedBody?.cleanup_library_after_dispatch).toBe(true);
+      });
+    });
+
+    it('defaults to omitting cleanup_library_after_dispatch (File Manager / Project flows survive)', async () => {
+      let capturedBody: Record<string, unknown> | null = null;
+      server.use(
+        http.post('/api/v1/library/files/:id/print', async ({ request }) => {
+          capturedBody = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 'abc', dispatch_position: 0 });
+        })
+      );
+      const user = userEvent.setup();
+
+      render(
+        <PrintModal
+          mode="reprint"
+          libraryFileId={5}
+          archiveName="Benchy"
+          initialSelectedPrinterIds={[1]}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByRole('button', { name: /^print$/i }));
+
+      await waitFor(() => {
+        expect(capturedBody).not.toBeNull();
+      });
+      // Either omitted entirely or explicitly undefined — both interpret as "keep file"
+      expect(capturedBody?.cleanup_library_after_dispatch).toBeUndefined();
+    });
+  });
 });

+ 0 - 654
frontend/src/__tests__/components/PrinterQueueWidgetClearPlate.test.tsx

@@ -1,654 +0,0 @@
-/**
- * Tests for the PrinterQueueWidget clear plate behavior.
- *
- * When the printer is in FINISH or FAILED state and has pending queue items,
- * the widget shows a "Clear Plate & Start Next" button instead of the
- * passive queue link. After clicking, it shows a confirmation state.
- */
-
-import { describe, it, expect, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render } from '../utils';
-import { PrinterQueueWidget } from '../../components/PrinterQueueWidget';
-import { http, HttpResponse } from 'msw';
-import { server } from '../mocks/server';
-
-const mockQueueItems = [
-  {
-    id: 1,
-    printer_id: 1,
-    archive_id: 1,
-    position: 1,
-    status: 'pending',
-    archive_name: 'First Print',
-    printer_name: 'X1 Carbon',
-    print_time_seconds: 3600,
-    scheduled_time: null,
-  },
-  {
-    id: 2,
-    printer_id: 1,
-    archive_id: 2,
-    position: 2,
-    status: 'pending',
-    archive_name: 'Second Print',
-    printer_name: 'X1 Carbon',
-    print_time_seconds: 7200,
-    scheduled_time: null,
-  },
-];
-
-describe('PrinterQueueWidget - Clear Plate', () => {
-  beforeEach(() => {
-    server.use(
-      http.get('/api/v1/queue/', ({ request }) => {
-        const url = new URL(request.url);
-        const printerId = url.searchParams.get('printer_id');
-        if (printerId === '1') {
-          return HttpResponse.json(mockQueueItems);
-        }
-        return HttpResponse.json([]);
-      }),
-      http.post('/api/v1/printers/:id/clear-plate', () => {
-        return HttpResponse.json({ success: true, message: 'Plate cleared' });
-      })
-    );
-  });
-
-  describe('clear plate button visibility', () => {
-    it('shows clear plate button when printer state is FINISH', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={true} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
-      });
-    });
-
-    it('shows clear plate button when printer state is FAILED', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={true} requirePlateClear={true} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
-      });
-    });
-
-    it('shows passive link when printer state is IDLE', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="IDLE" />);
-
-      await waitFor(() => {
-        const link = screen.getByRole('link');
-        expect(link).toHaveAttribute('href', '/queue');
-      });
-
-      expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
-    });
-
-    it('shows passive link when printer state is RUNNING', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="RUNNING" />);
-
-      await waitFor(() => {
-        const link = screen.getByRole('link');
-        expect(link).toHaveAttribute('href', '/queue');
-      });
-    });
-
-    it('shows passive link when printerState is not provided', async () => {
-      render(<PrinterQueueWidget printerId={1} />);
-
-      await waitFor(() => {
-        const link = screen.getByRole('link');
-        expect(link).toHaveAttribute('href', '/queue');
-      });
-    });
-
-    it('shows passive link when FINISH but plateCleared is true', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={false} />);
-
-      await waitFor(() => {
-        const link = screen.getByRole('link');
-        expect(link).toHaveAttribute('href', '/queue');
-      });
-
-      expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
-    });
-
-    it('shows passive link when FAILED but plateCleared is true', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={false} />);
-
-      await waitFor(() => {
-        const link = screen.getByRole('link');
-        expect(link).toHaveAttribute('href', '/queue');
-      });
-
-      expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
-    });
-
-    // Regression for #961: after Auto Off cycles the printer it boots into IDLE while
-    // still awaiting plate-clear ack. The prompt must still show — the ack state, not
-    // the reported printer state, is the authoritative signal.
-    it('shows clear plate button in IDLE state when awaitingPlateClear is true (#961)', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="IDLE" awaitingPlateClear={true} requirePlateClear={true} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
-      });
-    });
-
-    it('shows clear plate button with no printerState when awaitingPlateClear is true', async () => {
-      // State may be null briefly after a reconnect; the widget must still gate on the flag.
-      render(<PrinterQueueWidget printerId={1} awaitingPlateClear={true} requirePlateClear={true} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('clear plate button shows queue info', () => {
-    it('shows next item name in clear plate mode', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('First Print')).toBeInTheDocument();
-      });
-    });
-
-    it('shows additional items badge in clear plate mode', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('+1')).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('clear plate action', () => {
-    it('shows confirmation state after clicking clear plate', async () => {
-      const user = userEvent.setup();
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={true} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
-      });
-
-      await user.click(screen.getByText('Clear Plate & Start Next'));
-
-      await waitFor(() => {
-        // Both the widget confirmation and the toast show this text
-        const elements = screen.getAllByText('Plate cleared — ready for next print');
-        expect(elements.length).toBeGreaterThanOrEqual(1);
-      });
-    });
-
-    it('shows error toast on API failure', async () => {
-      server.use(
-        http.post('/api/v1/printers/:id/clear-plate', () => {
-          return HttpResponse.json(
-            { detail: 'Printer not connected' },
-            { status: 400 }
-          );
-        })
-      );
-
-      const user = userEvent.setup();
-      render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={true} requirePlateClear={true} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
-      });
-
-      await user.click(screen.getByText('Clear Plate & Start Next'));
-
-      // Button should remain visible (not transition to success state)
-      await waitFor(() => {
-        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('empty queue', () => {
-    it('renders nothing in FINISH state with no queue items', async () => {
-      const { container } = render(<PrinterQueueWidget printerId={999} printerState="FINISH" awaitingPlateClear={true} />);
-
-      await waitFor(() => {
-        expect(container.querySelector('button')).not.toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('filament compatibility filtering', () => {
-    const petgQueueItems = [
-      {
-        id: 10,
-        printer_id: 1,
-        archive_id: 10,
-        position: 1,
-        status: 'pending',
-        archive_name: 'PETG Print',
-        printer_name: 'H2S',
-        print_time_seconds: 3600,
-        scheduled_time: null,
-        required_filament_types: ['PETG'],
-      },
-    ];
-
-    it('hides widget when queue item requires filament not loaded on printer', async () => {
-      server.use(
-        http.get('/api/v1/queue/', () => HttpResponse.json(petgQueueItems))
-      );
-
-      const { container } = render(
-        <PrinterQueueWidget
-          printerId={1}
-          printerState="FINISH"
-          awaitingPlateClear={true}
-          loadedFilamentTypes={new Set(['PLA'])}
-        />
-      );
-
-      // Wait for query to settle, then confirm widget is not rendered
-      await waitFor(() => {
-        expect(container.querySelector('button')).not.toBeInTheDocument();
-      });
-      expect(screen.queryByText('PETG Print')).not.toBeInTheDocument();
-    });
-
-    it('shows widget when queue item required filaments match loaded', async () => {
-      server.use(
-        http.get('/api/v1/queue/', () => HttpResponse.json(petgQueueItems))
-      );
-
-      render(
-        <PrinterQueueWidget
-          printerId={1}
-          printerState="FINISH"
-          awaitingPlateClear={true}
-          requirePlateClear={true}
-          loadedFilamentTypes={new Set(['PLA', 'PETG'])}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('PETG Print')).toBeInTheDocument();
-        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
-      });
-    });
-
-    it('shows widget when queue item has no required_filament_types', async () => {
-      // Default mockQueueItems have no required_filament_types
-      render(
-        <PrinterQueueWidget
-          printerId={1}
-          printerState="FINISH"
-          awaitingPlateClear={true}
-          requirePlateClear={true}
-          loadedFilamentTypes={new Set(['PLA'])}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('First Print')).toBeInTheDocument();
-        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
-      });
-    });
-
-    it('shows widget when loadedFilamentTypes prop is not provided', async () => {
-      server.use(
-        http.get('/api/v1/queue/', () => HttpResponse.json(petgQueueItems))
-      );
-
-      render(
-        <PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={true} />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('PETG Print')).toBeInTheDocument();
-        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
-      });
-    });
-
-    it('skips incompatible first item and shows compatible second item', async () => {
-      const mixedQueue = [
-        {
-          id: 10,
-          printer_id: 1,
-          archive_id: 10,
-          position: 1,
-          status: 'pending',
-          archive_name: 'PETG Print',
-          printer_name: 'H2S',
-          print_time_seconds: 3600,
-          scheduled_time: null,
-          required_filament_types: ['PETG'],
-        },
-        {
-          id: 11,
-          printer_id: 1,
-          archive_id: 11,
-          position: 2,
-          status: 'pending',
-          archive_name: 'PLA Print',
-          printer_name: 'H2S',
-          print_time_seconds: 1800,
-          scheduled_time: null,
-          required_filament_types: ['PLA'],
-        },
-      ];
-
-      server.use(
-        http.get('/api/v1/queue/', () => HttpResponse.json(mixedQueue))
-      );
-
-      render(
-        <PrinterQueueWidget
-          printerId={1}
-          printerState="FINISH"
-          awaitingPlateClear={true}
-          loadedFilamentTypes={new Set(['PLA'])}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('PLA Print')).toBeInTheDocument();
-      });
-      expect(screen.queryByText('PETG Print')).not.toBeInTheDocument();
-    });
-
-    it('matches filament types case-insensitively', async () => {
-      const lowercaseQueue = [
-        {
-          id: 10,
-          printer_id: 1,
-          archive_id: 10,
-          position: 1,
-          status: 'pending',
-          archive_name: 'Petg Print',
-          printer_name: 'H2S',
-          print_time_seconds: 3600,
-          scheduled_time: null,
-          required_filament_types: ['petg'],
-        },
-      ];
-
-      server.use(
-        http.get('/api/v1/queue/', () => HttpResponse.json(lowercaseQueue))
-      );
-
-      render(
-        <PrinterQueueWidget
-          printerId={1}
-          printerState="FINISH"
-          awaitingPlateClear={true}
-          requirePlateClear={true}
-          loadedFilamentTypes={new Set(['PETG'])}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('Petg Print')).toBeInTheDocument();
-        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('filament override color filtering', () => {
-    const whitePetgOverrideItem = [
-      {
-        id: 20,
-        printer_id: null,
-        archive_id: 20,
-        position: 1,
-        status: 'pending',
-        archive_name: 'White PETG Print',
-        printer_name: null,
-        print_time_seconds: 3600,
-        scheduled_time: null,
-        required_filament_types: ['PETG'],
-        filament_overrides: [{ slot_id: 1, type: 'PETG', color: '#FFFFFF' }],
-      },
-    ];
-
-    it('hides widget when override color does not match loaded filaments', async () => {
-      server.use(
-        http.get('/api/v1/queue/', () => HttpResponse.json(whitePetgOverrideItem))
-      );
-
-      const { container } = render(
-        <PrinterQueueWidget
-          printerId={1}
-          printerState="FINISH"
-          awaitingPlateClear={true}
-          loadedFilamentTypes={new Set(['PETG'])}
-          loadedFilaments={new Set(['PETG:0000ff'])}
-        />
-      );
-
-      await waitFor(() => {
-        expect(container.querySelector('button')).not.toBeInTheDocument();
-      });
-      expect(screen.queryByText('White PETG Print')).not.toBeInTheDocument();
-    });
-
-    it('shows widget when override color matches loaded filaments', async () => {
-      server.use(
-        http.get('/api/v1/queue/', () => HttpResponse.json(whitePetgOverrideItem))
-      );
-
-      render(
-        <PrinterQueueWidget
-          printerId={1}
-          printerState="FINISH"
-          awaitingPlateClear={true}
-          requirePlateClear={true}
-          loadedFilamentTypes={new Set(['PETG'])}
-          loadedFilaments={new Set(['PETG:ffffff'])}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('White PETG Print')).toBeInTheDocument();
-        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
-      });
-    });
-
-    it('normalizes override color format (strips # and lowercases)', async () => {
-      const upperCaseColorItem = [
-        {
-          id: 21,
-          printer_id: null,
-          archive_id: 21,
-          position: 1,
-          status: 'pending',
-          archive_name: 'Red PLA Print',
-          printer_name: null,
-          print_time_seconds: 3600,
-          scheduled_time: null,
-          required_filament_types: ['PLA'],
-          filament_overrides: [{ slot_id: 1, type: 'PLA', color: '#FF0000' }],
-        },
-      ];
-
-      server.use(
-        http.get('/api/v1/queue/', () => HttpResponse.json(upperCaseColorItem))
-      );
-
-      render(
-        <PrinterQueueWidget
-          printerId={1}
-          printerState="FINISH"
-          awaitingPlateClear={true}
-          loadedFilamentTypes={new Set(['PLA'])}
-          loadedFilaments={new Set(['PLA:ff0000'])}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('Red PLA Print')).toBeInTheDocument();
-      });
-    });
-
-    it('shows widget when no loadedFilaments prop is provided (no color filtering)', async () => {
-      server.use(
-        http.get('/api/v1/queue/', () => HttpResponse.json(whitePetgOverrideItem))
-      );
-
-      render(
-        <PrinterQueueWidget
-          printerId={1}
-          printerState="FINISH"
-          awaitingPlateClear={true}
-          loadedFilamentTypes={new Set(['PETG'])}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('White PETG Print')).toBeInTheDocument();
-      });
-    });
-
-    it('shows widget when queue item has no filament overrides', async () => {
-      // Default mockQueueItems have no filament_overrides
-      render(
-        <PrinterQueueWidget
-          printerId={1}
-          printerState="FINISH"
-          awaitingPlateClear={true}
-          loadedFilaments={new Set(['PLA:000000'])}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('First Print')).toBeInTheDocument();
-      });
-    });
-
-    it('matches any override when multiple overrides exist', async () => {
-      const multiOverrideItem = [
-        {
-          id: 22,
-          printer_id: null,
-          archive_id: 22,
-          position: 1,
-          status: 'pending',
-          archive_name: 'Multi Color Print',
-          printer_name: null,
-          print_time_seconds: 3600,
-          scheduled_time: null,
-          required_filament_types: ['PLA'],
-          filament_overrides: [
-            { slot_id: 1, type: 'PLA', color: '#FF0000' },
-            { slot_id: 2, type: 'PLA', color: '#00FF00' },
-          ],
-        },
-      ];
-
-      server.use(
-        http.get('/api/v1/queue/', () => HttpResponse.json(multiOverrideItem))
-      );
-
-      // Printer has green PLA but not red — should still match (at least one override)
-      render(
-        <PrinterQueueWidget
-          printerId={1}
-          printerState="FINISH"
-          awaitingPlateClear={true}
-          loadedFilamentTypes={new Set(['PLA'])}
-          loadedFilaments={new Set(['PLA:00ff00'])}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('Multi Color Print')).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('requirePlateClear setting', () => {
-    it('shows passive link when requirePlateClear is false even in FINISH state', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={false} />);
-
-      await waitFor(() => {
-        const link = screen.getByRole('link');
-        expect(link).toHaveAttribute('href', '/queue');
-      });
-
-      expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
-    });
-
-    it('shows passive link when requirePlateClear is false even in FAILED state', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FAILED" awaitingPlateClear={true} requirePlateClear={false} />);
-
-      await waitFor(() => {
-        const link = screen.getByRole('link');
-        expect(link).toHaveAttribute('href', '/queue');
-      });
-
-      expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
-    });
-
-    it('shows clear plate button when requirePlateClear is true (explicit)', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={true} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
-      });
-    });
-
-    it('shows passive link when requirePlateClear is not provided (defaults to false)', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
-
-      await waitFor(() => {
-        const link = screen.getByRole('link');
-        expect(link).toHaveAttribute('href', '/queue');
-      });
-
-      expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
-    });
-
-    it('still shows next item info in passive link when requirePlateClear is false', async () => {
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={false} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('First Print')).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('staged (manual_start) items', () => {
-    const stagedItems = [
-      { id: 10, printer_id: 1, archive_id: 1, position: 1, status: 'pending', archive_name: 'Staged Print 1', manual_start: true, scheduled_time: null },
-      { id: 11, printer_id: 1, archive_id: 2, position: 2, status: 'pending', archive_name: 'Staged Print 2', manual_start: true, scheduled_time: null },
-    ];
-
-    it('does not show clear plate button when all items are staged', async () => {
-      server.use(
-        http.get('/api/v1/queue/', () => HttpResponse.json(stagedItems)),
-      );
-
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} />);
-
-      // Should show the passive link (not the clear plate button)
-      await waitFor(() => {
-        expect(screen.getByText('Staged Print 1')).toBeInTheDocument();
-      });
-      expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
-    });
-
-    it('shows clear plate button when mix of staged and auto-dispatch items', async () => {
-      const mixedItems = [
-        { id: 10, printer_id: 1, archive_id: 1, position: 1, status: 'pending', archive_name: 'Staged Print', manual_start: true, scheduled_time: null },
-        { id: 11, printer_id: 1, archive_id: 2, position: 2, status: 'pending', archive_name: 'Auto Print', manual_start: false, scheduled_time: null },
-      ];
-      server.use(
-        http.get('/api/v1/queue/', () => HttpResponse.json(mixedItems)),
-      );
-
-      render(<PrinterQueueWidget printerId={1} printerState="FINISH" awaitingPlateClear={true} requirePlateClear={true} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
-      });
-    });
-  });
-});

+ 67 - 0
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -348,6 +348,73 @@ describe('SpoolFormModal weightTouched', () => {
     expect(payload).toHaveProperty('cost_per_kg', null);
   });
 
+  it('normalizes a malformed legacy rgba on edit-form load so PATCH is not rejected (#1055)', async () => {
+    // #1055 regression guard: a spool with a legacy 7-char rgba (e.g. 'FFFFFFF')
+    // was editable in the UI but any save 422'd because SpoolUpdate now enforces
+    // the 8-char pattern. The form must sanitize the loaded value to a valid
+    // default so users can edit unrelated fields without being forced to fix
+    // a color they may not even have noticed was broken.
+    const spoolWithBadRgba: InventorySpool = {
+      ...existingSpool,
+      rgba: 'FFFFFFF', // 7 chars — the exact #1055 trigger pattern
+    };
+
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolWithBadRgba}
+        currencySymbol="$"
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    const saveButton = screen.getByRole('button', { name: /save/i });
+    fireEvent.click(saveButton);
+
+    await waitFor(() => {
+      expect(api.updateSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
+    // The PATCH payload must carry a valid 8-char rgba — never the raw 7-char
+    // value loaded from the stale DB row.
+    expect(payload).toHaveProperty('rgba');
+    expect(typeof (payload as { rgba: unknown }).rgba).toBe('string');
+    expect((payload as { rgba: string }).rgba).toMatch(/^[0-9A-Fa-f]{8}$/);
+  });
+
+  it('preserves a valid existing rgba on edit (no forced default)', async () => {
+    // Sanity: the normalization only kicks in for malformed values. A valid
+    // 8-char rgba must round-trip untouched so untouched edits don't quietly
+    // reset a user's chosen color.
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={existingSpool} // rgba = 'FF0000FF' (valid)
+        currencySymbol="$"
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    const saveButton = screen.getByRole('button', { name: /save/i });
+    fireEvent.click(saveButton);
+
+    await waitFor(() => {
+      expect(api.updateSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
+    expect((payload as { rgba: string }).rgba).toBe('FF0000FF');
+  });
+
   it('displays correct catalog name when duplicates exist', async () => {
     const spoolWithCatalogId: InventorySpool = {
       ...existingSpool,

+ 119 - 0
frontend/src/__tests__/components/spool-form/ColorSectionHexInput.test.tsx

@@ -0,0 +1,119 @@
+/**
+ * Regression tests for the ColorSection hex input normalization (#1055).
+ *
+ * The original bug: typing 5 hex chars on the RRGGBB field produced a 7-char
+ * rgba ("FFFFF" + "FF" alpha = 7 chars); typing 7 chars left the 7-char string
+ * unpadded. Either way the value passed frontend validation, survived a backend
+ * PATCH (SpoolUpdate had no pattern constraint), and then bricked the entire
+ * Filaments page because SpoolResponse enforced the 8-char pattern on serialize
+ * and one bad row 500'd the whole list endpoint.
+ *
+ * The input now emits a valid 8-char RRGGBBAA on every keystroke: shorter input
+ * is right-padded with '0' and given FF alpha; 7-char input drops the stray 7th
+ * char; 8-char paste passes through unchanged.
+ *
+ * These tests drive the onChange handler directly (via fireEvent.change) rather
+ * than userEvent.type so each assertion exercises a specific input length. The
+ * component itself is a controlled input whose displayed value derives from
+ * formData.rgba.substring(0, 6), so the real-world UX of typing one char at a
+ * time is quirkier than the handler contract — but the handler contract is
+ * what this regression guards.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { I18nextProvider } from 'react-i18next';
+import i18n from '../../../i18n';
+import { ColorSection } from '../../../components/spool-form/ColorSection';
+import { defaultFormData } from '../../../components/spool-form/types';
+
+type UpdateField = <K extends keyof typeof defaultFormData>(
+  key: K,
+  value: (typeof defaultFormData)[K],
+) => void;
+
+function renderColorSection(overrides: Partial<typeof defaultFormData> = {}) {
+  const updateField = vi.fn() as ReturnType<typeof vi.fn> & UpdateField;
+  const formData = { ...defaultFormData, ...overrides };
+
+  render(
+    <I18nextProvider i18n={i18n}>
+      <ColorSection
+        formData={formData}
+        updateField={updateField}
+        recentColors={[]}
+        onColorUsed={vi.fn()}
+        catalogColors={[]}
+      />
+    </I18nextProvider>,
+  );
+
+  const hexInput = screen.getByPlaceholderText('RRGGBB') as HTMLInputElement;
+  return { hexInput, updateField };
+}
+
+function lastRgba(updateField: ReturnType<typeof vi.fn>): string | undefined {
+  const rgbaCalls = updateField.mock.calls.filter(([key]) => key === 'rgba');
+  return rgbaCalls.at(-1)?.[1] as string | undefined;
+}
+
+describe('ColorSection hex input normalization (#1055)', () => {
+  it('pads a 6-char RRGGBB to 8-char RRGGBBAA with FF alpha', () => {
+    const { hexInput, updateField } = renderColorSection();
+    fireEvent.change(hexInput, { target: { value: 'FF0000' } });
+    expect(lastRgba(updateField)).toBe('FF0000FF');
+  });
+
+  it('passes an 8-char RRGGBBAA paste through unchanged', () => {
+    const { hexInput, updateField } = renderColorSection();
+    fireEvent.change(hexInput, { target: { value: '00112233' } });
+    expect(lastRgba(updateField)).toBe('00112233');
+  });
+
+  it('drops the stray 7th char — the exact #1055 trigger pattern', () => {
+    const { hexInput, updateField } = renderColorSection();
+    fireEvent.change(hexInput, { target: { value: 'FFFFFFF' } });
+    // Previously emitted "FFFFFFF" (7 chars) verbatim. Must now be 8 chars.
+    const rgba = lastRgba(updateField);
+    expect(rgba).toBe('FFFFFFFF');
+    expect(rgba).toMatch(/^[0-9A-F]{8}$/);
+  });
+
+  it('pads a 5-char input to 8 chars instead of emitting a 7-char rgba', () => {
+    // 5-char + 'FF' alpha = 7 chars was the other #1055 trigger pattern.
+    // Right-pad RGB to 6 with '0' so the output is always 8 chars.
+    const { hexInput, updateField } = renderColorSection();
+    fireEvent.change(hexInput, { target: { value: 'FFFFF' } });
+    const rgba = lastRgba(updateField);
+    expect(rgba).toBe('FFFFF0FF');
+    expect(rgba).toMatch(/^[0-9A-F]{8}$/);
+  });
+
+  it('pads any partial input to exactly 8 chars — never 7', () => {
+    // The essential invariant: for every legal input length (0..8), the
+    // emitted rgba must be 8 chars. Anything else risks reintroducing #1055.
+    const { hexInput, updateField } = renderColorSection();
+    for (const input of ['', 'F', 'FF', 'FFF', 'FFFF', 'FFFFF', 'FFFFFF', 'FFFFFFF', 'FFFFFFFF']) {
+      updateField.mockClear();
+      fireEvent.change(hexInput, { target: { value: input } });
+      const rgba = lastRgba(updateField);
+      expect(rgba).toBeDefined();
+      expect(rgba!.length).toBe(8);
+      expect(rgba).toMatch(/^[0-9A-F]{8}$/);
+    }
+  });
+
+  it('ignores input past 8 chars (no updateField call)', () => {
+    const { hexInput, updateField } = renderColorSection({ rgba: 'FFFFFFFF' });
+    updateField.mockClear();
+    fireEvent.change(hexInput, { target: { value: '0011223344' } });
+    expect(updateField.mock.calls.filter(([k]) => k === 'rgba')).toHaveLength(0);
+  });
+
+  it('strips non-hex characters before normalizing', () => {
+    // '#FF00ZZ' → strip '#' and non-hex → 'FF00' (4 chars) → pad to 6 + FF alpha
+    const { hexInput, updateField } = renderColorSection();
+    fireEvent.change(hexInput, { target: { value: '#FF00ZZ' } });
+    expect(lastRgba(updateField)).toBe('FF0000FF');
+  });
+});

+ 46 - 0
frontend/src/__tests__/pages/PrintersPageFormatPrintName.test.ts

@@ -0,0 +1,46 @@
+/**
+ * Unit tests for the formatPrintName helper on PrintersPage.
+ *
+ * Regression coverage for the #881 follow-up: when the printer card has an
+ * archive-linked plate label (resolved from the backend's current_archive_id
+ * + the archive's is_multi_plate plate list), the label must take precedence
+ * over the gcode_file regex fallback, including for plate 1.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { formatPrintName } from '../../utils/printName';
+
+// Minimal translator stub: returns the fallback with the plate number interpolated
+// the same way i18next would. Keeps these tests independent of the i18n setup.
+const t = (_key: string, fallback: string, opts?: Record<string, unknown>) =>
+  fallback.replace('{{number}}', String(opts?.number ?? ''));
+
+describe('formatPrintName', () => {
+  it('returns the name unchanged when neither plate source is available', () => {
+    expect(formatPrintName('Benchy', null, t)).toBe('Benchy');
+  });
+
+  it('appends gcode-file plate number only when > 1 (single-plate noise guard)', () => {
+    // Plate 1 from gcode_file alone is ambiguous (could be a single-plate 3MF)
+    // so the legacy fallback path keeps it silent.
+    expect(formatPrintName('Benchy', '/Metadata/plate_1.gcode', t)).toBe('Benchy');
+    expect(formatPrintName('Benchy', '/Metadata/plate_2.gcode', t)).toBe('Benchy — Plate 2');
+  });
+
+  it('uses plateLabel verbatim when provided, overriding the gcode_file fallback', () => {
+    // plateLabel comes from the archive lookup and is already disambiguated
+    // (only set when is_multi_plate === true). It must show even for plate 1.
+    expect(formatPrintName('Benchy', '/Metadata/plate_1.gcode', t, 'Plate 1')).toBe('Benchy — Plate 1');
+    expect(formatPrintName('Benchy', '/Metadata/plate_2.gcode', t, 'Small Parts')).toBe('Benchy — Small Parts');
+  });
+
+  it('returns empty string when name is missing, regardless of plate info', () => {
+    expect(formatPrintName(null, '/Metadata/plate_2.gcode', t)).toBe('');
+    expect(formatPrintName(null, null, t, 'Plate 3')).toBe('');
+  });
+
+  it('treats null/empty plateLabel as absent and falls through to gcode_file parsing', () => {
+    expect(formatPrintName('Benchy', '/Metadata/plate_2.gcode', t, null)).toBe('Benchy — Plate 2');
+    expect(formatPrintName('Benchy', '/Metadata/plate_2.gcode', t, '')).toBe('Benchy — Plate 2');
+  });
+});

+ 3 - 0
frontend/src/api/client.ts

@@ -220,6 +220,8 @@ export interface PrinterStatus {
   state: string | null;
   current_print: string | null;
   subtask_name: string | null;
+  current_archive_id: number | null;
+  current_plate_id: number | null;
   gcode_file: string | null;
   progress: number | null;
   remaining_time: number | null;
@@ -4643,6 +4645,7 @@ export const api = {
       timelapse?: boolean;
       use_ams?: boolean;
       project_id?: number;
+      cleanup_library_after_dispatch?: boolean;
     }
   ) =>
     request<BackgroundDispatchResponse>(

+ 28 - 7
frontend/src/components/AssignSpoolModal.tsx

@@ -111,12 +111,18 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     return 'none';
   };
 
+  // Bambu Studio / OrcaSlicer profile names carry a printer/nozzle/variant qualifier after
+  // `@` (e.g. "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"), while the tray's
+  // profile is typically the bare base name. Strip the qualifier before comparing so identical
+  // base profiles don't trigger a mismatch warning (#1047).
+  const stripProfileQualifier = (value: string) => value.split('@')[0].trim();
+
   const checkProfileMatch = (
     spoolProfile: string | undefined | null,
     trayProfile: string | undefined | null
   ): boolean => {
-    const normalizedSpoolProfile = normalizeValue(spoolProfile);
-    const normalizedTrayProfile = normalizeValue(trayProfile);
+    const normalizedSpoolProfile = stripProfileQualifier(normalizeValue(spoolProfile));
+    const normalizedTrayProfile = stripProfileQualifier(normalizeValue(trayProfile));
 
     if (!normalizedSpoolProfile || !normalizedTrayProfile) return false;
 
@@ -138,14 +144,29 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     !assignedSpoolIds.has(spool.id) && (isExternalSlot || (!spool.tag_uid && !spool.tray_uuid))
   );
 
-  // Filtering logic with toggle: search filter always applies, AMS tray profile filter is optional
+  // Filtering logic with toggle: search filter always applies, AMS tray profile filter is optional.
+  // Show a spool if EITHER the slicer profile matches exactly OR the material overlaps with the
+  // tray's material (partial-match both directions — "PLA" spool accepts a "PLA Basic" slot and
+  // vice versa). Manually-added inventory spools typically have no slicer_filament_name; gating
+  // on strict profile equality alone hid them even when the material matched (#1047).
   let filteredSpools = manualSpools;
   if (!disableFiltering) {
-    if (trayInfo?.profile || trayInfo?.type) {
-      const trayProfile = normalizeValue(trayInfo.profile || trayInfo.type);
+    const trayProfile = stripProfileQualifier(normalizeValue(trayInfo?.profile));
+    const trayMaterial = normalizeValue(trayInfo?.material || trayInfo?.type);
+    if (trayProfile || trayMaterial) {
       filteredSpools = filteredSpools?.filter((spool: InventorySpool) => {
-        const spoolProfile = normalizeValue(spool.slicer_filament_name || spool.slicer_filament);
-        return trayProfile && spoolProfile && spoolProfile === trayProfile;
+        const spoolProfile = stripProfileQualifier(normalizeValue(spool.slicer_filament_name || spool.slicer_filament));
+        const spoolMaterial = normalizeValue(spool.material);
+        if (trayProfile && spoolProfile && spoolProfile === trayProfile) return true;
+        if (trayMaterial && spoolMaterial) {
+          return (
+            spoolMaterial === trayMaterial ||
+            trayMaterial.includes(spoolMaterial) ||
+            spoolMaterial.includes(trayMaterial)
+          );
+        }
+        // Neither side has filterable info on whatever dimension remains — show it.
+        return !spoolProfile && !spoolMaterial;
       });
     }
   }

+ 4 - 5
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -369,19 +369,18 @@ export function ConfigureAmsSlotModal({
         trayInfoIdx = builtinFilamentId!;
         settingId = '';
       } else {
-        // Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id
         trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
         settingId = selectedPresetId;
 
-        // For user presets (not starting with GF), fetch the detail to get the real filament_id
+        // User cloud presets may carry a distinct filament_id in the cloud detail
+        // (e.g. "P285e239"); prefer it when present. Never fall back to base_id —
+        // that collapses custom presets to the inherited generic's filament_id and
+        // makes the slicer resolve the slot to "Generic …" instead (#1053).
         if (!selectedPresetId.startsWith('GFS')) {
           try {
             const detail = await api.getCloudSettingDetail(selectedPresetId);
             if (detail.filament_id) {
               trayInfoIdx = detail.filament_id;
-            } else if (detail.base_id) {
-              trayInfoIdx = convertToTrayInfoIdx(detail.base_id);
-              console.log(`Derived tray_info_idx from base_id: ${detail.base_id} -> ${trayInfoIdx}`);
             }
           } catch (e) {
             console.warn('Failed to fetch preset detail for filament_id:', e);

+ 10 - 0
frontend/src/components/Layout.tsx

@@ -1030,6 +1030,16 @@ export function Layout() {
             </CardHeader>
             <CardContent>
               <div className="space-y-4">
+                <input
+                  type="text"
+                  name="username"
+                  autoComplete="username"
+                  value={user?.username ?? ''}
+                  readOnly
+                  hidden
+                  aria-hidden="true"
+                  tabIndex={-1}
+                />
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
                     {t('changePassword.currentPassword')}

+ 2 - 0
frontend/src/components/PrintModal/index.tsx

@@ -49,6 +49,7 @@ export function PrintModal({
   onClose,
   onSuccess,
   projectId,
+  cleanupLibraryAfterDispatch,
 }: PrintModalProps) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -737,6 +738,7 @@ export function PrintModal({
                   ams_mapping: printerMapping,
                   ...printOptions,
                   project_id: projectId,
+                  cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
                 });
               } else {
                 // project_id is intentionally omitted here: reprintArchive targets an existing

+ 3 - 0
frontend/src/components/PrintModal/types.ts

@@ -34,6 +34,9 @@ export interface PrintModalProps {
   onSuccess?: () => void;
   /** Project ID to associate the resulting archive with (only when triggered from project view) */
   projectId?: number;
+  /** Delete the LibraryFile after dispatch — used by the Printers-page Direct-Print flow
+   *  so transient uploads don't linger in File Manager. Only applies to library-file prints. */
+  cleanupLibraryAfterDispatch?: boolean;
 }
 
 /**

+ 8 - 84
frontend/src/components/PrinterQueueWidget.tsx

@@ -1,117 +1,41 @@
-import { useEffect } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Clock, Calendar, ChevronRight, Loader2, CircleCheck } from 'lucide-react';
+import { useQuery } from '@tanstack/react-query';
+import { Clock, Calendar, ChevronRight } from 'lucide-react';
 import { Link } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
-import { useAuth } from '../contexts/AuthContext';
-import { useToast } from '../contexts/ToastContext';
 import { formatRelativeTime } from '../utils/date';
 import { filterCompatibleQueueItems } from '../utils/printer';
 
 interface PrinterQueueWidgetProps {
   printerId: number;
   printerModel?: string | null;
-  /** @deprecated use awaitingPlateClear — kept so existing callers/tests still compile */
-  printerState?: string | null;
-  awaitingPlateClear?: boolean;
-  requirePlateClear?: boolean;
   loadedFilamentTypes?: Set<string>;
   loadedFilaments?: Set<string>;  // "TYPE:rrggbb" pairs for filament override color matching
 }
 
-export function PrinterQueueWidget({ printerId, printerModel, awaitingPlateClear, requirePlateClear = false, loadedFilamentTypes, loadedFilaments }: PrinterQueueWidgetProps) {
+export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentTypes, loadedFilaments }: PrinterQueueWidgetProps) {
   const { t } = useTranslation();
-  const queryClient = useQueryClient();
-  const { showToast } = useToast();
-  const { hasPermission } = useAuth();
   const { data: queue } = useQuery({
     queryKey: ['queue', printerId, 'pending', printerModel],
     queryFn: () => api.getQueue(printerId, 'pending', printerModel || undefined),
     refetchInterval: 30000,
   });
 
-  const clearPlateMutation = useMutation({
-    mutationFn: () => api.clearPlate(printerId),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['queue', printerId] });
-      queryClient.invalidateQueries({ queryKey: ['printerStatus', printerId] });
-      showToast(t('queue.clearPlateSuccess'), 'success');
-    },
-    onError: (err: Error) => {
-      showToast(err.message, 'error');
-    },
-  });
-
-  // Reset mutation state when the awaiting flag clears so the button is clickable
-  // again after the next finished print (fixes #912). The flag is the authoritative
-  // signal — state alone is not reliable across power cycles (#961).
-  useEffect(() => {
-    if (!awaitingPlateClear) {
-      clearPlateMutation.reset();
-    }
-  }, [awaitingPlateClear, clearPlateMutation]);
-
   // Filter queue to items this printer can actually print (filament type + color check)
   const compatibleQueue = queue ? filterCompatibleQueueItems(queue, loadedFilamentTypes, loadedFilaments) : undefined;
-
-  // Split into auto-dispatchable vs staged (manual_start) items
-  const autoDispatchQueue = compatibleQueue?.filter(item => !item.manual_start) ?? [];
   const totalPending = compatibleQueue?.length || 0;
 
   if (totalPending === 0) {
     return null;
   }
 
-  const nextAutoItem = autoDispatchQueue[0];
   const nextItem = compatibleQueue?.[0];
-  // Prompt "Clear Plate & Start Next" whenever the backend flags the printer as awaiting
-  // acknowledgment. Don't gate on reported state: after Auto Off cycles the printer, it
-  // boots into IDLE while still awaiting — the prompt must survive that (#961). The flag
-  // is cleared by the backend on ack or when the next print dispatches.
-  const needsClearPlate = requirePlateClear && !!awaitingPlateClear && autoDispatchQueue.length > 0;
-
-  if (needsClearPlate) {
-    const displayItem = nextAutoItem || nextItem;
-    return (
-      <div className="mb-3 p-3 bg-bambu-dark rounded-lg border border-yellow-400/30">
-        <div className="flex items-center gap-3 mb-2">
-          <Calendar className="w-5 h-5 text-yellow-400 flex-shrink-0" />
-          <div className="min-w-0 flex-1">
-            <p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
-            <p className="text-sm text-white truncate">
-              {displayItem?.archive_name || displayItem?.library_file_name || `File #${displayItem?.archive_id || displayItem?.library_file_id}`}
-            </p>
-          </div>
-          {totalPending > 1 && (
-            <span className="text-xs px-1.5 py-0.5 bg-yellow-400/20 text-yellow-400 rounded flex-shrink-0">
-              +{totalPending - 1}
-            </span>
-          )}
-        </div>
-        {clearPlateMutation.isSuccess ? (
-          <div className="w-full py-2 px-3 rounded-lg bg-bambu-green/10 border border-bambu-green/20 text-bambu-green text-sm flex items-center justify-center gap-2">
-            <CircleCheck className="w-4 h-4" />
-            {t('queue.plateReady')}
-          </div>
-        ) : (
-          <button
-            onClick={() => clearPlateMutation.mutate()}
-            disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}
-            className="w-full py-2 px-3 rounded-lg bg-bambu-green/20 border border-bambu-green/40 text-bambu-green hover:bg-bambu-green/30 transition-colors text-sm font-medium flex items-center justify-center gap-2 disabled:opacity-50"
-          >
-            {clearPlateMutation.isPending ? (
-              <Loader2 className="w-4 h-4 animate-spin" />
-            ) : (
-              <CircleCheck className="w-4 h-4" />
-            )}
-            {t('queue.clearPlate')}
-          </button>
-        )}
-      </div>
-    );
-  }
 
+  // Passive next-in-queue preview. Plate-clear acknowledgment is handled by the
+  // card-level "Mark plate as cleared" button (PrintersPage.tsx). Having a
+  // second button in this widget caused the two controls to overlap whenever
+  // the plate-clear gate was up with auto-dispatch items queued — both POSTed
+  // to the same /clear-plate endpoint, so the widget button was pure noise.
   return (
     <Link
       to="/queue"

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

@@ -311,7 +311,7 @@ export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModa
         >
           {status?.cover_url ? (
             <img
-              src={`${status.cover_url}?view=top`}
+              src={withStreamToken(`${status.cover_url}?view=top`)}
               alt={t('printers.printPreview')}
               className="w-full h-full object-contain rounded-lg bg-gray-900"
             />

+ 11 - 1
frontend/src/components/SpoolFormModal.tsx

@@ -253,12 +253,22 @@ export function SpoolFormModal({
   useEffect(() => {
     if (isOpen) {
       if (spool) {
+        // Legacy rows may carry a malformed rgba (e.g. the 7-char 'FFFFFFF'
+        // from #1055 before the create/update pattern was enforced). The
+        // backend SpoolUpdate schema rejects non-8-char hex on PATCH, so
+        // re-submitting a malformed value would 422 every edit on that spool
+        // — even edits that don't touch color. Normalize on load: any value
+        // that isn't exactly 8 hex chars falls back to the default, so the
+        // user can save unrelated fields (weight, material, note) without
+        // first being forced to fix a color they may not even be aware is
+        // broken. Saving also purges the bad value from the DB.
+        const validRgba = spool.rgba && /^[0-9A-Fa-f]{8}$/.test(spool.rgba) ? spool.rgba : '808080FF';
         setFormData({
           material: spool.material || '',
           subtype: spool.subtype || '',
           brand: spool.brand || '',
           color_name: spool.color_name || '',
-          rgba: spool.rgba || '808080FF',
+          rgba: validRgba,
           label_weight: spool.label_weight || 1000,
           core_weight: spool.core_weight || 250,
           core_weight_catalog_id: spool.core_weight_catalog_id ?? null,

+ 12 - 2
frontend/src/components/spool-form/ColorSection.tsx

@@ -298,8 +298,18 @@ export function ColorSection({
                 placeholder="RRGGBB"
                 value={currentHex.toUpperCase()}
                 onChange={(e) => {
-                  const val = e.target.value.replace('#', '').replace(/[^0-9A-Fa-f]/g, '');
-                  if (val.length <= 8) updateField('rgba', val.toUpperCase() + (val.length <= 6 ? 'FF' : ''));
+                  const val = e.target.value.replace('#', '').replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
+                  if (val.length > 8) return;
+                  // Normalize to a valid 8-char RRGGBBAA on every keystroke so
+                  // the backend never receives a malformed rgba (#1055). 8-char
+                  // paste passes through; 7-char drops the stray typo; anything
+                  // shorter is right-padded with '0' to a full RGB triplet and
+                  // given FF alpha. Prior logic emitted 3/5/7-char strings mid-
+                  // typing that PATCH would accept (SpoolUpdate was unchecked)
+                  // and later 500 the list endpoint on response serialization.
+                  const rgba =
+                    val.length === 8 ? val : val.length === 7 ? val.substring(0, 6) + 'FF' : val.padEnd(6, '0') + 'FF';
+                  updateField('rgba', rgba);
                 }}
               />
             </div>

+ 0 - 2
frontend/src/i18n/locales/de.ts

@@ -904,9 +904,7 @@ export default {
     },
     addedBy: 'Hinzugefügt von {{name}}',
     nextInQueue: 'Nächster in der Warteschlange',
-    clearPlate: 'Druckplatte freigeben & Nächsten starten',
     clearPlateSuccess: 'Druckplatte freigegeben — bereit für nächsten Druck',
-    plateReady: 'Druckplatte freigegeben — bereit für nächsten Druck',
     plateNumber: 'Platte {{index}}',
     // Batch / quantity
     quantity: 'Menge',

+ 0 - 2
frontend/src/i18n/locales/en.ts

@@ -904,9 +904,7 @@ export default {
     },
     addedBy: 'Added by {{name}}',
     nextInQueue: 'Next in queue',
-    clearPlate: 'Clear Plate & Start Next',
     clearPlateSuccess: 'Plate cleared — ready for next print',
-    plateReady: 'Plate cleared — ready for next print',
     plateNumber: 'Plate {{index}}',
     // Batch / quantity
     quantity: 'Quantity',

+ 0 - 2
frontend/src/i18n/locales/fr.ts

@@ -897,9 +897,7 @@ export default {
     },
     addedBy: 'Ajouté par {{name}}',
     nextInQueue: 'Prochain en file',
-    clearPlate: 'Vider plateau & lancer suivant',
     clearPlateSuccess: 'Plateau vidé — prêt pour l\'impression suivante',
-    plateReady: 'Plateau vidé — prêt pour l\'impression suivante',
     plateNumber: 'Plateau {{index}}',
     // Batch / quantity
     quantity: 'Quantité',

+ 0 - 2
frontend/src/i18n/locales/it.ts

@@ -897,9 +897,7 @@ export default {
     },
     addedBy: 'Aggiunto da {{name}}',
     nextInQueue: 'Prossimo in coda',
-    clearPlate: 'Libera piatto e avvia il prossimo',
     clearPlateSuccess: 'Piatto liberato — pronto per la prossima stampa',
-    plateReady: 'Piatto liberato — pronto per la prossima stampa',
     plateNumber: 'Piatto {{index}}',
     // Batch / quantity
     quantity: 'Quantità',

+ 0 - 2
frontend/src/i18n/locales/ja.ts

@@ -896,9 +896,7 @@ export default {
     },
     addedBy: '{{username}}が追加',
     nextInQueue: '次のキュー',
-    clearPlate: 'プレートをクリアして次を開始',
     clearPlateSuccess: 'プレートをクリアしました — 次の印刷の準備完了',
-    plateReady: 'プレートをクリアしました — 次の印刷の準備完了',
     plateNumber: 'プレート {{index}}',
     // Batch / quantity
     quantity: '数量',

+ 0 - 2
frontend/src/i18n/locales/pt-BR.ts

@@ -897,9 +897,7 @@ export default {
     },
     addedBy: 'Adicionado por {{name}}',
     nextInQueue: 'Próximo na fila',
-    clearPlate: 'Limpar Placa e Iniciar Próximo',
     clearPlateSuccess: 'Placa limpa — pronta para a próxima impressão',
-    plateReady: 'Placa limpa — pronta para a próxima impressão',
     plateNumber: 'Placa {{index}}',
     // Batch / quantity
     quantity: 'Quantidade',

+ 0 - 2
frontend/src/i18n/locales/zh-CN.ts

@@ -904,9 +904,7 @@ export default {
     },
     addedBy: '由 {{name}} 添加',
     nextInQueue: '队列中的下一个',
-    clearPlate: '清理打印板并开始下一个',
     clearPlateSuccess: '打印板已清理 — 准备进行下一个打印',
-    plateReady: '打印板已清理 — 准备进行下一个打印',
     plateNumber: '板 {{index}}',
     // Batch / quantity
     quantity: '数量',

+ 0 - 2
frontend/src/i18n/locales/zh-TW.ts

@@ -904,9 +904,7 @@ export default {
     },
     addedBy: '由 {{name}} 新增',
     nextInQueue: '佇列中的下一個',
-    clearPlate: '清理列印板並開始下一個',
     clearPlateSuccess: '列印板已清理 — 準備進行下一個列印',
-    plateReady: '列印板已清理 — 準備進行下一個列印',
     plateNumber: '板 {{index}}',
     // Batch / quantity
     quantity: '數量',

+ 35 - 23
frontend/src/pages/PrintersPage.tsx

@@ -1,5 +1,6 @@
 import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
 import { compareFwVersions } from '../utils/firmwareVersion';
+import { formatPrintName } from '../utils/printName';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
@@ -90,17 +91,6 @@ import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors'
 // Color names resolve via getColorName() which reads the backend color_catalog
 // (loaded once by ColorCatalogProvider). No hardcoded tables here — see #857.
 
-// Extract plate number from gcode_file path and append to print name
-function formatPrintName(name: string | null, gcodeFile: string | null | undefined, t: (key: string, fallback: string, opts?: Record<string, unknown>) => string): string {
-  if (!name) return '';
-  if (!gcodeFile) return name;
-  const match = gcodeFile.match(/plate_(\d+)\.gcode/);
-  if (match && parseInt(match[1], 10) > 1) {
-    return `${name} — ${t('printers.plateNumber', 'Plate {{number}}', { number: match[1] })}`;
-  }
-  return name;
-}
-
 // Format K value with 3 decimal places, default to 0.020 if null
 function formatKValue(k: number | null | undefined): string {
   const value = k ?? 0.020;
@@ -1528,6 +1518,23 @@ function PrinterCard({
     staleTime: 2 * 60 * 1000, // 2 minutes
   });
 
+  // Fetch plate list for the archive linked to the active print (#881 follow-up).
+  // Only queried when there's a running print backed by an archive; shared
+  // React Query cache with the Queue / Archives pages keeps it cheap.
+  const activeArchiveId =
+    (status?.state === 'RUNNING' || status?.state === 'PAUSE') ? status?.current_archive_id ?? null : null;
+  const { data: activeArchivePlates } = useQuery({
+    queryKey: ['archive-plates', activeArchiveId],
+    queryFn: () => api.getArchivePlates(activeArchiveId!),
+    enabled: activeArchiveId != null,
+    staleTime: 5 * 60 * 1000,
+  });
+  const activePlateLabel = (() => {
+    if (!activeArchivePlates?.is_multi_plate || status?.current_plate_id == null) return null;
+    const plate = activeArchivePlates.plates.find(p => p.index === status.current_plate_id);
+    return plate?.name || t('printers.plateNumber', 'Plate {{number}}', { number: status.current_plate_id });
+  })();
+
   // Fetch user-defined AMS friendly names from the database
   const { data: amsLabels, refetch: refetchAmsLabels } = useQuery({
     queryKey: ['amsLabels', printer.id],
@@ -2718,7 +2725,7 @@ function PrinterCard({
                     {/* Cover Image */}
                     <CoverImage
                       url={(status.state === 'RUNNING' || status.state === 'PAUSE') ? status.cover_url : null}
-                      printName={(status.state === 'RUNNING' || status.state === 'PAUSE') ? (formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t) || undefined) : undefined}
+                      printName={(status.state === 'RUNNING' || status.state === 'PAUSE') ? (formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t, activePlateLabel) || undefined) : undefined}
                     />
                     {/* Print Info */}
                     <div className="flex-1 min-w-0">
@@ -2729,7 +2736,7 @@ function PrinterCard({
                             {plateStatusPill}
                           </div>
                           <p className="text-white text-sm mb-2 truncate">
-                            {formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t)}
+                            {formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t, activePlateLabel)}
                           </p>
                           <div className="flex items-center justify-between text-sm">
                             <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
@@ -2800,7 +2807,7 @@ function PrinterCard({
                 </div>
 
                 {/* Queue Widget - always visible when there are pending items */}
-                <PrinterQueueWidget printerId={printer.id} printerModel={printer.model} printerState={status.state} awaitingPlateClear={status.awaiting_plate_clear} requirePlateClear={requirePlateClear} loadedFilamentTypes={loadedFilamentTypes} loadedFilaments={loadedFilaments} />
+                <PrinterQueueWidget printerId={printer.id} printerModel={printer.model} loadedFilamentTypes={loadedFilamentTypes} loadedFilaments={loadedFilaments} />
               </>
             )}
 
@@ -3559,7 +3566,7 @@ function PrinterCard({
                                       </FilamentHoverCard>
                                     ) : (
                                       <EmptySlotHoverCard
-                                        configureSlot={tray?.state === 10 ? {
+                                        configureSlot={{
                                           enabled: hasPermission('printers:control'),
                                           onConfigure: () => setConfigureSlotModal({
                                             amsId: ams.id,
@@ -3567,8 +3574,8 @@ function PrinterCard({
                                             trayCount: ams.tray.length,
                                             extruderId: mappedExtruderId,
                                           }),
-                                        } : undefined}
-                                        inventory={tray?.state === 10 && !spoolmanEnabled ? {
+                                        }}
+                                        inventory={spoolmanEnabled ? undefined : {
                                           onAssignSpool: () => setAssignSpoolModal({
                                             printerId: printer.id,
                                             amsId: ams.id,
@@ -3579,7 +3586,7 @@ function PrinterCard({
                                               location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,
                                             },
                                           }),
-                                        } : undefined}
+                                        }}
                                       >
                                         {slotVisual}
                                       </EmptySlotHoverCard>
@@ -3877,7 +3884,7 @@ function PrinterCard({
                                   </FilamentHoverCard>
                                 ) : (
                                   <EmptySlotHoverCard
-                                    configureSlot={tray?.state === 10 ? {
+                                    configureSlot={{
                                       enabled: hasPermission('printers:control'),
                                       onConfigure: () => setConfigureSlotModal({
                                         amsId: ams.id,
@@ -3885,8 +3892,8 @@ function PrinterCard({
                                         trayCount: ams.tray.length,
                                         extruderId: mappedExtruderId,
                                       }),
-                                    } : undefined}
-                                    inventory={tray?.state === 10 && !spoolmanEnabled ? {
+                                    }}
+                                    inventory={spoolmanEnabled ? undefined : {
                                       onAssignSpool: () => setAssignSpoolModal({
                                         printerId: printer.id,
                                         amsId: ams.id,
@@ -3897,7 +3904,7 @@ function PrinterCard({
                                           location: getAmsLabel(ams.id, ams.tray.length),
                                         },
                                       }),
-                                    } : undefined}
+                                    }}
                                   >
                                     {slotVisual}
                                   </EmptySlotHoverCard>
@@ -4391,6 +4398,7 @@ function PrinterCard({
           initialSelectedPrinterIds={[printer.id]}
           onClose={() => setPrintAfterUpload(null)}
           onSuccess={() => setPrintAfterUpload(null)}
+          cleanupLibraryAfterDispatch
         />
       )}
 
@@ -6478,7 +6486,11 @@ export function PrintersPage() {
             <div className="relative w-full sm:max-w-sm mt-3">
               <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50" />
               <input
-                type="text"
+                type="search"
+                name="printer-search"
+                autoComplete="off"
+                data-1p-ignore
+                data-lpignore="true"
                 value={search}
                 onChange={(e) => setSearch(e.target.value)}
                 placeholder={t('printers.search')}

+ 1 - 1
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -291,7 +291,7 @@ export function SpoolBuddyAmsPage() {
   }, [effectiveTrayNow]);
 
   const handleAmsSlotClick = useCallback((amsId: number, trayId: number, tray: AMSTray | null) => {
-    const globalTrayId = amsId >= 128 ? (amsId - 128) * 4 + trayId + 64 : amsId * 4 + trayId;
+    const globalTrayId = getGlobalTrayId(amsId, trayId, false);
     const slotPreset = slotPresets?.[globalTrayId];
     const mappedExtruderId = amsExtruderMap[String(amsId)];
     const normalizedId = amsId >= 128 ? amsId - 128 : amsId;

+ 23 - 0
frontend/src/utils/printName.ts

@@ -0,0 +1,23 @@
+/**
+ * Append a plate label to the print name. When `plateLabel` is provided (resolved
+ * by the caller from the linked archive's plate list — see #881 follow-up), it
+ * is used verbatim, including the explicit "Plate 1" case on multi-plate 3MFs.
+ * Falls back to parsing `plate_N.gcode` from the MQTT gcode_file path, and in
+ * that fallback we only show N > 1 because we can't tell from the path alone
+ * whether the 3MF is multi-plate.
+ */
+export function formatPrintName(
+  name: string | null,
+  gcodeFile: string | null | undefined,
+  t: (key: string, fallback: string, opts?: Record<string, unknown>) => string,
+  plateLabel?: string | null,
+): string {
+  if (!name) return '';
+  if (plateLabel) return `${name} — ${plateLabel}`;
+  if (!gcodeFile) return name;
+  const match = gcodeFile.match(/plate_(\d+)\.gcode/);
+  if (match && parseInt(match[1], 10) > 1) {
+    return `${name} — ${t('printers.plateNumber', 'Plate {{number}}', { number: match[1] })}`;
+  }
+  return name;
+}

+ 2 - 0
requirements.txt

@@ -11,6 +11,8 @@ greenlet>=3.0.0
 # Pydantic
 pydantic>=2.0.0
 pydantic-settings>=2.0.0
+# Transitive of pydantic-settings, floor-pinned to patch CVE-2026-28684 (dotenv 1.2.1)
+python-dotenv>=1.2.2
 
 # Bambu Lab Printer Communication
 paho-mqtt>=2.0.0

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BoxU3Y8Y.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CkAOuJaW.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-aHxaU9HU.js


+ 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-CRR3Ug0p.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CkAOuJaW.css">
+    <script type="module" crossorigin src="/assets/index-aHxaU9HU.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BoxU3Y8Y.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff