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

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 4 дней назад
Родитель
Сommit
ce9853c056
100 измененных файлов с 816 добавлено и 18 удалено
  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.
 - **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
 ### 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 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 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.
 - **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
         - difference_percent: float - How different from calibration reference
         - message: str - Human-readable result message
         - message: str - Human-readable result message
         - needs_calibration: bool - True if calibration is required
         - 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
         - light_warning: bool - True if chamber light is off
     """
     """
     from backend.app.services.plate_detection import (
     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.archive_purge import archive_purge_service
 from backend.app.services.bambu_ftp import (
 from backend.app.services.bambu_ftp import (
+    RECOVERY_DOWNLOAD_MAX_TIMEOUT,
     FileNotOnPrinterError,
     FileNotOnPrinterError,
     cache_3mf_download,
     cache_3mf_download,
     clear_3mf_cache,
     clear_3mf_cache,
@@ -4008,6 +4009,7 @@ def _schedule_fallback_3mf_retry(
                         temp_path,
                         temp_path,
                         socket_timeout=ftp_timeout,
                         socket_timeout=ftp_timeout,
                         printer_model=printer_model,
                         printer_model=printer_model,
+                        max_total_timeout=RECOVERY_DOWNLOAD_MAX_TIMEOUT,
                     )
                     )
                 except Exception as e:
                 except Exception as e:
                     logger.debug("[RECOVER] Retry download of %s failed: %s", name, 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):
 async def on_print_start(printer_id: int, data: dict):
     """Handle print start - archive the 3MF file immediately."""
     """Handle print start - archive the 3MF file immediately."""
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
@@ -4456,6 +4469,7 @@ async def on_print_start(printer_id: int, data: dict):
 
 
                 plate_photo_data = None
                 plate_photo_data = None
                 objects_detected = False
                 objects_detected = False
+                check_failure: str | None = None
                 try:
                 try:
                     logger.info("[PLATE CHECK] Running plate detection for printer %s", printer_id)
                     logger.info("[PLATE CHECK] Running plate detection for printer %s", printer_id)
                     plate_result = await check_plate_empty(
                     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,
                         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:
                     if objects_detected:
                         # Objects detected - pause the print!
                         # Objects detected - pause the print!
                         logger.warning(
                         logger.warning(
@@ -4527,11 +4546,24 @@ async def on_print_start(printer_id: int, data: dict):
                         )
                         )
                     except Exception as notif_err:
                     except Exception as notif_err:
                         logger.warning("[PLATE CHECK] Failed to send notification: %s", 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:
                 else:
                     logger.info("[PLATE CHECK] Plate is empty for printer %s, proceeding with print", printer_id)
                     logger.info("[PLATE CHECK] Plate is empty for printer %s, proceeding with print", printer_id)
             except Exception as plate_err:
             except Exception as plate_err:
                 # Don't block print on plate detection errors
                 # 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:
         if not printer:
             logger.info("[CALLBACK] Skipping archive - printer not found in database")
             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.
 # the floor is pessimistic by design and a real link is not that slow.
 _DOWNLOAD_MAX_TIMEOUT = 300.0
 _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:
 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.
     """Extra seconds to allow a download the printer says is this big.
@@ -534,6 +538,7 @@ class BambuFTPClient:
         printer_model: str | None = None,
         printer_model: str | None = None,
         force_prot_c: bool = False,
         force_prot_c: bool = False,
         respect_handshake_cooloff: bool = True,
         respect_handshake_cooloff: bool = True,
+        records_handshake_cooloff: bool = True,
     ):
     ):
         """Set ``respect_handshake_cooloff=False`` for bounded, user-initiated work.
         """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
         Leave it at the default everywhere else. Opting out is only defensible
         because the caller's own connection count is bounded and small.
         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.ip_address = ip_address
         self.access_code = access_code
         self.access_code = access_code
@@ -553,6 +562,7 @@ class BambuFTPClient:
         self.printer_model = printer_model
         self.printer_model = printer_model
         self.force_prot_c = force_prot_c
         self.force_prot_c = force_prot_c
         self.respect_handshake_cooloff = respect_handshake_cooloff
         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
         # 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
         # only gets a bool back (#2899). Per instance, so it describes one
         # operation and cannot be overwritten by work against another printer.
         # 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.
             # sending people to do the one thing already known not to work.
             logger.warning(
             logger.warning(
                 "FTP SSL error connecting to %s: %s — the printer answered port %s with something "
                 "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,
                 self.ip_address,
                 e,
                 e,
                 self.FTP_PORT,
                 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
             # Close the dead socket before asking this printer for anything
             # else. The probe below opens a second connection, and the leading
             # else. The probe below opens a second connection, and the leading
@@ -748,7 +759,8 @@ class BambuFTPClient:
                         self.ip_address,
                         self.ip_address,
                         self.FTP_PORT,
                         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)
             self.last_failure = FtpFailure(FtpFailureKind.HANDSHAKE, detail)
             return False
             return False
         except (OSError, ftplib.Error) as e:
         except (OSError, ftplib.Error) as e:
@@ -1262,6 +1274,9 @@ class BambuFTPClient:
                     e,
                     e,
                     type(e).__name__,
                     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:
             if callback_exception is not None:
                 cleanup_result: DeleteResult = DeleteResult.FAILED
                 cleanup_result: DeleteResult = DeleteResult.FAILED
@@ -1394,11 +1409,32 @@ class BambuFTPClient:
                     )
                     )
                     return False
                     return False
             except Exception:
             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
             return True
         except (OSError, ftplib.Error):
         except (OSError, ftplib.Error):
             return False
             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:
     def delete_file(self, remote_path: str) -> DeleteResult:
         """Delete a file from the printer.
         """Delete a file from the printer.
 
 
@@ -1485,6 +1521,31 @@ class BambuFTPClient:
         return result if result else None
         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:
 def describe_upload_failure(failure: FtpFailure | None) -> str:
     """One sentence for the operator, chosen from what actually went wrong.
     """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,
     socket_timeout: float | None = None,
     printer_model: str | None = None,
     printer_model: str | None = None,
     timeout: float = 90.0,
     timeout: float = 90.0,
+    max_total_timeout: float | None = None,
 ) -> str | None:
 ) -> str | None:
     """Try downloading a file from multiple paths using a single connection.
     """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
             orphaned (#2957), so a DB-holding caller's worst case is the gate
             wait plus this cap plus one unwind -- still bounded, and the
             wait plus this cap plus one unwind -- still bounded, and the
             orphaned worker no longer keeps the printer's socket after it.
             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()
     loop = asyncio.get_event_loop()
     # An executor thread cannot be cancelled, so the cap alone used to leave a
     # 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.
     # flag stops it at the next chunk instead.
     cancel = threading.Event()
     cancel = threading.Event()
     done = threading.Event()
     done = threading.Event()
+    reported: dict = {}
 
 
     def _download():
     def _download():
         try:
         try:
@@ -1925,7 +1992,12 @@ async def download_file_try_paths_async(
                     if cancel.is_set():
                     if cancel.is_set():
                         return None
                         return None
                     try:
                     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
                             return remote_path
                     except FileNotOnPrinterError:
                     except FileNotOnPrinterError:
                         continue
                         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"):
     async with _serialized_download(ip_address, f"a {len(remote_paths)}-path lookup"):
         worker = loop.run_in_executor(_ftp_executor, _download)
         worker = loop.run_in_executor(_ftp_executor, _download)
         try:
         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:
         except asyncio.CancelledError:
             # The caller is going away and should not be made to wait, but the
             # The caller is going away and should not be made to wait, but the
             # worker must not keep the printer to itself either.
             # 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
 from __future__ import annotations
 
 
 import asyncio
 import asyncio
+import io
 import logging
 import logging
+import zipfile
 from collections.abc import Awaitable, Callable
 from collections.abc import Awaitable, Callable
 from dataclasses import replace
 from dataclasses import replace
 from typing import Any
 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):
 class MakerWorldService(ProviderService):
     """Per-request MakerWorld API client.
     """Per-request MakerWorld API client.
 
 
@@ -273,6 +285,10 @@ class MakerWorldService(ProviderService):
     async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
     async def download(self, info: ProviderDownloadInfo) -> ProviderDownload:
         """Fetch the 3MF bytes for a signed URL, returning ``(bytes, filename)``."""
         """Fetch the 3MF bytes for a signed URL, returning ``(bytes, filename)``."""
         file_bytes, download_filename = await self.download_3mf(info.url)
         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)
         return ProviderDownload(file_bytes=file_bytes, filename=download_filename)
 
 
     # ---------------------------------------------------------------- endpoints
     # ---------------------------------------------------------------- endpoints

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

@@ -463,9 +463,12 @@ class NotificationService:
         if not phone or not apikey:
         if not phone or not apikey:
             return False, "Phone number and API key are required"
             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()
         client = await self._get_client()
         response = await client.get(url)
         response = await client.get(url)

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

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

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

@@ -19,6 +19,7 @@ import socket
 import threading
 import threading
 import time
 import time
 from pathlib import Path
 from pathlib import Path
+from unittest.mock import patch
 
 
 import pytest
 import pytest
 
 
@@ -451,6 +452,88 @@ class TestUpload:
         assert result is False
         assert result is False
         client.disconnect()
         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):
     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
         """Some P2S firmware revisions return 426 on voidresp() even when the
         file landed fully (TLS data-channel close races the 226). #1417
         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
         assert svc._owns_client is True
         await svc.close()
         await svc.close()
         assert svc._client.is_closed
         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
+@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:
 class TestTheCapNoLongerLeavesAWorkerOnTheSocket:
     async def test_a_capped_path_walk_stops_its_worker(self, tmp_path):
     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
         """``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:
     for d in dicts:
         d.clear()
         d.clear()
+    BROADCASTS.clear()
     yield
     yield
     for d in dicts:
     for d in dicts:
         d.clear()
         d.clear()
 
 
 
 
+BROADCASTS: list[dict] = []
+
+
 def _printer():
 def _printer():
     printer = MagicMock()
     printer = MagicMock()
     printer.id = 1
     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.__aexit__ = AsyncMock()
     session.execute = AsyncMock(side_effect=execute_router)
     session.execute = AsyncMock(side_effect=execute_router)
 
 
-    async def broadcast(_message):
+    async def broadcast(message):
         events.append("broadcast")
         events.append("broadcast")
+        BROADCASTS.append(message)
 
 
     async def notify(**_kwargs):
     async def notify(**_kwargs):
         events.append("notify")
         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")))
     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
 @pytest.mark.asyncio
@@ -146,3 +151,53 @@ async def test_light_goes_off_when_pausing_raises():
 
 
     assert "light:off" in events
     assert "light:off" in events
     assert "notify" not 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 () => {
     it('closes modal when I Understand button is clicked', async () => {
       render(<Layout />);
       render(<Layout />);
 
 

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

@@ -468,6 +468,30 @@ describe('useWebSocket hook', () => {
       vi.unstubAllGlobals();
       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 () => {
     it('invalidates inventory queries on inventory_changed message', async () => {
       vi.useFakeTimers();
       vi.useFakeTimers();
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       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 { AnnouncementsPanel } from './AnnouncementsPanel';
 import { AnnouncementBanner } from './AnnouncementBanner';
 import { AnnouncementBanner } from './AnnouncementBanner';
 import { useAnnouncements } from '../hooks/useAnnouncements';
 import { useAnnouncements } from '../hooks/useAnnouncements';
+import { plateCheckFailureText } from '../utils/plateCheck';
 import {
 import {
   getHiddenSidebarSystemItemIds,
   getHiddenSidebarSystemItemIds,
   getSidebarOrder,
   getSidebarOrder,
@@ -488,6 +489,26 @@ export function Layout() {
     return () => window.removeEventListener('plate-not-empty', handlePlateNotEmpty);
     return () => window.removeEventListener('plate-not-empty', handlePlateNotEmpty);
   }, [hasPermission]);
   }, [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
   // 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
   // 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
   // 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;
         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':
       case 'announcements_changed':
         // The backend fetched a newer feed: re-read the list so the sidebar dot
         // 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.
         // 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.',
       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',
       plateEmpty: 'Platte erscheint leer',
       objectsDetected: 'Objekte auf Platte erkannt',
       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',
       confidence: 'Konfidenz',
       difference: 'Differenz',
       difference: 'Differenz',
       analysisPreview: 'Analysevorschau:',
       analysisPreview: 'Analysevorschau:',
@@ -1666,6 +1676,9 @@ export default {
       historyCleared: '{{count}} Verlaufselement(e) gelöscht',
       historyCleared: '{{count}} Verlaufselement(e) gelöscht',
       clearHistoryFailed: 'Verlauf konnte nicht gelöscht werden',
       clearHistoryFailed: 'Verlauf konnte nicht gelöscht werden',
       updateFailed: 'Elemente konnten nicht aktualisiert 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',
       bulkCancelled: '{{count}} Element(e) abgebrochen',
       bulkCancelFailed: 'Elemente konnten nicht abgebrochen werden',
       bulkCancelFailed: 'Elemente konnten nicht abgebrochen werden',
       batchCreated: 'Stapel „{{name}}“ erstellt',
       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.',
       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',
       plateEmpty: 'Plate appears empty',
       objectsDetected: 'Objects detected on plate',
       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',
       confidence: 'Confidence',
       difference: 'Difference',
       difference: 'Difference',
       analysisPreview: 'Analysis preview:',
       analysisPreview: 'Analysis preview:',
@@ -1684,6 +1694,9 @@ export default {
       historyCleared: 'Cleared {{count}} history item(s)',
       historyCleared: 'Cleared {{count}} history item(s)',
       clearHistoryFailed: 'Failed to clear history',
       clearHistoryFailed: 'Failed to clear history',
       updateFailed: 'Failed to update items',
       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)',
       bulkCancelled: 'Cancelled {{count}} item(s)',
       bulkCancelFailed: 'Failed to cancel items',
       bulkCancelFailed: 'Failed to cancel items',
       batchCreated: 'Batch "{{name}}" created',
       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.',
       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',
       plateEmpty: 'La cama parece estar vacía',
       objectsDetected: 'Objetos detectados en la cama',
       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',
       confidence: 'Confianza',
       difference: 'Diferencia',
       difference: 'Diferencia',
       analysisPreview: 'Vista previa del análisis:',
       analysisPreview: 'Vista previa del análisis:',
@@ -1666,6 +1676,9 @@ export default {
       historyCleared: 'Se borraron {{count}} elemento(s) del historial',
       historyCleared: 'Se borraron {{count}} elemento(s) del historial',
       clearHistoryFailed: 'Error al borrar el historial',
       clearHistoryFailed: 'Error al borrar el historial',
       updateFailed: 'Error al actualizar los elementos',
       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)',
       bulkCancelled: 'Se cancelaron {{count}} elemento(s)',
       bulkCancelFailed: 'Error al cancelar los elementos',
       bulkCancelFailed: 'Error al cancelar los elementos',
       batchCreated: 'Lote "{{name}}" creado',
       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.',
       calibrationTip: 'Conseil : Stockez jusqu\'à 5 références. Le système utilise la meilleure correspondance.',
       plateEmpty: 'Le plateau semble vide',
       plateEmpty: 'Le plateau semble vide',
       objectsDetected: 'Objets détectés sur le plateau',
       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',
       confidence: 'Confiance',
       difference: 'Différence',
       difference: 'Différence',
       analysisPreview: 'Aperçu de l\'analyse :',
       analysisPreview: 'Aperçu de l\'analyse :',
@@ -1666,6 +1676,9 @@ export default {
       historyCleared: '{{count}} éléments effacés',
       historyCleared: '{{count}} éléments effacés',
       clearHistoryFailed: 'Échec effacement',
       clearHistoryFailed: 'Échec effacement',
       updateFailed: 'Échec mise à jour',
       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',
       bulkCancelled: '{{count}} éléments annulés',
       bulkCancelFailed: 'Échec annulation',
       bulkCancelFailed: 'Échec annulation',
       batchCreated: 'Lot « {{name}} » créé',
       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.',
       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',
       plateEmpty: 'Il piatto sembra vuoto',
       objectsDetected: 'Oggetti rilevati sul piatto',
       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',
       confidence: 'Confidenza',
       difference: 'Differenza',
       difference: 'Differenza',
       analysisPreview: 'Anteprima analisi:',
       analysisPreview: 'Anteprima analisi:',
@@ -1666,6 +1676,9 @@ export default {
       historyCleared: 'Cancellati {{count}} elementi cronologia',
       historyCleared: 'Cancellati {{count}} elementi cronologia',
       clearHistoryFailed: 'Svuotamento cronologia non riuscito',
       clearHistoryFailed: 'Svuotamento cronologia non riuscito',
       updateFailed: 'Aggiornamento elementi 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',
       bulkCancelled: 'Annullati {{count}} elementi',
       bulkCancelFailed: 'Annullamento elementi non riuscito',
       bulkCancelFailed: 'Annullamento elementi non riuscito',
       batchCreated: 'Lotto «{{name}}» creato',
       batchCreated: 'Lotto «{{name}}» creato',

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

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

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

@@ -799,6 +799,16 @@ export default {
       calibrationTip: '<strong>팁:</strong> 다른 플레이트에 대해 최대 5개의 캘리브레이션을 저장할 수 있습니다. 확인 시 시스템이 자동으로 가장 잘 맞는 것을 사용합니다.',
       calibrationTip: '<strong>팁:</strong> 다른 플레이트에 대해 최대 5개의 캘리브레이션을 저장할 수 있습니다. 확인 시 시스템이 자동으로 가장 잘 맞는 것을 사용합니다.',
       plateEmpty: '플레이트가 비어 있는 것으로 보입니다',
       plateEmpty: '플레이트가 비어 있는 것으로 보입니다',
       objectsDetected: '플레이트에 객체가 감지되었습니다',
       objectsDetected: '플레이트에 객체가 감지되었습니다',
+      couldNotRun: '{{printer}}에서 플레이트 검사를 실행할 수 없습니다({{reason}}). 출력은 계속됩니다.',
+      couldNotRunTitle: '플레이트 검사를 실행할 수 없음',
+      unavailableReasons: {
+        no_camera_frame: '카메라 이미지 없음',
+        decode_failed: '카메라 이미지를 읽을 수 없음',
+        analysis_error: '이미지 분석 실패',
+        opencv_missing: '이미지 분석이 설치되지 않음',
+        not_calibrated: '빈 플레이트 보정이 없습니다',
+        error: '예기치 않은 오류',
+      },
       confidence: '신뢰도',
       confidence: '신뢰도',
       difference: '차이',
       difference: '차이',
       analysisPreview: '분석 미리보기:',
       analysisPreview: '분석 미리보기:',
@@ -1587,6 +1597,9 @@ export default {
       historyCleared: '{{count}}개 기록 항목이 지워졌습니다',
       historyCleared: '{{count}}개 기록 항목이 지워졌습니다',
       clearHistoryFailed: '기록 지우기 실패',
       clearHistoryFailed: '기록 지우기 실패',
       updateFailed: '항목 업데이트 실패',
       updateFailed: '항목 업데이트 실패',
+      bulkUpdated: '작업 {{updated}}개를 업데이트했습니다',
+      bulkUpdatedSome: '작업 {{updated}}개를 업데이트했습니다. {{skipped}}개는 변경할 수 없었습니다',
+      bulkUpdatedNone: '업데이트된 작업이 없습니다',
       bulkCancelled: '{{count}}개 항목이 취소되었습니다',
       bulkCancelled: '{{count}}개 항목이 취소되었습니다',
       bulkCancelFailed: '항목 취소 실패',
       bulkCancelFailed: '항목 취소 실패',
       batchCreated: '"{{name}}" 배치를 만들었습니다',
       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.',
       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',
       plateEmpty: 'Plaat lijkt leeg',
       objectsDetected: 'Objecten op plaat gedetecteerd',
       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',
       confidence: 'Betrouwbaarheid',
       difference: 'Verschil',
       difference: 'Verschil',
       analysisPreview: 'Analysevoorbeeld:',
       analysisPreview: 'Analysevoorbeeld:',
@@ -1682,6 +1692,9 @@ export default {
       historyCleared: '{{count}} geschiedenisitem(s) gewist',
       historyCleared: '{{count}} geschiedenisitem(s) gewist',
       clearHistoryFailed: 'Geschiedenis wissen mislukt',
       clearHistoryFailed: 'Geschiedenis wissen mislukt',
       updateFailed: 'Items bijwerken 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',
       bulkCancelled: '{{count}} item(s) geannuleerd',
       bulkCancelFailed: 'Items annuleren mislukt',
       bulkCancelFailed: 'Items annuleren mislukt',
       batchCreated: 'Batch "{{name}}" gemaakt',
       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.',
       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',
       plateEmpty: 'A placa parece vazia',
       objectsDetected: 'Objetos detectados na placa',
       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',
       confidence: 'Confiança',
       difference: 'Diferença',
       difference: 'Diferença',
       analysisPreview: 'Pré-visualização da análise:',
       analysisPreview: 'Pré-visualização da análise:',
@@ -1666,6 +1676,9 @@ export default {
       historyCleared: 'Limpar {{count}} item(s) do histórico',
       historyCleared: 'Limpar {{count}} item(s) do histórico',
       clearHistoryFailed: 'Falha ao limpar histórico',
       clearHistoryFailed: 'Falha ao limpar histórico',
       updateFailed: 'Falha ao atualizar itens',
       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)',
       bulkCancelled: 'Cancelado {{count}} item(s)',
       bulkCancelFailed: 'Falha ao cancelar itens',
       bulkCancelFailed: 'Falha ao cancelar itens',
       batchCreated: 'Lote "{{name}}" criado',
       batchCreated: 'Lote "{{name}}" criado',

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

@@ -804,6 +804,16 @@ export default {
       calibrationTip: "<strong>Совет:</strong> можно сохранить до 5 калибровок для разных пластин. При проверке система автоматически выберет наиболее подходящий эталон.",
       calibrationTip: "<strong>Совет:</strong> можно сохранить до 5 калибровок для разных пластин. При проверке система автоматически выберет наиболее подходящий эталон.",
       plateEmpty: "Пластина выглядит пустой",
       plateEmpty: "Пластина выглядит пустой",
       objectsDetected: "На пластине обнаружены объекты",
       objectsDetected: "На пластине обнаружены объекты",
+      couldNotRun: 'Не удалось проверить стол на {{printer}} ({{reason}}). Печать продолжается.',
+      couldNotRunTitle: 'Проверка стола не выполнена',
+      unavailableReasons: {
+        no_camera_frame: 'нет изображения с камеры',
+        decode_failed: 'не удалось прочитать изображение с камеры',
+        analysis_error: 'не удалось проанализировать изображение',
+        opencv_missing: 'анализ изображений не установлен',
+        not_calibrated: 'нет калибровки пустого стола',
+        error: 'непредвиденная ошибка',
+      },
       confidence: "Уверенность",
       confidence: "Уверенность",
       difference: "Различие",
       difference: "Различие",
       analysisPreview: "Предпросмотр анализа:",
       analysisPreview: "Предпросмотр анализа:",
@@ -1601,6 +1611,9 @@ export default {
       historyCleared: "Удалено записей истории: {{count}}",
       historyCleared: "Удалено записей истории: {{count}}",
       clearHistoryFailed: "Не удалось очистить историю",
       clearHistoryFailed: "Не удалось очистить историю",
       updateFailed: "Не удалось обновить задания",
       updateFailed: "Не удалось обновить задания",
+      bulkUpdated: 'Обновлено заданий: {{updated}}',
+      bulkUpdatedSome: 'Обновлено заданий: {{updated}}; {{skipped}} изменить не удалось',
+      bulkUpdatedNone: 'Ни одно задание не обновлено',
       bulkCancelled: "Отменено заданий: {{count}}",
       bulkCancelled: "Отменено заданий: {{count}}",
       bulkCancelFailed: "Не удалось отменить задания",
       bulkCancelFailed: "Не удалось отменить задания",
       batchCreated: "Партия «{{name}}» создана",
       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.',
       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',
       plateEmpty: 'Plattan verkar tom',
       objectsDetected: 'Objekt upptäckta på plattan',
       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',
       confidence: 'Säkerhet',
       difference: 'Skillnad',
       difference: 'Skillnad',
       analysisPreview: 'Analysförhandsvisning:',
       analysisPreview: 'Analysförhandsvisning:',
@@ -1682,6 +1692,9 @@ export default {
       historyCleared: 'Rensade {{count}} historikpost(er)',
       historyCleared: 'Rensade {{count}} historikpost(er)',
       clearHistoryFailed: 'Kunde inte rensa historik',
       clearHistoryFailed: 'Kunde inte rensa historik',
       updateFailed: 'Kunde inte uppdatera poster',
       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)',
       bulkCancelled: 'Avbröt {{count}} post(er)',
       bulkCancelFailed: 'Kunde inte avbryta poster',
       bulkCancelFailed: 'Kunde inte avbryta poster',
       batchCreated: 'Batch "{{name}}" skapad',
       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.',
       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',
       plateEmpty: 'Plaka boş görünüyor',
       objectsDetected: 'Plakada nesneler algılandı',
       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',
       confidence: 'Güven',
       difference: 'Fark',
       difference: 'Fark',
       analysisPreview: 'Analiz önizlemesi:',
       analysisPreview: 'Analiz önizlemesi:',
@@ -1666,6 +1676,9 @@ export default {
       historyCleared: '{{count}} geçmiş öğesi temizlendi',
       historyCleared: '{{count}} geçmiş öğesi temizlendi',
       clearHistoryFailed: 'Geçmiş temizlenemedi',
       clearHistoryFailed: 'Geçmiş temizlenemedi',
       updateFailed: 'Öğeler güncellenemedi',
       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',
       bulkCancelled: '{{count}} öğe iptal edildi',
       bulkCancelFailed: 'Öğeler iptal edilemedi',
       bulkCancelFailed: 'Öğeler iptal edilemedi',
       batchCreated: '"{{name}}" yığını oluşturuldu',
       batchCreated: '"{{name}}" yığını oluşturuldu',

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

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

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

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

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

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

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

@@ -197,6 +197,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { FeedDirectionModal } from '../components/FeedDirectionModal';
 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 { 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 { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
@@ -2271,6 +2272,7 @@ function PrinterCard({
     message: string;
     message: string;
     debug_image_url?: string;
     debug_image_url?: string;
     needs_calibration: boolean;
     needs_calibration: boolean;
+    failure_reason?: string | null;
     light_warning?: boolean;
     light_warning?: boolean;
     reference_count?: number;
     reference_count?: number;
     max_references?: number;
     max_references?: number;
@@ -6848,6 +6850,8 @@ function PrinterCard({
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">
                 {plateCheckResult.needs_calibration ? (
                 {plateCheckResult.needs_calibration ? (
                   <ScanSearch className="w-[var(--pc-i5,1.25rem)] h-[var(--pc-i5,1.25rem)] text-blue-500" />
                   <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 ? (
                 ) : plateCheckResult.is_empty ? (
                   <CheckCircle className="w-[var(--pc-i5,1.25rem)] h-[var(--pc-i5,1.25rem)] text-green-500" />
                   <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') }} />
                     <p dangerouslySetInnerHTML={{ __html: t('printers.plateDetection.calibrationTip') }} />
                   </div>
                   </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'}`}>
                   <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 { useState, useMemo, useEffect, useCallback } from 'react';
+import { bulkUpdateToast } from '../utils/queueBulkUpdate';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Link } from 'react-router-dom';
 import { Link } from 'react-router-dom';
@@ -2008,7 +2009,8 @@ export function QueuePage() {
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       setSelectedItems([]);
       setSelectedItems([]);
       setShowBulkEditModal(false);
       setShowBulkEditModal(false);
-      showToast(result.message);
+      const toast = bulkUpdateToast(result);
+      showToast(t(toast.key, toast.params), toast.type);
     },
     },
     onError: () => showToast(t('queue.toast.updateFailed'), 'error'),
     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' };
+}

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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