Procházet zdrojové kódy

fix(printing): a plate check that could not run says so; the archive recovery download follows the file size; an unconfirmed upload is read back; broken MakerWorld downloads are refused; bulk-edit toast reflects the result; CallMeBot values are encoded

maziggy před 4 dny
rodič
revize
ce9853c056
100 změnil soubory, kde provedl 816 přidání a 18 odebrání
  1. 6 0
      CHANGELOG.md
  2. 1 0
      backend/app/api/routes/camera.py
  3. 34 2
      backend/app/main.py
  4. 98 7
      backend/app/services/bambu_ftp.py
  5. 16 0
      backend/app/services/model_providers/makerworld/service.py
  6. 6 3
      backend/app/services/notification_service.py
  7. 10 0
      backend/app/services/plate_detection.py
  8. 83 0
      backend/tests/unit/services/test_bambu_ftp.py
  9. 39 0
      backend/tests/unit/services/test_makerworld.py
  10. 29 0
      backend/tests/unit/test_callmebot_url_encoding.py
  11. 71 0
      backend/tests/unit/test_download_deadline_and_gate_2957.py
  12. 57 2
      backend/tests/unit/test_plate_check_chamber_light.py
  13. 34 0
      backend/tests/unit/test_plate_detection_failure_reason.py
  14. 16 0
      frontend/src/__tests__/components/Layout.test.tsx
  15. 24 0
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  16. 27 0
      frontend/src/__tests__/utils/queueBulkUpdate.test.ts
  17. 21 0
      frontend/src/components/Layout.tsx
  18. 11 0
      frontend/src/hooks/useWebSocket.ts
  19. 13 0
      frontend/src/i18n/locales/de.ts
  20. 13 0
      frontend/src/i18n/locales/en.ts
  21. 13 0
      frontend/src/i18n/locales/es.ts
  22. 13 0
      frontend/src/i18n/locales/fr.ts
  23. 13 0
      frontend/src/i18n/locales/it.ts
  24. 13 0
      frontend/src/i18n/locales/ja.ts
  25. 13 0
      frontend/src/i18n/locales/ko.ts
  26. 13 0
      frontend/src/i18n/locales/nl.ts
  27. 13 0
      frontend/src/i18n/locales/pt-BR.ts
  28. 13 0
      frontend/src/i18n/locales/ru.ts
  29. 13 0
      frontend/src/i18n/locales/sv.ts
  30. 13 0
      frontend/src/i18n/locales/tr.ts
  31. 13 0
      frontend/src/i18n/locales/uk.ts
  32. 13 0
      frontend/src/i18n/locales/zh-CN.ts
  33. 13 0
      frontend/src/i18n/locales/zh-TW.ts
  34. 9 0
      frontend/src/pages/PrintersPage.tsx
  35. 3 1
      frontend/src/pages/QueuePage.tsx
  36. 15 0
      frontend/src/utils/plateCheck.ts
  37. 11 0
      frontend/src/utils/queueBulkUpdate.ts
  38. 0 0
      static/assets/AdditionalSection-Doa186JT.js
  39. 0 0
      static/assets/AdditionalSection-L-15MXNk.js
  40. 0 0
      static/assets/AmsUnitCard-BV3Srg61.js
  41. 0 0
      static/assets/ArchivesPage-B4hbjTm5.js
  42. 0 0
      static/assets/ArchivesPage-C8xaZfPh.js
  43. 0 0
      static/assets/AssignToAmsModal-oHg_-VzX.js
  44. 0 0
      static/assets/CamWallPage-CKwIox3i.js
  45. 0 0
      static/assets/CameraDiagnoseModal-rsXlo7XK.js
  46. 0 0
      static/assets/CameraPage-DaU4aAdP.js
  47. 0 0
      static/assets/CameraWall-CV7N5RPN.js
  48. 0 0
      static/assets/ColumnConfigModal-CbMSr6Vc.js
  49. 0 0
      static/assets/ConfigureAmsSlotModal-Cft8_zh3.js
  50. 0 0
      static/assets/ConfigureAmsSlotModal-DC9_cLDm.js
  51. 0 0
      static/assets/ConnectAuthorizePage-BxLTNn1X.js
  52. 0 0
      static/assets/ContextMenu-C1c-V3yk.js
  53. 0 0
      static/assets/ExternalLinkPage-Dvv7J3m6.js
  54. 0 1
      static/assets/FileManagerPage-Dyd7cpJv.js
  55. 0 0
      static/assets/FileUploadModal-zZp7aS5v.js
  56. 0 0
      static/assets/FinancePage-Bcnaagls.js
  57. 0 0
      static/assets/GCodeViewerPage-B-iQmPmI.js
  58. 0 0
      static/assets/GroupEditPage-BWx6_-LP.js
  59. 0 0
      static/assets/ImagePreviewModal-DtN5vOS2.js
  60. 0 0
      static/assets/InventoryPage-DPb6qbDa.js
  61. 0 0
      static/assets/InventoryPage-DjaDMrPH.js
  62. 0 0
      static/assets/LibraryTrashPage-BxGkHrfc.js
  63. 0 0
      static/assets/LoginPage-DNnkmXTo.js
  64. 0 0
      static/assets/LoginPage-n1FUbhvM.js
  65. 0 0
      static/assets/MaintenancePage-07IZyhJ2.js
  66. 0 0
      static/assets/MaintenancePage-BPZYQvLz.js
  67. 0 0
      static/assets/ModelSourcesPage-B9OO9GmK.js
  68. 0 0
      static/assets/ModelSourcesPage-BSGlDWqx.js
  69. 0 0
      static/assets/NotificationsPage-qSg5cczw.js
  70. 0 1
      static/assets/PdfPreviewModal-Dx5F5d6D.js
  71. 0 0
      static/assets/PhotoGalleryModal-D5z0bauD.js
  72. 0 0
      static/assets/PrinterLocationsPage-8pmlv2S6.js
  73. 0 0
      static/assets/PrintersPage-0TUMi4jA.js
  74. 0 0
      static/assets/PrintersPage-CdUF8kSL.js
  75. 0 0
      static/assets/ProfilesPage-Blrzv8Jz.js
  76. 0 0
      static/assets/ProfilesPage-DefkWrEW.js
  77. 0 0
      static/assets/ProjectDetailPage-BOxYfr7z.js
  78. 0 0
      static/assets/ProjectsPage-CmcOz5CS.js
  79. 0 0
      static/assets/ProjectsPage-DWkL3KO4.js
  80. 0 0
      static/assets/QueuePage-C3qqdE-w.js
  81. 0 0
      static/assets/QueuePage-CY5DMk-B.js
  82. 0 0
      static/assets/RichTextEditor-C3EKnW2P.js
  83. 0 0
      static/assets/SettingsPage-C-mEB0JB.js
  84. 0 0
      static/assets/SetupPage-D_iQ0QTN.js
  85. 0 0
      static/assets/SliceModal-DcQAciyD.js
  86. 0 0
      static/assets/SpoolBuddyAmsPage-R5lmC3oq.js
  87. 0 0
      static/assets/SpoolBuddyDashboard-BJ1m8qFd.js
  88. 0 0
      static/assets/SpoolBuddyInventoryPage-BpWa12xs.js
  89. 0 1
      static/assets/SpoolBuddySettingsPage-BfCI16Sz.js
  90. 0 0
      static/assets/SpoolBuddyWriteTagPage-B4TAT5Wj.js
  91. 0 0
      static/assets/SpoolBuddyWriteTagPage-C4bFxvMZ.js
  92. 0 0
      static/assets/SpreadsheetPreviewModal-CxLsrrKs.js
  93. 0 0
      static/assets/StatsPage-Brst6AkI.js
  94. 0 0
      static/assets/StreamOverlayPage-lTrRmI-d.js
  95. 0 0
      static/assets/SystemInfoPage-CxlNdoRS.js
  96. 0 0
      static/assets/de-B-xQkGCT.js
  97. 0 0
      static/assets/de-BqH_BwEQ.js
  98. 0 0
      static/assets/es-BvEQLmnW.js
  99. 0 0
      static/assets/es-Lnnz1GCC.js
  100. 0 0
      static/assets/fr-CX0fIqzU.js

+ 6 - 0
CHANGELOG.md

@@ -164,6 +164,12 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A plate check that could not run says so** — When the camera gave no image, the analysis failed or there was no calibration to compare against, the check before a print reported an empty plate, logged "Plate is empty" and told nobody. The print still goes ahead, but users who can control printers now get a warning with the reason, the log records it, and **Check Plate** shows it instead of "Plate appears empty".
+- **The archive recovery download gives a large 3MF time** — The retry that fetches a print's 3MF when the first download failed gave up after a flat 90 seconds, so a large file on a slow SD card never made it. Once the printer reports the file's size, the deadline follows it (up to 15 minutes), as the first download's already did.
+- **A broken MakerWorld download is not kept** — Only the HTTP status was checked, so a truncated file or an error page served as a success was stored as the model's 3MF. It is now refused unless it is a readable 3MF.
+- **An upload the printer never confirmed is checked** — When the printer's "transfer complete" reply does not arrive (an H2D can take over 30 seconds), the upload still counts, but its size is now read back on a new connection and a mismatch is logged as an error.
+- **The queue bulk edit says when it changed nothing** — The result was a neutral, English-only message even when no job was updated. It is now translated, and shown as an error when nothing was updated and as a warning when some jobs could not be changed.
+- **CallMeBot gets the phone number as written** — The phone number and API key went into the request unencoded, so "+49 151 ..." arrived without its "+".
 - **A plate printed only from the external spool starts without the AMS on multi-plate files too** — The queue already sends such a plate with the AMS off (#3087), but when another plate of the same file used an AMS slot, the print command turned the AMS back on and the printer refused the job, also for a job whose AMS was already switched off. The plate's own answer now stands.
 - **A Silk spool no longer takes a slot's Matte preset** — A spool without a slicer preset reuses the filament preset already in the slot when the material matches, to keep a calibrated preset, and so does **Configure Slot** for a preset without a filament id. That now also requires the finish to match when both name one (Basic, Matte, Silk, HF, ...); otherwise the slot gets the generic preset for the material and finish, such as Generic PLA Silk for a Silk+ spool. The generic fallback picks the finish's own generic in Spoolman mode too (Generic PLA Silk, PLA High Speed, PETG HF).
 - **A virtual printer for any printer of a model keeps the AMS on** — It stored the slicer's AMS on/off with the job. The slicer was looking at the virtual printer, which has no AMS, so that choice did not describe the printer the job went to. Only a virtual printer with a target printer keeps it now.

+ 1 - 0
backend/app/api/routes/camera.py

@@ -1885,6 +1885,7 @@ async def check_plate_empty(
         - difference_percent: float - How different from calibration reference
         - message: str - Human-readable result message
         - needs_calibration: bool - True if calibration is required
+        - failure_reason: str | None - why the check could not run, None when it ran
         - light_warning: bool - True if chamber light is off
     """
     from backend.app.services.plate_detection import (

+ 34 - 2
backend/app/main.py

@@ -105,6 +105,7 @@ from backend.app.services.archive_cost_estimate import (
 )
 from backend.app.services.archive_purge import archive_purge_service
 from backend.app.services.bambu_ftp import (
+    RECOVERY_DOWNLOAD_MAX_TIMEOUT,
     FileNotOnPrinterError,
     cache_3mf_download,
     clear_3mf_cache,
@@ -4008,6 +4009,7 @@ def _schedule_fallback_3mf_retry(
                         temp_path,
                         socket_timeout=ftp_timeout,
                         printer_model=printer_model,
+                        max_total_timeout=RECOVERY_DOWNLOAD_MAX_TIMEOUT,
                     )
                 except Exception as e:
                     logger.debug("[RECOVER] Retry download of %s failed: %s", name, e)
@@ -4303,6 +4305,17 @@ async def _forget_if_printer_gone(printer_id: int) -> None:
         )
 
 
+async def _announce_plate_check_failed(printer_id: int, printer_name: str, reason: str) -> None:
+    """Tell the UI a plate check could not run. It does not block the print,
+    but an unchecked plate is not an empty one."""
+    try:
+        await ws_manager.broadcast(
+            {"type": "plate_check_failed", "printer_id": printer_id, "printer_name": printer_name, "reason": reason}
+        )
+    except Exception as e:
+        logging.getLogger(__name__).warning("[PLATE CHECK] Could not announce the failed check: %s", e)
+
+
 async def on_print_start(printer_id: int, data: dict):
     """Handle print start - archive the 3MF file immediately."""
     logger = logging.getLogger(__name__)
@@ -4456,6 +4469,7 @@ async def on_print_start(printer_id: int, data: dict):
 
                 plate_photo_data = None
                 objects_detected = False
+                check_failure: str | None = None
                 try:
                     logger.info("[PLATE CHECK] Running plate detection for printer %s", printer_id)
                     plate_result = await check_plate_empty(
@@ -4471,7 +4485,12 @@ async def on_print_start(printer_id: int, data: dict):
                         external_camera_snapshot_url=printer.external_camera_snapshot_url,
                     )
 
-                    objects_detected = not plate_result.needs_calibration and not plate_result.is_empty
+                    # No reference to compare against is a check that did not
+                    # run either: say so rather than call the plate empty.
+                    check_failure = getattr(plate_result, "failure_reason", None) or (
+                        "not_calibrated" if plate_result.needs_calibration else None
+                    )
+                    objects_detected = not check_failure and not plate_result.is_empty
                     if objects_detected:
                         # Objects detected - pause the print!
                         logger.warning(
@@ -4527,11 +4546,24 @@ async def on_print_start(printer_id: int, data: dict):
                         )
                     except Exception as notif_err:
                         logger.warning("[PLATE CHECK] Failed to send notification: %s", notif_err)
+                elif check_failure:
+                    logger.warning(
+                        "[PLATE CHECK] Plate check could not run for printer %s (%s): %s; proceeding with print",
+                        printer_id,
+                        check_failure,
+                        plate_result.message,
+                    )
+                    await _announce_plate_check_failed(printer_id, printer.name, check_failure)
                 else:
                     logger.info("[PLATE CHECK] Plate is empty for printer %s, proceeding with print", printer_id)
             except Exception as plate_err:
                 # Don't block print on plate detection errors
-                logger.warning("[PLATE CHECK] Plate detection failed for printer %s: %s", printer_id, plate_err)
+                logger.warning(
+                    "[PLATE CHECK] Plate check could not run for printer %s: %s; proceeding with print",
+                    printer_id,
+                    plate_err,
+                )
+                await _announce_plate_check_failed(printer_id, printer.name, "error")
 
         if not printer:
             logger.info("[CALLBACK] Skipping archive - printer not found in database")

+ 98 - 7
backend/app/services/bambu_ftp.py

@@ -81,6 +81,10 @@ _DOWNLOAD_FLOOR_BYTES_PER_SEC = 25 * 1024
 # the floor is pessimistic by design and a real link is not that slow.
 _DOWNLOAD_MAX_TIMEOUT = 300.0
 
+# The archive recovery retry holds no database session while it downloads, so
+# a large 3MF may take longer there: ~22 MB at the floor rate.
+RECOVERY_DOWNLOAD_MAX_TIMEOUT = 900.0
+
 
 def _download_extension(size: int | None, base_timeout: float, ceiling: float = _DOWNLOAD_MAX_TIMEOUT) -> float:
     """Extra seconds to allow a download the printer says is this big.
@@ -534,6 +538,7 @@ class BambuFTPClient:
         printer_model: str | None = None,
         force_prot_c: bool = False,
         respect_handshake_cooloff: bool = True,
+        records_handshake_cooloff: bool = True,
     ):
         """Set ``respect_handshake_cooloff=False`` for bounded, user-initiated work.
 
@@ -546,6 +551,10 @@ class BambuFTPClient:
 
         Leave it at the default everywhere else. Opting out is only defensible
         because the caller's own connection count is bounded and small.
+
+        ``records_handshake_cooloff=False`` keeps a failed handshake on this
+        client from opening the cool-off for everyone: for a side check whose
+        refusal says nothing about the printer's file service at large.
         """
         self.ip_address = ip_address
         self.access_code = access_code
@@ -553,6 +562,7 @@ class BambuFTPClient:
         self.printer_model = printer_model
         self.force_prot_c = force_prot_c
         self.respect_handshake_cooloff = respect_handshake_cooloff
+        self.records_handshake_cooloff = records_handshake_cooloff
         # Why the last connect/upload on this client failed, for a caller that
         # only gets a bool back (#2899). Per instance, so it describes one
         # operation and cannot be overwritten by work against another printer.
@@ -709,12 +719,13 @@ class BambuFTPClient:
             # sending people to do the one thing already known not to work.
             logger.warning(
                 "FTP SSL error connecting to %s: %s — the printer answered port %s with something "
-                "that is not TLS, so print files, covers and timelapses cannot be fetched from it. "
-                "Pausing FTP to this printer for %.0fs.",
+                "that is not TLS, so print files, covers and timelapses cannot be fetched from it.%s",
                 self.ip_address,
                 e,
                 self.FTP_PORT,
-                _HANDSHAKE_COOLOFF_SECONDS,
+                f" Pausing FTP to this printer for {_HANDSHAKE_COOLOFF_SECONDS:.0f}s."
+                if self.records_handshake_cooloff
+                else "",
             )
             # Close the dead socket before asking this printer for anything
             # else. The probe below opens a second connection, and the leading
@@ -748,7 +759,8 @@ class BambuFTPClient:
                         self.ip_address,
                         self.FTP_PORT,
                     )
-            self._handshake_blocked_until[self.ip_address] = time.monotonic() + _HANDSHAKE_COOLOFF_SECONDS
+            if self.records_handshake_cooloff:
+                self._handshake_blocked_until[self.ip_address] = time.monotonic() + _HANDSHAKE_COOLOFF_SECONDS
             self.last_failure = FtpFailure(FtpFailureKind.HANDSHAKE, detail)
             return False
         except (OSError, ftplib.Error) as e:
@@ -1262,6 +1274,9 @@ class BambuFTPClient:
                     e,
                     type(e).__name__,
                 )
+                if callback_exception is None:
+                    # A cancelled upload is deleted below; its size says nothing.
+                    self._check_unconfirmed_upload(remote_path, file_size)
 
             if callback_exception is not None:
                 cleanup_result: DeleteResult = DeleteResult.FAILED
@@ -1394,11 +1409,32 @@ class BambuFTPClient:
                     )
                     return False
             except Exception:
-                pass  # Timeout / socket-level — proceed, data was sent.
+                # Timeout / socket-level — proceed, data was sent.
+                self._check_unconfirmed_upload(remote_path, len(data))
             return True
         except (OSError, ftplib.Error):
             return False
 
+    def _check_unconfirmed_upload(self, remote_path: str, expected_size: int) -> None:
+        """Read an upload's size back when its 226 never came.
+
+        The upload still counts (an H2D can take 30+ s to answer), but a
+        printer that kept a partial file should not go unnoticed. A fresh
+        connection, because this one may be waiting for that late reply.
+        """
+        server_size = _size_on_new_connection(self, remote_path)
+        if server_size is None:
+            logger.warning("FTP upload of %s could not be read back to check its size", remote_path)
+        elif server_size != expected_size:
+            logger.error(
+                "FTP upload of %s is %s bytes on the printer, expected %s: the file may be incomplete",
+                remote_path,
+                server_size,
+                expected_size,
+            )
+        else:
+            logger.info("FTP upload of %s verified on a new connection (%s bytes)", remote_path, server_size)
+
     def delete_file(self, remote_path: str) -> DeleteResult:
         """Delete a file from the printer.
 
@@ -1485,6 +1521,31 @@ class BambuFTPClient:
         return result if result else None
 
 
+def _size_on_new_connection(client: BambuFTPClient, remote_path: str) -> int | None:
+    """The size of ``remote_path`` read on a connection of its own, or None."""
+    fresh = BambuFTPClient(
+        client.ip_address,
+        client.access_code,
+        timeout=client.timeout,
+        printer_model=client.printer_model,
+        force_prot_c=client.force_prot_c,
+        # A second session while the first still waits for its reply may be
+        # refused (the printer's connection slots); that must not block the
+        # printer's downloads for the cool-off.
+        respect_handshake_cooloff=False,
+        records_handshake_cooloff=False,
+    )
+    fresh.FTP_PORT = client.FTP_PORT
+    try:
+        if not fresh.connect():
+            return None
+        return fresh.get_file_size(remote_path)
+    except Exception:
+        return None
+    finally:
+        fresh.disconnect()
+
+
 def describe_upload_failure(failure: FtpFailure | None) -> str:
     """One sentence for the operator, chosen from what actually went wrong.
 
@@ -1876,6 +1937,7 @@ async def download_file_try_paths_async(
     socket_timeout: float | None = None,
     printer_model: str | None = None,
     timeout: float = 90.0,
+    max_total_timeout: float | None = None,
 ) -> str | None:
     """Try downloading a file from multiple paths using a single connection.
 
@@ -1900,6 +1962,10 @@ async def download_file_try_paths_async(
             orphaned (#2957), so a DB-holding caller's worst case is the gate
             wait plus this cap plus one unwind -- still bounded, and the
             orphaned worker no longer keeps the printer's socket after it.
+        max_total_timeout: how far a path that answered SIZE may stretch that
+            cap (see ``_download_extension``), as ``download_file_async`` does.
+            None (the default) stretches nothing: a caller holding a DB session
+            keeps the bound above.
     """
     loop = asyncio.get_event_loop()
     # An executor thread cannot be cancelled, so the cap alone used to leave a
@@ -1910,6 +1976,7 @@ async def download_file_try_paths_async(
     # flag stops it at the next chunk instead.
     cancel = threading.Event()
     done = threading.Event()
+    reported: dict = {}
 
     def _download():
         try:
@@ -1925,7 +1992,12 @@ async def download_file_try_paths_async(
                     if cancel.is_set():
                         return None
                     try:
-                        if client.download_to_file(remote_path, local_path, cancel_event=cancel):
+                        if client.download_to_file(
+                            remote_path,
+                            local_path,
+                            cancel_event=cancel,
+                            size_callback=lambda n: reported.__setitem__("size", n),
+                        ):
                             return remote_path
                     except FileNotOnPrinterError:
                         continue
@@ -1940,7 +2012,26 @@ async def download_file_try_paths_async(
     async with _serialized_download(ip_address, f"a {len(remote_paths)}-path lookup"):
         worker = loop.run_in_executor(_ftp_executor, _download)
         try:
-            return await asyncio.wait_for(asyncio.shield(worker), timeout=timeout)
+            try:
+                return await asyncio.wait_for(asyncio.shield(worker), timeout=timeout)
+            except TimeoutError:
+                # A path the printer is serving has told us its size: give it
+                # the time that size needs, as download_file_async does.
+                extension = (
+                    _download_extension(reported.get("size"), timeout, max_total_timeout)
+                    if max_total_timeout is not None
+                    else 0.0
+                )
+                if extension <= 0:
+                    raise
+                logger.info(
+                    "FTP download_try_paths passed its %ss cap but the printer reports %s bytes — allowing %.0fs more",
+                    timeout,
+                    reported.get("size"),
+                    extension,
+                )
+                timeout += extension
+                return await asyncio.wait_for(asyncio.shield(worker), timeout=extension)
         except asyncio.CancelledError:
             # The caller is going away and should not be made to wait, but the
             # worker must not keep the printer to itself either.

+ 16 - 0
backend/app/services/model_providers/makerworld/service.py

@@ -22,7 +22,9 @@ Bambu Lab, and not intended to circumvent any access control.
 from __future__ import annotations
 
 import asyncio
+import io
 import logging
+import zipfile
 from collections.abc import Awaitable, Callable
 from dataclasses import replace
 from typing import Any
@@ -90,6 +92,16 @@ _SIGN_IN_EXPIRED_MESSAGE = (
 )
 
 
+def _is_readable_zip(data: bytes) -> bool:
+    """Whether ``data`` is a zip whose directory can be read."""
+    try:
+        with zipfile.ZipFile(io.BytesIO(data)) as archive:
+            archive.namelist()
+        return True
+    except (zipfile.BadZipFile, ValueError, OSError, EOFError):
+        return False
+
+
 class MakerWorldService(ProviderService):
     """Per-request MakerWorld API client.
 
@@ -273,6 +285,10 @@ class MakerWorldService(ProviderService):
     async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
         """Fetch the 3MF bytes for a signed URL, returning ``(bytes, filename)``."""
         file_bytes, download_filename = await self.download_3mf(info.url)
+        # A 3MF is a zip. A truncated body or an error page served with 200
+        # would otherwise be stored, and kept, as the model's file.
+        if not _is_readable_zip(file_bytes):
+            raise MakerWorldUnavailableError("MakerWorld sent a file that is not a valid 3MF")
         return ProviderDownload(file_bytes=file_bytes, filename=download_filename)
 
     # ---------------------------------------------------------------- endpoints

+ 6 - 3
backend/app/services/notification_service.py

@@ -463,9 +463,12 @@ class NotificationService:
         if not phone or not apikey:
             return False, "Phone number and API key are required"
 
-        # URL encode the message
-        encoded_message = quote(message)
-        url = f"https://api.callmebot.com/whatsapp.php?phone={phone}&text={encoded_message}&apikey={apikey}"
+        # Every value URL-encoded: a phone number written "+49 151 ..." would
+        # otherwise lose its "+", which a query string reads as a space.
+        url = (
+            "https://api.callmebot.com/whatsapp.php"
+            f"?phone={quote(phone, safe='')}&text={quote(message, safe='')}&apikey={quote(apikey, safe='')}"
+        )
 
         client = await self._get_client()
         response = await client.get(url)

+ 10 - 0
backend/app/services/plate_detection.py

@@ -73,6 +73,7 @@ class PlateDetectionResult:
         message: str,
         debug_image: bytes | None = None,
         needs_calibration: bool = False,
+        failure_reason: str | None = None,
     ):
         self.is_empty = is_empty
         self.confidence = confidence  # 0.0 to 1.0
@@ -80,6 +81,10 @@ class PlateDetectionResult:
         self.message = message
         self.debug_image = debug_image  # Optional annotated image for debugging
         self.needs_calibration = needs_calibration  # True if no reference image exists
+        # Why the check could not run (no_camera_frame, decode_failed,
+        # analysis_error, opencv_missing); None when it ran. is_empty is then
+        # True only so that a print is not blocked.
+        self.failure_reason = failure_reason
 
     def to_dict(self) -> dict:
         return {
@@ -89,6 +94,7 @@ class PlateDetectionResult:
             "message": self.message,
             "has_debug_image": self.debug_image is not None,
             "needs_calibration": bool(self.needs_calibration),
+            "failure_reason": self.failure_reason,
         }
 
 
@@ -450,6 +456,7 @@ class PlateDetector:
                     confidence=0.0,
                     difference_percent=0.0,
                     message="Failed to decode current image",
+                    failure_reason="decode_failed",
                 )
 
             # Extract ROI from current frame
@@ -587,6 +594,7 @@ class PlateDetector:
                 confidence=0.0,
                 difference_percent=0.0,
                 message=f"Analysis error: {e!s}",
+                failure_reason="analysis_error",
             )
 
 
@@ -717,6 +725,7 @@ async def check_plate_empty(
             confidence=0.0,
             difference_percent=0.0,
             message="OpenCV not available - plate detection disabled",
+            failure_reason="opencv_missing",
         )
 
     image_data, camera_source = await capture_camera_image(
@@ -736,6 +745,7 @@ async def check_plate_empty(
             confidence=0.0,
             difference_percent=0.0,
             message="Failed to capture camera frame from any source",
+            failure_reason="no_camera_frame",
         )
 
     # Analyze the captured frame

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

@@ -19,6 +19,7 @@ import socket
 import threading
 import time
 from pathlib import Path
+from unittest.mock import patch
 
 import pytest
 
@@ -451,6 +452,88 @@ class TestUpload:
         assert result is False
         client.disconnect()
 
+    @pytest.mark.parametrize(("server_size", "logged_error"), [(100, True), (1024, False), (None, False)])
+    def test_upload_without_a_226_is_checked_on_a_new_connection(
+        self, ftp_client_factory, ftp_server, tmp_path, caplog, server_size, logged_error
+    ):
+        """No 226 within the wait (an H2D can take 30+ s) still lets the upload
+        proceed, but its size is read back on a fresh connection, since the
+        old one may be out of step, and a mismatch is logged as an error."""
+        import logging
+
+        import backend.app.services.bambu_ftp as ftp_mod
+
+        local = tmp_path / "test.bin"
+        local.write_bytes(b"data" * 256)
+        client = ftp_client_factory()
+        client.connect()
+
+        def never_answers():
+            raise TimeoutError("timed out")
+
+        client._ftp.voidresp = never_answers
+        checked: list[str] = []
+
+        def fake_size(c, remote_path):
+            checked.append(remote_path)
+            return server_size
+
+        with (
+            patch.object(ftp_mod, "_size_on_new_connection", fake_size),
+            caplog.at_level(logging.INFO, logger="backend.app.services.bambu_ftp"),
+        ):
+            assert client.upload_file(local, "/cache/test.bin") is True
+        client.disconnect()
+
+        assert checked == ["/cache/test.bin"]
+        errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
+        assert bool(errors) is logged_error
+
+    def test_the_read_back_reads_the_size_on_its_own_connection(self, ftp_client_factory, ftp_server):
+        import backend.app.services.bambu_ftp as ftp_mod
+
+        client = ftp_client_factory()
+        client.connect()
+        assert client.upload_bytes(b"x" * 300, "/cache/r.bin") is True
+
+        assert ftp_mod._size_on_new_connection(client, "/cache/r.bin") == 300
+        client.disconnect()
+
+    def test_a_refused_read_back_does_not_pause_the_printer(self, ftp_client_factory, ftp_server):
+        """A second session can be refused while the first still waits; that
+        must not open the cool-off that skips the printer's archive downloads."""
+        import ssl
+
+        import backend.app.services.bambu_ftp as ftp_mod
+
+        client = ftp_client_factory()
+        with patch.object(ftp_mod.ImplicitFTP_TLS, "connect", side_effect=ssl.SSLError("refused")):
+            assert ftp_mod._size_on_new_connection(client, "/cache/r.bin") is None
+
+        assert not ftp_mod.BambuFTPClient.handshake_blocked("127.0.0.1")
+
+    def test_upload_bytes_without_a_226_is_checked_too(self, ftp_client_factory, ftp_server):
+        import backend.app.services.bambu_ftp as ftp_mod
+
+        client = ftp_client_factory()
+        client.connect()
+
+        def never_answers():
+            raise TimeoutError("timed out")
+
+        client._ftp.voidresp = never_answers
+        checked: list[str] = []
+
+        def fake_size(c, remote_path):
+            checked.append(remote_path)
+            return 3
+
+        with patch.object(ftp_mod, "_size_on_new_connection", fake_size):
+            assert client.upload_bytes(b"abc", "/cache/b.bin") is True
+        client.disconnect()
+
+        assert checked == ["/cache/b.bin"]
+
     def test_upload_426_with_intact_file_proceeds(self, ftp_client_factory, ftp_server, tmp_path):
         """Some P2S firmware revisions return 426 on voidresp() even when the
         file landed fully (TLS data-channel close races the 226). #1417

+ 39 - 0
backend/tests/unit/services/test_makerworld.py

@@ -959,3 +959,42 @@ class TestSharedHttpClient:
         assert svc._owns_client is True
         await svc.close()
         assert svc._client.is_closed
+
+
+class TestADownloadIsAZip:
+    """Only the status code was checked, so a truncated body or an error page
+    served with 200 was stored as the model's 3MF and kept."""
+
+    @staticmethod
+    def _info():
+        from backend.app.services.model_providers.base import ProviderDownloadInfo
+
+        return ProviderDownloadInfo(
+            ref=ProviderResourceRef(source_type="makerworld", external_id="1"),
+            url="https://makerworld.bblmw.com/f.3mf",
+            suggested_filename="f.3mf",
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("body", [b"<html>Access denied</html>", b"PK\x03\x04truncated"])
+    async def test_a_body_that_is_not_a_zip_is_refused(self, body):
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient))
+        svc.download_3mf = AsyncMock(return_value=(body, "f.3mf"))
+
+        with pytest.raises(MakerWorldUnavailableError, match="not a valid 3MF"):
+            await svc.download(self._info())
+
+    @pytest.mark.asyncio
+    async def test_a_zip_is_returned(self):
+        import io
+        import zipfile
+
+        buffer = io.BytesIO()
+        with zipfile.ZipFile(buffer, "w") as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient))
+        svc.download_3mf = AsyncMock(return_value=(buffer.getvalue(), "f.3mf"))
+
+        result = await svc.download(self._info())
+
+        assert result.file_bytes == buffer.getvalue()

+ 29 - 0
backend/tests/unit/test_callmebot_url_encoding.py

@@ -0,0 +1,29 @@
+"""CallMeBot gets the phone number and API key URL-encoded.
+
+They went into the query string as typed, so a phone number written as
+"+49 151 2345678" reached CallMeBot without its "+", which a query string reads
+as a space.
+"""
+
+from unittest.mock import AsyncMock, MagicMock
+from urllib.parse import parse_qs, urlparse
+
+import pytest
+
+from backend.app.services.notification_service import NotificationService
+
+
+@pytest.mark.asyncio
+async def test_the_phone_and_key_arrive_as_typed():
+    service = NotificationService()
+    client = MagicMock()
+    client.get = AsyncMock(return_value=MagicMock(status_code=200, text="ok"))
+    service._get_client = AsyncMock(return_value=client)
+
+    ok, _ = await service._send_callmebot({"phone": "+49 151 2345678", "apikey": "12&34"}, "Done: 100%")
+
+    assert ok is True
+    query = parse_qs(urlparse(client.get.await_args.args[0]).query)
+    assert query["phone"] == ["+49 151 2345678"]
+    assert query["apikey"] == ["12&34"]
+    assert query["text"] == ["Done: 100%"]

+ 71 - 0
backend/tests/unit/test_download_deadline_and_gate_2957.py

@@ -288,6 +288,77 @@ class TestOnlyOneDownloadPerPrinter:
 
 
 @pytest.mark.asyncio
+@pytest.mark.asyncio
+class TestAPathWalkFollowsTheFileToo:
+    """The archive recovery retry walks the printer's paths for a 3MF, and had
+    a flat 90 s cap whatever the file's size."""
+
+    async def test_a_path_that_reports_its_size_is_given_the_time(self, tmp_path):
+        payload = b"y" * 2048
+
+        class _Client(_FakeClient):
+            def download_to_file(self, remote_path, local_path, *, size_callback=None, cancel_event=None, **kwargs):
+                size_callback(1_000_000)
+                for _ in range(40):
+                    if cancel_event is not None and cancel_event.is_set():
+                        raise ftp_mod.DownloadCancelled(remote_path)
+                    time.sleep(0.01)
+                local_path.write_bytes(payload)
+                return True
+
+        with patch.object(ftp_mod, "BambuFTPClient", _Client):
+            hit = await download_file_try_paths_async(
+                "10.0.0.7", "x", ["/a.3mf"], tmp_path / "a.3mf", timeout=0.05, max_total_timeout=60
+            )
+
+        assert hit == "/a.3mf"
+        assert (tmp_path / "a.3mf").read_bytes() == payload
+
+    async def test_a_caller_that_does_not_ask_keeps_its_cap(self, tmp_path):
+        """The objects route holds a DB session through this download."""
+
+        class _Client(_FakeClient):
+            def download_to_file(self, remote_path, local_path, *, size_callback=None, cancel_event=None, **kwargs):
+                size_callback(1_000_000)
+                for _ in range(40):
+                    if cancel_event is not None and cancel_event.is_set():
+                        raise ftp_mod.DownloadCancelled(remote_path)
+                    time.sleep(0.01)
+                return True
+
+        with patch.object(ftp_mod, "BambuFTPClient", _Client):
+            hit = await download_file_try_paths_async("10.0.0.6", "x", ["/a.3mf"], tmp_path / "a.3mf", timeout=0.05)
+
+        assert hit is None
+
+    async def test_without_a_size_the_cap_stands(self, tmp_path):
+        class _Client(_FakeClient):
+            def download_to_file(self, remote_path, local_path, *, cancel_event=None, **kwargs):
+                for _ in range(40):
+                    if cancel_event is not None and cancel_event.is_set():
+                        raise ftp_mod.DownloadCancelled(remote_path)
+                    time.sleep(0.01)
+                return True
+
+        with patch.object(ftp_mod, "BambuFTPClient", _Client):
+            hit = await download_file_try_paths_async("10.0.0.8", "x", ["/a.3mf"], tmp_path / "a.3mf", timeout=0.05)
+
+        assert hit is None
+
+
+def test_the_recovery_retry_holds_no_session_and_may_wait_longer():
+    import inspect
+
+    from backend.app import main as main_module
+
+    source = inspect.getsource(main_module)
+    retry = source[source.index("async def _retry() -> None:") :]
+    call = retry[retry.index("download_file_try_paths_async(") :]
+    call = call[: call.index("except Exception")]
+    assert "max_total_timeout=RECOVERY_DOWNLOAD_MAX_TIMEOUT" in call
+    assert ftp_mod.RECOVERY_DOWNLOAD_MAX_TIMEOUT == 900.0
+
+
 class TestTheCapNoLongerLeavesAWorkerOnTheSocket:
     async def test_a_capped_path_walk_stops_its_worker(self, tmp_path):
         """``asyncio.wait_for`` cannot cancel an executor thread, so the cap used

+ 57 - 2
backend/tests/unit/test_plate_check_chamber_light.py

@@ -33,11 +33,15 @@ def _clear_dicts():
     )
     for d in dicts:
         d.clear()
+    BROADCASTS.clear()
     yield
     for d in dicts:
         d.clear()
 
 
+BROADCASTS: list[dict] = []
+
+
 def _printer():
     printer = MagicMock()
     printer.id = 1
@@ -74,8 +78,9 @@ async def _run_print_start(events: list[str], *, check_plate_empty, pause_side_e
     session.__aexit__ = AsyncMock()
     session.execute = AsyncMock(side_effect=execute_router)
 
-    async def broadcast(_message):
+    async def broadcast(message):
         events.append("broadcast")
+        BROADCASTS.append(message)
 
     async def notify(**_kwargs):
         events.append("notify")
@@ -133,7 +138,7 @@ async def test_light_goes_off_when_detection_raises():
 
     await _run_print_start(events, check_plate_empty=AsyncMock(side_effect=RuntimeError("camera gone")))
 
-    assert events == ["light:on", "light:off"]
+    assert events == ["light:on", "light:off", "broadcast"]
 
 
 @pytest.mark.asyncio
@@ -146,3 +151,53 @@ async def test_light_goes_off_when_pausing_raises():
 
     assert "light:off" in events
     assert "notify" not in events
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("check", "reason"),
+    [
+        (
+            AsyncMock(
+                return_value=SimpleNamespace(
+                    needs_calibration=False,
+                    is_empty=True,
+                    confidence=0.0,
+                    difference_percent=0.0,
+                    failure_reason="no_camera_frame",
+                    message="Failed to capture camera frame from any source",
+                )
+            ),
+            "no_camera_frame",
+        ),
+        (AsyncMock(side_effect=RuntimeError("camera gone")), "error"),
+        (
+            AsyncMock(
+                return_value=SimpleNamespace(
+                    needs_calibration=True,
+                    is_empty=True,
+                    confidence=0.0,
+                    difference_percent=0.0,
+                    failure_reason=None,
+                    message="No calibration - please calibrate with empty plate first",
+                )
+            ),
+            "not_calibrated",
+        ),
+    ],
+)
+async def test_a_check_that_could_not_run_says_so(check, reason, caplog):
+    """It still does not block the print, but it is no longer reported as an
+    empty plate."""
+    import logging
+
+    events: list[str] = []
+    with caplog.at_level(logging.WARNING):
+        await _run_print_start(events, check_plate_empty=check)
+
+    assert "pause" not in events
+    assert [m for m in BROADCASTS if m["type"] == "plate_check_failed"] == [
+        {"type": "plate_check_failed", "printer_id": 1, "printer_name": "TestP1S", "reason": reason}
+    ]
+    assert any("could not run" in r.message for r in caplog.records if r.levelno == logging.WARNING)
+    assert not any("Plate is empty" in r.message for r in caplog.records)

+ 34 - 0
backend/tests/unit/test_plate_detection_failure_reason.py

@@ -0,0 +1,34 @@
+"""A plate check that could not run says why instead of reporting an empty plate."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services import plate_detection
+from backend.app.services.plate_detection import PlateDetectionResult, check_plate_empty
+
+
+@pytest.mark.asyncio
+async def test_no_camera_frame():
+    with (
+        patch.object(plate_detection, "OPENCV_AVAILABLE", True),
+        patch.object(plate_detection, "capture_camera_image", AsyncMock(return_value=(None, None))),
+    ):
+        result = await check_plate_empty(printer_id=1, ip_address="10.0.0.1", access_code="x", model="X1C")
+
+    assert result.failure_reason == "no_camera_frame"
+    assert result.to_dict()["failure_reason"] == "no_camera_frame"
+
+
+@pytest.mark.asyncio
+async def test_no_opencv():
+    with patch.object(plate_detection, "OPENCV_AVAILABLE", False):
+        result = await check_plate_empty(printer_id=1, ip_address="10.0.0.1", access_code="x", model="X1C")
+
+    assert result.failure_reason == "opencv_missing"
+
+
+def test_a_check_that_ran_has_no_failure_reason():
+    result = PlateDetectionResult(is_empty=True, confidence=0.9, difference_percent=1.0, message="ok")
+
+    assert result.to_dict()["failure_reason"] is None

+ 16 - 0
frontend/src/__tests__/components/Layout.test.tsx

@@ -414,6 +414,22 @@ describe('Layout', () => {
       });
     });
 
+    it('warns when a plate check could not run, naming why', async () => {
+      render(<Layout />);
+
+      window.dispatchEvent(
+        new CustomEvent('plate-check-failed', {
+          detail: { printer_id: 1, printer_name: 'Test Printer', reason: 'no_camera_frame' },
+        })
+      );
+
+      await waitFor(() => {
+        expect(document.body.textContent).toContain(
+          'Plate check could not run on Test Printer (no camera image). The print continues.'
+        );
+      });
+    });
+
     it('closes modal when I Understand button is clicked', async () => {
       render(<Layout />);
 

+ 24 - 0
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -468,6 +468,30 @@ describe('useWebSocket hook', () => {
       vi.unstubAllGlobals();
     });
 
+    it('relays a plate check that could not run to the layout', async () => {
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      const received: unknown[] = [];
+      const listener = (event: Event) => received.push((event as CustomEvent).detail);
+      window.addEventListener('plate-check-failed', listener);
+
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+      const ws = await waitForWs();
+      act(() => {
+        ws.open();
+      });
+      act(() => {
+        ws.simulateMessage({
+          type: 'plate_check_failed',
+          printer_id: 3,
+          printer_name: 'X1C',
+          reason: 'no_camera_frame',
+        });
+      });
+      window.removeEventListener('plate-check-failed', listener);
+
+      expect(received).toEqual([{ printer_id: 3, printer_name: 'X1C', reason: 'no_camera_frame' }]);
+    });
+
     it('invalidates inventory queries on inventory_changed message', async () => {
       vi.useFakeTimers();
       const { useWebSocket } = await import('../../hooks/useWebSocket');

+ 27 - 0
frontend/src/__tests__/utils/queueBulkUpdate.test.ts

@@ -0,0 +1,27 @@
+/**
+ * A queue bulk edit said only "Updated N items" in a neutral toast, also when
+ * it updated none of them.
+ */
+import { describe, it, expect } from 'vitest';
+import { bulkUpdateToast } from '../../utils/queueBulkUpdate';
+
+describe('bulkUpdateToast', () => {
+  it('is an error when nothing was updated', () => {
+    expect(bulkUpdateToast({ updated_count: 0, skipped_count: 3 })).toMatchObject({
+      key: 'queue.toast.bulkUpdatedNone',
+      type: 'error',
+    });
+  });
+
+  it('is a warning when some were skipped', () => {
+    expect(bulkUpdateToast({ updated_count: 2, skipped_count: 1 })).toEqual({
+      key: 'queue.toast.bulkUpdatedSome',
+      params: { updated: 2, skipped: 1 },
+      type: 'warning',
+    });
+  });
+
+  it('is a success when all were updated', () => {
+    expect(bulkUpdateToast({ updated_count: 4, skipped_count: 0 }).type).toBe('success');
+  });
+});

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

@@ -25,6 +25,7 @@ import { BugReportBubble } from './BugReportBubble';
 import { AnnouncementsPanel } from './AnnouncementsPanel';
 import { AnnouncementBanner } from './AnnouncementBanner';
 import { useAnnouncements } from '../hooks/useAnnouncements';
+import { plateCheckFailureText } from '../utils/plateCheck';
 import {
   getHiddenSidebarSystemItemIds,
   getSidebarOrder,
@@ -488,6 +489,26 @@ export function Layout() {
     return () => window.removeEventListener('plate-not-empty', handlePlateNotEmpty);
   }, [hasPermission]);
 
+  // A plate check that could not run (no camera image, ...) does not hold the
+  // print, but it is not an empty plate either: say so to those who can act.
+  useEffect(() => {
+    const handlePlateCheckFailed = (event: Event) => {
+      if (!hasPermission('printers:control')) {
+        return;
+      }
+      const detail = (event as CustomEvent).detail ?? {};
+      showToast(
+        t('printers.plateDetection.couldNotRun', {
+          printer: detail.printer_name ?? '',
+          reason: plateCheckFailureText(t, detail.reason),
+        }),
+        'warning',
+      );
+    };
+    window.addEventListener('plate-check-failed', handlePlateCheckFailed);
+    return () => window.removeEventListener('plate-check-failed', handlePlateCheckFailed);
+  }, [hasPermission, showToast, t]);
+
   // A completed print asked for its outcome verdict (#1898). Same CustomEvent
   // relay as plate-not-empty, and gated the same way: the PATCH route decides
   // who may record a verdict, so a user who cannot should not be handed a

+ 11 - 0
frontend/src/hooks/useWebSocket.ts

@@ -455,6 +455,17 @@ export function useWebSocket() {
         }));
         break;
 
+      case 'plate_check_failed':
+        // The plate check before a print could not run; the print goes on.
+        window.dispatchEvent(new CustomEvent('plate-check-failed', {
+          detail: {
+            printer_id: message.printer_id,
+            printer_name: (message as unknown as { printer_name?: string }).printer_name,
+            reason: (message as unknown as { reason?: string }).reason,
+          }
+        }));
+        break;
+
       case 'announcements_changed':
         // The backend fetched a newer feed: re-read the list so the sidebar dot
         // and the banner appear without a reload. The event says nothing itself.

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

@@ -841,6 +841,16 @@ export default {
       calibrationTip: '<strong>Tipp:</strong> Sie können bis zu 5 Kalibrierungen für verschiedene Platten speichern. Das System verwendet automatisch die beste Übereinstimmung bei der Prüfung.',
       plateEmpty: 'Platte erscheint leer',
       objectsDetected: 'Objekte auf Platte erkannt',
+      couldNotRun: 'Plattenprüfung auf {{printer}} nicht möglich ({{reason}}). Der Druck läuft weiter.',
+      couldNotRunTitle: 'Plattenprüfung nicht möglich',
+      unavailableReasons: {
+        no_camera_frame: 'kein Kamerabild',
+        decode_failed: 'das Kamerabild war nicht lesbar',
+        analysis_error: 'die Bildanalyse ist fehlgeschlagen',
+        opencv_missing: 'die Bildanalyse ist nicht installiert',
+        not_calibrated: 'keine Kalibrierung der leeren Platte',
+        error: 'ein unerwarteter Fehler',
+      },
       confidence: 'Konfidenz',
       difference: 'Differenz',
       analysisPreview: 'Analysevorschau:',
@@ -1666,6 +1676,9 @@ export default {
       historyCleared: '{{count}} Verlaufselement(e) gelöscht',
       clearHistoryFailed: 'Verlauf konnte nicht gelöscht werden',
       updateFailed: 'Elemente konnten nicht aktualisiert werden',
+      bulkUpdated: '{{updated}} Auftrag/Aufträge aktualisiert',
+      bulkUpdatedSome: '{{updated}} Auftrag/Aufträge aktualisiert; {{skipped}} konnten nicht geändert werden',
+      bulkUpdatedNone: 'Keine Aufträge aktualisiert',
       bulkCancelled: '{{count}} Element(e) abgebrochen',
       bulkCancelFailed: 'Elemente konnten nicht abgebrochen werden',
       batchCreated: 'Stapel „{{name}}“ erstellt',

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

@@ -848,6 +848,16 @@ export default {
       calibrationTip: '<strong>Tip:</strong> You can store up to 5 calibrations for different plates. The system automatically uses the best match when checking.',
       plateEmpty: 'Plate appears empty',
       objectsDetected: 'Objects detected on plate',
+      couldNotRun: 'Plate check could not run on {{printer}} ({{reason}}). The print continues.',
+      couldNotRunTitle: 'Plate check could not run',
+      unavailableReasons: {
+        no_camera_frame: 'no camera image',
+        decode_failed: 'the camera image could not be read',
+        analysis_error: 'the image analysis failed',
+        opencv_missing: 'image analysis is not installed',
+        not_calibrated: 'no calibration of the empty plate',
+        error: 'an unexpected error',
+      },
       confidence: 'Confidence',
       difference: 'Difference',
       analysisPreview: 'Analysis preview:',
@@ -1684,6 +1694,9 @@ export default {
       historyCleared: 'Cleared {{count}} history item(s)',
       clearHistoryFailed: 'Failed to clear history',
       updateFailed: 'Failed to update items',
+      bulkUpdated: 'Updated {{updated}} job(s)',
+      bulkUpdatedSome: 'Updated {{updated}} job(s); {{skipped}} could not be changed',
+      bulkUpdatedNone: 'No jobs were updated',
       bulkCancelled: 'Cancelled {{count}} item(s)',
       bulkCancelFailed: 'Failed to cancel items',
       batchCreated: 'Batch "{{name}}" created',

+ 13 - 0
frontend/src/i18n/locales/es.ts

@@ -841,6 +841,16 @@ export default {
       calibrationTip: '<strong>Consejo:</strong> puede almacenar hasta 5 calibraciones para distintas camas. El sistema usa automáticamente la mejor coincidencia al comprobar.',
       plateEmpty: 'La cama parece estar vacía',
       objectsDetected: 'Objetos detectados en la cama',
+      couldNotRun: 'No se pudo comprobar la placa en {{printer}} ({{reason}}). La impresión continúa.',
+      couldNotRunTitle: 'No se pudo comprobar la placa',
+      unavailableReasons: {
+        no_camera_frame: 'sin imagen de la cámara',
+        decode_failed: 'no se pudo leer la imagen de la cámara',
+        analysis_error: 'falló el análisis de imagen',
+        opencv_missing: 'el análisis de imagen no está instalado',
+        not_calibrated: 'la placa vacía no está calibrada',
+        error: 'un error inesperado',
+      },
       confidence: 'Confianza',
       difference: 'Diferencia',
       analysisPreview: 'Vista previa del análisis:',
@@ -1666,6 +1676,9 @@ export default {
       historyCleared: 'Se borraron {{count}} elemento(s) del historial',
       clearHistoryFailed: 'Error al borrar el historial',
       updateFailed: 'Error al actualizar los elementos',
+      bulkUpdated: '{{updated}} trabajo(s) actualizado(s)',
+      bulkUpdatedSome: '{{updated}} trabajo(s) actualizado(s); {{skipped}} no se pudieron cambiar',
+      bulkUpdatedNone: 'No se actualizó ningún trabajo',
       bulkCancelled: 'Se cancelaron {{count}} elemento(s)',
       bulkCancelFailed: 'Error al cancelar los elementos',
       batchCreated: 'Lote "{{name}}" creado',

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

@@ -841,6 +841,16 @@ export default {
       calibrationTip: 'Conseil : Stockez jusqu\'à 5 références. Le système utilise la meilleure correspondance.',
       plateEmpty: 'Le plateau semble vide',
       objectsDetected: 'Objets détectés sur le plateau',
+      couldNotRun: "Impossible de vérifier le plateau sur {{printer}} ({{reason}}). L'impression continue.",
+      couldNotRunTitle: 'Vérification du plateau impossible',
+      unavailableReasons: {
+        no_camera_frame: "pas d'image de la caméra",
+        decode_failed: "l'image de la caméra est illisible",
+        analysis_error: "l'analyse d'image a échoué",
+        opencv_missing: "l'analyse d'image n'est pas installée",
+        not_calibrated: 'aucun étalonnage du plateau vide',
+        error: 'une erreur inattendue',
+      },
       confidence: 'Confiance',
       difference: 'Différence',
       analysisPreview: 'Aperçu de l\'analyse :',
@@ -1666,6 +1676,9 @@ export default {
       historyCleared: '{{count}} éléments effacés',
       clearHistoryFailed: 'Échec effacement',
       updateFailed: 'Échec mise à jour',
+      bulkUpdated: '{{updated}} tâche(s) mise(s) à jour',
+      bulkUpdatedSome: "{{updated}} tâche(s) mise(s) à jour ; {{skipped}} n'ont pas pu être modifiées",
+      bulkUpdatedNone: 'Aucune tâche mise à jour',
       bulkCancelled: '{{count}} éléments annulés',
       bulkCancelFailed: 'Échec annulation',
       batchCreated: 'Lot « {{name}} » créé',

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

@@ -841,6 +841,16 @@ export default {
       calibrationTip: '<strong>Suggerimento:</strong> Puoi salvare fino a 5 calibrazioni per piatti diversi. Il sistema usa automaticamente la migliore corrispondenza durante il controllo.',
       plateEmpty: 'Il piatto sembra vuoto',
       objectsDetected: 'Oggetti rilevati sul piatto',
+      couldNotRun: 'Impossibile controllare il piatto su {{printer}} ({{reason}}). La stampa continua.',
+      couldNotRunTitle: 'Controllo del piatto non riuscito',
+      unavailableReasons: {
+        no_camera_frame: 'nessuna immagine dalla fotocamera',
+        decode_failed: "l'immagine della fotocamera non è leggibile",
+        analysis_error: "l'analisi dell'immagine non è riuscita",
+        opencv_missing: "l'analisi delle immagini non è installata",
+        not_calibrated: 'nessuna calibrazione del piatto vuoto',
+        error: 'un errore imprevisto',
+      },
       confidence: 'Confidenza',
       difference: 'Differenza',
       analysisPreview: 'Anteprima analisi:',
@@ -1666,6 +1676,9 @@ export default {
       historyCleared: 'Cancellati {{count}} elementi cronologia',
       clearHistoryFailed: 'Svuotamento cronologia non riuscito',
       updateFailed: 'Aggiornamento elementi non riuscito',
+      bulkUpdated: '{{updated}} lavoro/i aggiornato/i',
+      bulkUpdatedSome: '{{updated}} lavoro/i aggiornato/i; non è stato possibile modificarne {{skipped}}',
+      bulkUpdatedNone: 'Nessun lavoro aggiornato',
       bulkCancelled: 'Annullati {{count}} elementi',
       bulkCancelFailed: 'Annullamento elementi non riuscito',
       batchCreated: 'Lotto «{{name}}» creato',

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

@@ -840,6 +840,16 @@ export default {
       calibrationTip: '<strong>ヒント:</strong> 異なるプレート用に最大5つのキャリブレーションを保存できます。チェック時に最適なものが自動的に使用されます。',
       plateEmpty: 'プレートは空のようです',
       objectsDetected: 'オブジェクトを検出しました',
+      couldNotRun: '{{printer}} のプレートチェックを実行できませんでした({{reason}})。印刷は続行されます。',
+      couldNotRunTitle: 'プレートチェックを実行できませんでした',
+      unavailableReasons: {
+        no_camera_frame: 'カメラ画像がありません',
+        decode_failed: 'カメラ画像を読み取れませんでした',
+        analysis_error: '画像解析に失敗しました',
+        opencv_missing: '画像解析がインストールされていません',
+        not_calibrated: '空のプレートのキャリブレーションがありません',
+        error: '予期しないエラー',
+      },
       confidence: '信頼度',
       difference: '差分',
       analysisPreview: '分析プレビュー:',
@@ -1665,6 +1675,9 @@ export default {
       historyCleared: '{{count}}件の履歴をクリアしました',
       clearHistoryFailed: '履歴のクリアに失敗しました',
       updateFailed: 'アイテムの更新に失敗しました',
+      bulkUpdated: '{{updated}} 件のジョブを更新しました',
+      bulkUpdatedSome: '{{updated}} 件のジョブを更新しました。{{skipped}} 件は変更できませんでした',
+      bulkUpdatedNone: '更新されたジョブはありません',
       bulkCancelled: '{{count}}件のアイテムをキャンセルしました',
       bulkCancelFailed: 'アイテムのキャンセルに失敗しました',
       batchCreated: 'バッチ「{{name}}」を作成しました',

+ 13 - 0
frontend/src/i18n/locales/ko.ts

@@ -799,6 +799,16 @@ export default {
       calibrationTip: '<strong>팁:</strong> 다른 플레이트에 대해 최대 5개의 캘리브레이션을 저장할 수 있습니다. 확인 시 시스템이 자동으로 가장 잘 맞는 것을 사용합니다.',
       plateEmpty: '플레이트가 비어 있는 것으로 보입니다',
       objectsDetected: '플레이트에 객체가 감지되었습니다',
+      couldNotRun: '{{printer}}에서 플레이트 검사를 실행할 수 없습니다({{reason}}). 출력은 계속됩니다.',
+      couldNotRunTitle: '플레이트 검사를 실행할 수 없음',
+      unavailableReasons: {
+        no_camera_frame: '카메라 이미지 없음',
+        decode_failed: '카메라 이미지를 읽을 수 없음',
+        analysis_error: '이미지 분석 실패',
+        opencv_missing: '이미지 분석이 설치되지 않음',
+        not_calibrated: '빈 플레이트 보정이 없습니다',
+        error: '예기치 않은 오류',
+      },
       confidence: '신뢰도',
       difference: '차이',
       analysisPreview: '분석 미리보기:',
@@ -1587,6 +1597,9 @@ export default {
       historyCleared: '{{count}}개 기록 항목이 지워졌습니다',
       clearHistoryFailed: '기록 지우기 실패',
       updateFailed: '항목 업데이트 실패',
+      bulkUpdated: '작업 {{updated}}개를 업데이트했습니다',
+      bulkUpdatedSome: '작업 {{updated}}개를 업데이트했습니다. {{skipped}}개는 변경할 수 없었습니다',
+      bulkUpdatedNone: '업데이트된 작업이 없습니다',
       bulkCancelled: '{{count}}개 항목이 취소되었습니다',
       bulkCancelFailed: '항목 취소 실패',
       batchCreated: '"{{name}}" 배치를 만들었습니다',

+ 13 - 0
frontend/src/i18n/locales/nl.ts

@@ -846,6 +846,16 @@ export default {
       calibrationTip: '<strong>Tip:</strong> Je kunt maximaal 5 kalibraties voor verschillende platen opslaan. Het systeem gebruikt bij een controle automatisch de beste overeenkomst.',
       plateEmpty: 'Plaat lijkt leeg',
       objectsDetected: 'Objecten op plaat gedetecteerd',
+      couldNotRun: 'Plaatcontrole op {{printer}} niet mogelijk ({{reason}}). De print gaat door.',
+      couldNotRunTitle: 'Plaatcontrole niet mogelijk',
+      unavailableReasons: {
+        no_camera_frame: 'geen camerabeeld',
+        decode_failed: 'het camerabeeld was niet leesbaar',
+        analysis_error: 'de beeldanalyse is mislukt',
+        opencv_missing: 'beeldanalyse is niet geïnstalleerd',
+        not_calibrated: 'geen kalibratie van de lege plaat',
+        error: 'een onverwachte fout',
+      },
       confidence: 'Betrouwbaarheid',
       difference: 'Verschil',
       analysisPreview: 'Analysevoorbeeld:',
@@ -1682,6 +1692,9 @@ export default {
       historyCleared: '{{count}} geschiedenisitem(s) gewist',
       clearHistoryFailed: 'Geschiedenis wissen mislukt',
       updateFailed: 'Items bijwerken mislukt',
+      bulkUpdated: '{{updated}} taak/taken bijgewerkt',
+      bulkUpdatedSome: '{{updated}} taak/taken bijgewerkt; {{skipped}} konden niet worden gewijzigd',
+      bulkUpdatedNone: 'Geen taken bijgewerkt',
       bulkCancelled: '{{count}} item(s) geannuleerd',
       bulkCancelFailed: 'Items annuleren mislukt',
       batchCreated: 'Batch "{{name}}" gemaakt',

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

@@ -841,6 +841,16 @@ export default {
       calibrationTip: '<strong>Dica:</strong> Você pode armazenar até 5 calibrações para diferentes placas. O sistema usa automaticamente a melhor correspondência ao verificar.',
       plateEmpty: 'A placa parece vazia',
       objectsDetected: 'Objetos detectados na placa',
+      couldNotRun: 'Não foi possível verificar a mesa em {{printer}} ({{reason}}). A impressão continua.',
+      couldNotRunTitle: 'Não foi possível verificar a mesa',
+      unavailableReasons: {
+        no_camera_frame: 'sem imagem da câmera',
+        decode_failed: 'a imagem da câmera não pôde ser lida',
+        analysis_error: 'a análise da imagem falhou',
+        opencv_missing: 'a análise de imagem não está instalada',
+        not_calibrated: 'sem calibração da mesa vazia',
+        error: 'um erro inesperado',
+      },
       confidence: 'Confiança',
       difference: 'Diferença',
       analysisPreview: 'Pré-visualização da análise:',
@@ -1666,6 +1676,9 @@ export default {
       historyCleared: 'Limpar {{count}} item(s) do histórico',
       clearHistoryFailed: 'Falha ao limpar histórico',
       updateFailed: 'Falha ao atualizar itens',
+      bulkUpdated: '{{updated}} trabalho(s) atualizado(s)',
+      bulkUpdatedSome: '{{updated}} trabalho(s) atualizado(s); {{skipped}} não puderam ser alterados',
+      bulkUpdatedNone: 'Nenhum trabalho atualizado',
       bulkCancelled: 'Cancelado {{count}} item(s)',
       bulkCancelFailed: 'Falha ao cancelar itens',
       batchCreated: 'Lote "{{name}}" criado',

+ 13 - 0
frontend/src/i18n/locales/ru.ts

@@ -804,6 +804,16 @@ export default {
       calibrationTip: "<strong>Совет:</strong> можно сохранить до 5 калибровок для разных пластин. При проверке система автоматически выберет наиболее подходящий эталон.",
       plateEmpty: "Пластина выглядит пустой",
       objectsDetected: "На пластине обнаружены объекты",
+      couldNotRun: 'Не удалось проверить стол на {{printer}} ({{reason}}). Печать продолжается.',
+      couldNotRunTitle: 'Проверка стола не выполнена',
+      unavailableReasons: {
+        no_camera_frame: 'нет изображения с камеры',
+        decode_failed: 'не удалось прочитать изображение с камеры',
+        analysis_error: 'не удалось проанализировать изображение',
+        opencv_missing: 'анализ изображений не установлен',
+        not_calibrated: 'нет калибровки пустого стола',
+        error: 'непредвиденная ошибка',
+      },
       confidence: "Уверенность",
       difference: "Различие",
       analysisPreview: "Предпросмотр анализа:",
@@ -1601,6 +1611,9 @@ export default {
       historyCleared: "Удалено записей истории: {{count}}",
       clearHistoryFailed: "Не удалось очистить историю",
       updateFailed: "Не удалось обновить задания",
+      bulkUpdated: 'Обновлено заданий: {{updated}}',
+      bulkUpdatedSome: 'Обновлено заданий: {{updated}}; {{skipped}} изменить не удалось',
+      bulkUpdatedNone: 'Ни одно задание не обновлено',
       bulkCancelled: "Отменено заданий: {{count}}",
       bulkCancelFailed: "Не удалось отменить задания",
       batchCreated: "Партия «{{name}}» создана",

+ 13 - 0
frontend/src/i18n/locales/sv.ts

@@ -846,6 +846,16 @@ export default {
       calibrationTip: '<strong>Tips:</strong> Du kan spara upp till 5 kalibreringar för olika plattor. Systemet använder automatiskt den bästa matchningen vid kontroll.',
       plateEmpty: 'Plattan verkar tom',
       objectsDetected: 'Objekt upptäckta på plattan',
+      couldNotRun: 'Plattkontrollen på {{printer}} kunde inte köras ({{reason}}). Utskriften fortsätter.',
+      couldNotRunTitle: 'Plattkontrollen kunde inte köras',
+      unavailableReasons: {
+        no_camera_frame: 'ingen kamerabild',
+        decode_failed: 'kamerabilden kunde inte läsas',
+        analysis_error: 'bildanalysen misslyckades',
+        opencv_missing: 'bildanalys är inte installerad',
+        not_calibrated: 'ingen kalibrering av den tomma plattan',
+        error: 'ett oväntat fel',
+      },
       confidence: 'Säkerhet',
       difference: 'Skillnad',
       analysisPreview: 'Analysförhandsvisning:',
@@ -1682,6 +1692,9 @@ export default {
       historyCleared: 'Rensade {{count}} historikpost(er)',
       clearHistoryFailed: 'Kunde inte rensa historik',
       updateFailed: 'Kunde inte uppdatera poster',
+      bulkUpdated: '{{updated}} jobb uppdaterade',
+      bulkUpdatedSome: '{{updated}} jobb uppdaterade; {{skipped}} kunde inte ändras',
+      bulkUpdatedNone: 'Inga jobb uppdaterades',
       bulkCancelled: 'Avbröt {{count}} post(er)',
       bulkCancelFailed: 'Kunde inte avbryta poster',
       batchCreated: 'Batch "{{name}}" skapad',

+ 13 - 0
frontend/src/i18n/locales/tr.ts

@@ -841,6 +841,16 @@ export default {
       calibrationTip: '<strong>İpucu:</strong> Farklı plakalar için 5 kalibrasyona kadar saklayabilirsiniz. Sistem, kontrol sırasında en iyi eşleşmeyi otomatik olarak kullanır.',
       plateEmpty: 'Plaka boş görünüyor',
       objectsDetected: 'Plakada nesneler algılandı',
+      couldNotRun: '{{printer}} üzerinde tabla kontrolü yapılamadı ({{reason}}). Baskı devam ediyor.',
+      couldNotRunTitle: 'Tabla kontrolü yapılamadı',
+      unavailableReasons: {
+        no_camera_frame: 'kamera görüntüsü yok',
+        decode_failed: 'kamera görüntüsü okunamadı',
+        analysis_error: 'görüntü analizi başarısız oldu',
+        opencv_missing: 'görüntü analizi kurulu değil',
+        not_calibrated: 'boş tabla kalibrasyonu yok',
+        error: 'beklenmeyen bir hata',
+      },
       confidence: 'Güven',
       difference: 'Fark',
       analysisPreview: 'Analiz önizlemesi:',
@@ -1666,6 +1676,9 @@ export default {
       historyCleared: '{{count}} geçmiş öğesi temizlendi',
       clearHistoryFailed: 'Geçmiş temizlenemedi',
       updateFailed: 'Öğeler güncellenemedi',
+      bulkUpdated: '{{updated}} iş güncellendi',
+      bulkUpdatedSome: '{{updated}} iş güncellendi; {{skipped}} tanesi değiştirilemedi',
+      bulkUpdatedNone: 'Hiçbir iş güncellenmedi',
       bulkCancelled: '{{count}} öğe iptal edildi',
       bulkCancelFailed: 'Öğeler iptal edilemedi',
       batchCreated: '"{{name}}" yığını oluşturuldu',

+ 13 - 0
frontend/src/i18n/locales/uk.ts

@@ -845,6 +845,16 @@ export default {
       calibrationTip: "<strong>Порада:</strong> Ви можете зберегти до 5 калібрувань для різних пластин. Під час перевірки система автоматично використовує найкращий збіг.",
       plateEmpty: "Друкарська пластина виглядає порожньою",
       objectsDetected: "На пластині виявлено об’єкти",
+      couldNotRun: 'Не вдалося перевірити стіл на {{printer}} ({{reason}}). Друк триває.',
+      couldNotRunTitle: 'Перевірку столу не виконано',
+      unavailableReasons: {
+        no_camera_frame: 'немає зображення з камери',
+        decode_failed: 'не вдалося прочитати зображення з камери',
+        analysis_error: 'не вдалося проаналізувати зображення',
+        opencv_missing: 'аналіз зображень не встановлено',
+        not_calibrated: 'немає калібрування порожнього столу',
+        error: 'неочікувана помилка',
+      },
       confidence: "Впевненість",
       difference: "Різниця",
       analysisPreview: "Попередній перегляд аналізу:",
@@ -1681,6 +1691,9 @@ export default {
       historyCleared: "Очищено елементів історії: {{count}}",
       clearHistoryFailed: "Не вдалося очистити історію",
       updateFailed: "Не вдалося оновити елементи",
+      bulkUpdated: 'Оновлено завдань: {{updated}}',
+      bulkUpdatedSome: 'Оновлено завдань: {{updated}}; {{skipped}} змінити не вдалося',
+      bulkUpdatedNone: 'Жодне завдання не оновлено',
       bulkCancelled: "Скасовано елементів: {{count}}",
       bulkCancelFailed: "Не вдалося скасувати елементи",
       batchCreated: "Партію «{{name}}» створено",

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

@@ -841,6 +841,16 @@ export default {
       calibrationTip: '<strong>提示:</strong>您最多可以为不同的打印板存储5个校准。系统会在检查时自动使用最佳匹配。',
       plateEmpty: '打印板似乎是空的',
       objectsDetected: '在打印板上检测到物体',
+      couldNotRun: '无法在 {{printer}} 上执行热床检查({{reason}})。打印将继续。',
+      couldNotRunTitle: '无法执行热床检查',
+      unavailableReasons: {
+        no_camera_frame: '没有摄像头图像',
+        decode_failed: '无法读取摄像头图像',
+        analysis_error: '图像分析失败',
+        opencv_missing: '未安装图像分析',
+        not_calibrated: '没有空热床的校准',
+        error: '意外错误',
+      },
       confidence: '置信度',
       difference: '差异',
       analysisPreview: '分析预览:',
@@ -1666,6 +1676,9 @@ export default {
       historyCleared: '已清除 {{count}} 条历史记录',
       clearHistoryFailed: '清除历史失败',
       updateFailed: '更新项目失败',
+      bulkUpdated: '已更新 {{updated}} 个任务',
+      bulkUpdatedSome: '已更新 {{updated}} 个任务;{{skipped}} 个无法更改',
+      bulkUpdatedNone: '没有更新任何任务',
       bulkCancelled: '已取消 {{count}} 个项目',
       bulkCancelFailed: '批量取消项目失败',
       batchCreated: '已创建批次"{{name}}"',

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

@@ -841,6 +841,16 @@ export default {
       calibrationTip: '<strong>提示:</strong>您最多可以為不同的列印板儲存5個校準。系統會在檢查時自動使用最佳匹配。',
       plateEmpty: '列印板似乎是空的',
       objectsDetected: '在列印板上偵測到物體',
+      couldNotRun: '無法在 {{printer}} 上執行熱床檢查({{reason}})。列印將繼續。',
+      couldNotRunTitle: '無法執行熱床檢查',
+      unavailableReasons: {
+        no_camera_frame: '沒有攝影機影像',
+        decode_failed: '無法讀取攝影機影像',
+        analysis_error: '影像分析失敗',
+        opencv_missing: '未安裝影像分析',
+        not_calibrated: '沒有空熱床的校準',
+        error: '未預期的錯誤',
+      },
       confidence: '置信度',
       difference: '差異',
       analysisPreview: '分析預覽:',
@@ -1666,6 +1676,9 @@ export default {
       historyCleared: '已清除 {{count}} 條歷史紀錄',
       clearHistoryFailed: '清除歷史失敗',
       updateFailed: '更新項目失敗',
+      bulkUpdated: '已更新 {{updated}} 個工作',
+      bulkUpdatedSome: '已更新 {{updated}} 個工作;{{skipped}} 個無法變更',
+      bulkUpdatedNone: '沒有更新任何工作',
       bulkCancelled: '已取消 {{count}} 個項目',
       bulkCancelFailed: '批次取消項目失敗',
       batchCreated: '已建立批次「{{name}}」',

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

@@ -197,6 +197,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { FeedDirectionModal } from '../components/FeedDirectionModal';
+import { plateCheckFailureText } from '../utils/plateCheck';
 import { amsHasLoadedTray, getAmsLabel, getEmptySlotKind, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isAmsHtUnit, isBambuLabSpool, trayForSlot, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, slotPresetDescribesTray, FTS_INLET_SIDE } from '../utils/amsHelpers';
 import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
@@ -2271,6 +2272,7 @@ function PrinterCard({
     message: string;
     debug_image_url?: string;
     needs_calibration: boolean;
+    failure_reason?: string | null;
     light_warning?: boolean;
     reference_count?: number;
     max_references?: number;
@@ -6848,6 +6850,8 @@ function PrinterCard({
               <div className="flex items-center gap-2">
                 {plateCheckResult.needs_calibration ? (
                   <ScanSearch className="w-[var(--pc-i5,1.25rem)] h-[var(--pc-i5,1.25rem)] text-blue-500" />
+                ) : plateCheckResult.failure_reason ? (
+                  <XCircle className="w-[var(--pc-i5,1.25rem)] h-[var(--pc-i5,1.25rem)] text-bambu-gray" />
                 ) : plateCheckResult.is_empty ? (
                   <CheckCircle className="w-[var(--pc-i5,1.25rem)] h-[var(--pc-i5,1.25rem)] text-green-500" />
                 ) : (
@@ -6883,6 +6887,11 @@ function PrinterCard({
                     <p dangerouslySetInnerHTML={{ __html: t('printers.plateDetection.calibrationTip') }} />
                   </div>
                 </>
+              ) : plateCheckResult.failure_reason ? (
+                <div className="p-3 rounded-lg bg-bambu-dark-tertiary border border-bambu-dark-tertiary">
+                  <p className="font-medium text-white">{t('printers.plateDetection.couldNotRunTitle')}</p>
+                  <p className="text-sm text-bambu-gray mt-1">{plateCheckFailureText(t, plateCheckResult.failure_reason)}</p>
+                </div>
               ) : (
                 <>
                   <div className={`p-3 rounded-lg ${plateCheckResult.is_empty ? 'bg-green-100 dark:bg-green-500/20 border border-green-300 dark:border-green-500/50' : 'bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-300 dark:border-yellow-500/50'}`}>

+ 3 - 1
frontend/src/pages/QueuePage.tsx

@@ -1,4 +1,5 @@
 import { useState, useMemo, useEffect, useCallback } from 'react';
+import { bulkUpdateToast } from '../utils/queueBulkUpdate';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Link } from 'react-router-dom';
@@ -2008,7 +2009,8 @@ export function QueuePage() {
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       setSelectedItems([]);
       setShowBulkEditModal(false);
-      showToast(result.message);
+      const toast = bulkUpdateToast(result);
+      showToast(t(toast.key, toast.params), toast.type);
     },
     onError: () => showToast(t('queue.toast.updateFailed'), 'error'),
   });

+ 15 - 0
frontend/src/utils/plateCheck.ts

@@ -0,0 +1,15 @@
+import type { TFunction } from 'i18next';
+
+const PLATE_CHECK_FAILURE_REASONS = [
+  'no_camera_frame',
+  'decode_failed',
+  'analysis_error',
+  'opencv_missing',
+  'not_calibrated',
+] as const;
+
+/** Why a plate check could not run, in the reader's language. */
+export function plateCheckFailureText(t: TFunction, reason: string | null | undefined): string {
+  const known = (PLATE_CHECK_FAILURE_REASONS as readonly string[]).includes(reason ?? '');
+  return t(`printers.plateDetection.unavailableReasons.${known ? reason : 'error'}`);
+}

+ 11 - 0
frontend/src/utils/queueBulkUpdate.ts

@@ -0,0 +1,11 @@
+/** The toast for a queue bulk edit, from what the backend updated and skipped. */
+export function bulkUpdateToast(result: { updated_count: number; skipped_count: number }): {
+  key: string;
+  params: Record<string, number>;
+  type: 'success' | 'warning' | 'error';
+} {
+  const params = { updated: result.updated_count, skipped: result.skipped_count };
+  if (result.updated_count === 0) return { key: 'queue.toast.bulkUpdatedNone', params, type: 'error' };
+  if (result.skipped_count > 0) return { key: 'queue.toast.bulkUpdatedSome', params, type: 'warning' };
+  return { key: 'queue.toast.bulkUpdated', params, type: 'success' };
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/AdditionalSection-Doa186JT.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/AdditionalSection-L-15MXNk.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/AmsUnitCard-BV3Srg61.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ArchivesPage-B4hbjTm5.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ArchivesPage-C8xaZfPh.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/AssignToAmsModal-oHg_-VzX.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CamWallPage-CKwIox3i.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CameraDiagnoseModal-rsXlo7XK.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CameraPage-DaU4aAdP.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CameraWall-CV7N5RPN.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ColumnConfigModal-CbMSr6Vc.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ConfigureAmsSlotModal-Cft8_zh3.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ConfigureAmsSlotModal-DC9_cLDm.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ConnectAuthorizePage-BxLTNn1X.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ContextMenu-C1c-V3yk.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ExternalLinkPage-Dvv7J3m6.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 1
static/assets/FileManagerPage-Dyd7cpJv.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/FileUploadModal-zZp7aS5v.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/FinancePage-Bcnaagls.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/GCodeViewerPage-B-iQmPmI.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/GroupEditPage-BWx6_-LP.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ImagePreviewModal-DtN5vOS2.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/InventoryPage-DPb6qbDa.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/InventoryPage-DjaDMrPH.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/LibraryTrashPage-BxGkHrfc.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/LoginPage-DNnkmXTo.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/LoginPage-n1FUbhvM.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/MaintenancePage-07IZyhJ2.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/MaintenancePage-BPZYQvLz.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ModelSourcesPage-B9OO9GmK.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ModelSourcesPage-BSGlDWqx.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/NotificationsPage-qSg5cczw.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 1
static/assets/PdfPreviewModal-Dx5F5d6D.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/PhotoGalleryModal-D5z0bauD.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/PrinterLocationsPage-8pmlv2S6.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/PrintersPage-0TUMi4jA.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/PrintersPage-CdUF8kSL.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ProfilesPage-Blrzv8Jz.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ProfilesPage-DefkWrEW.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ProjectDetailPage-BOxYfr7z.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ProjectsPage-CmcOz5CS.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ProjectsPage-DWkL3KO4.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/QueuePage-C3qqdE-w.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/QueuePage-CY5DMk-B.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/RichTextEditor-C3EKnW2P.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SettingsPage-C-mEB0JB.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SetupPage-D_iQ0QTN.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SliceModal-DcQAciyD.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyAmsPage-R5lmC3oq.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyDashboard-BJ1m8qFd.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-BpWa12xs.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 1
static/assets/SpoolBuddySettingsPage-BfCI16Sz.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-B4TAT5Wj.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-C4bFxvMZ.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpreadsheetPreviewModal-CxLsrrKs.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/StatsPage-Brst6AkI.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/StreamOverlayPage-lTrRmI-d.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SystemInfoPage-CxlNdoRS.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/de-B-xQkGCT.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/de-BqH_BwEQ.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/es-BvEQLmnW.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/es-Lnnz1GCC.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/fr-CX0fIqzU.js


Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů