فهرست منبع

Merge branch 'dev' into feature/oidc-env-config

MartinNYHC 1 ماه پیش
والد
کامیت
21d61c3535
96فایلهای تغییر یافته به همراه9308 افزوده شده و 512 حذف شده
  1. 1 0
      BACKERS.md
  2. 1 0
      CHANGELOG.md
  3. 2 0
      backend/app/api/routes/archives.py
  4. 34 0
      backend/app/api/routes/github_backup.py
  5. 13 1
      backend/app/api/routes/library.py
  6. 10 3
      backend/app/api/routes/obico.py
  7. 24 3
      backend/app/api/routes/orca_cloud.py
  8. 63 0
      backend/app/api/routes/print_queue.py
  9. 24 8
      backend/app/api/routes/projects.py
  10. 1 0
      backend/app/api/routes/settings.py
  11. 155 20
      backend/app/api/routes/support.py
  12. 6 0
      backend/app/api/routes/virtual_printers.py
  13. 9 0
      backend/app/core/database.py
  14. 571 57
      backend/app/main.py
  15. 12 0
      backend/app/models/virtual_printer.py
  16. 13 0
      backend/app/schemas/github_backup.py
  17. 6 0
      backend/app/schemas/print_queue.py
  18. 35 0
      backend/app/schemas/settings.py
  19. 36 0
      backend/app/services/archive.py
  20. 133 32
      backend/app/services/bambu_mqtt.py
  21. 64 0
      backend/app/services/camera.py
  22. 8 2
      backend/app/services/export.py
  23. 200 25
      backend/app/services/external_camera.py
  24. 8 1
      backend/app/services/failure_analysis.py
  25. 335 58
      backend/app/services/github_backup.py
  26. 115 0
      backend/app/services/layer_timelapse.py
  27. 87 12
      backend/app/services/obico_detection.py
  28. 68 0
      backend/app/services/print_dispatch_context.py
  29. 96 2
      backend/app/services/print_scheduler.py
  30. 6 0
      backend/app/services/printer_diagnostic.py
  31. 15 0
      backend/app/services/printer_manager.py
  32. 6 1
      backend/app/services/slice_preview.py
  33. 211 50
      backend/app/services/slicer_api.py
  34. 205 7
      backend/app/services/virtual_printer/manager.py
  35. 65 14
      backend/app/utils/threemf_tools.py
  36. 92 0
      backend/tests/integration/test_archives_api.py
  37. 26 1
      backend/tests/integration/test_library_slice_api.py
  38. 205 0
      backend/tests/integration/test_print_queue_api.py
  39. 203 0
      backend/tests/integration/test_projects_api.py
  40. 236 60
      backend/tests/unit/services/test_bambu_mqtt.py
  41. 146 0
      backend/tests/unit/services/test_camera_rotation.py
  42. 493 0
      backend/tests/unit/services/test_external_camera_capture_coalescing.py
  43. 327 1
      backend/tests/unit/services/test_layer_timelapse.py
  44. 77 0
      backend/tests/unit/services/test_print_dispatch_context.py
  45. 871 44
      backend/tests/unit/services/test_virtual_printer.py
  46. 157 0
      backend/tests/unit/test_connection_watchdog.py
  47. 66 0
      backend/tests/unit/test_finish_photo_from_timelapse.py
  48. 566 15
      backend/tests/unit/test_finish_photo_moment_sync.py
  49. 509 0
      backend/tests/unit/test_github_backup_cloud_profiles.py
  50. 1 0
      backend/tests/unit/test_layer_timelapse_expected_archive.py
  51. 199 0
      backend/tests/unit/test_obico_detection.py
  52. 125 0
      backend/tests/unit/test_orca_cloud_refresh.py
  53. 115 0
      backend/tests/unit/test_scheduler_watchdog.py
  54. 229 0
      backend/tests/unit/test_slicer_stall_timeout.py
  55. 128 6
      backend/tests/unit/test_support_helpers.py
  56. 88 0
      backend/tests/unit/test_threemf_tools.py
  57. 192 0
      frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx
  58. 97 0
      frontend/src/__tests__/components/FailureDetectionSettings.test.tsx
  59. 195 0
      frontend/src/__tests__/components/FilamentMappingArchivePick.test.tsx
  60. 45 1
      frontend/src/__tests__/components/HMSErrorModal.test.tsx
  61. 72 0
      frontend/src/__tests__/components/archiveAmsMapping.test.ts
  62. 78 3
      frontend/src/__tests__/components/spool-form/isMatchingCalibration.test.ts
  63. 60 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  64. 75 0
      frontend/src/__tests__/utils/projectQueries.test.ts
  65. 36 3
      frontend/src/api/client.ts
  66. 4 8
      frontend/src/components/BatchProjectModal.tsx
  67. 90 19
      frontend/src/components/ConfigureAmsSlotModal.tsx
  68. 4 2
      frontend/src/components/EditArchiveModal.tsx
  69. 29 3
      frontend/src/components/FailureDetectionSettings.tsx
  70. 35 4
      frontend/src/components/GitHubBackupSettings.tsx
  71. 43 5
      frontend/src/components/HMSErrorModal.tsx
  72. 79 11
      frontend/src/components/PrintModal/FilamentMapping.tsx
  73. 46 0
      frontend/src/components/PrintModal/archiveAmsMapping.ts
  74. 16 0
      frontend/src/components/PrintModal/index.tsx
  75. 8 0
      frontend/src/components/PrintModal/types.ts
  76. 38 0
      frontend/src/components/VirtualPrinterCard.tsx
  77. 57 5
      frontend/src/components/spool-form/utils.ts
  78. 27 1
      frontend/src/i18n/locales/de.ts
  79. 27 1
      frontend/src/i18n/locales/en.ts
  80. 27 1
      frontend/src/i18n/locales/es.ts
  81. 27 1
      frontend/src/i18n/locales/fr.ts
  82. 27 1
      frontend/src/i18n/locales/it.ts
  83. 27 1
      frontend/src/i18n/locales/ja.ts
  84. 28 2
      frontend/src/i18n/locales/ko.ts
  85. 27 1
      frontend/src/i18n/locales/pt-BR.ts
  86. 27 1
      frontend/src/i18n/locales/ru.ts
  87. 27 1
      frontend/src/i18n/locales/tr.ts
  88. 27 1
      frontend/src/i18n/locales/uk.ts
  89. 27 1
      frontend/src/i18n/locales/zh-CN.ts
  90. 27 1
      frontend/src/i18n/locales/zh-TW.ts
  91. 49 9
      frontend/src/pages/ArchivesPage.tsx
  92. 13 0
      frontend/src/pages/QueuePage.tsx
  93. 58 1
      frontend/src/pages/SettingsPage.tsx
  94. 39 0
      frontend/src/utils/projectQueries.ts
  95. 0 0
      static/assets/index-CxAiFpme.js
  96. 1 1
      static/index.html

+ 1 - 0
BACKERS.md

@@ -69,6 +69,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
 - [@iljur](https://github.com/iljur)
 - [@bhamiltoncx](https://github.com/bhamiltoncx)
 - [@g7ufo](https://github.com/g7ufo)
+- [@Heidelberger2000](https://github.com/Heidelberger2000)
 
 ---
 

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
CHANGELOG.md


+ 2 - 0
backend/app/api/routes/archives.py

@@ -3905,6 +3905,7 @@ async def _try_preview_slice_filaments(
     """
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_preview import get_preview_filaments
+    from backend.app.services.slicer_api import get_stall_timeout_seconds
 
     preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
     if preferred == "orcaslicer":
@@ -3930,6 +3931,7 @@ async def _try_preview_slice_filaments(
         file_name=file_path.name,
         api_url=api_url,
         request_id=request_id,
+        timeout_seconds=await get_stall_timeout_seconds(db),
     )
 
 

+ 34 - 0
backend/app/api/routes/github_backup.py

@@ -12,6 +12,7 @@ from backend.app.core.permissions import Permission
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.user import User
 from backend.app.schemas.github_backup import (
+    CloudAccountCounts,
     GitHubBackupConfigCreate,
     GitHubBackupConfigResponse,
     GitHubBackupConfigUpdate,
@@ -75,6 +76,39 @@ async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> Non
         raise HTTPException(status_code=400, detail=_PUBLIC_REPO_ERROR)
 
 
+async def _count_cloud_accounts(db: AsyncSession) -> tuple[int, int]:
+    """How many Bambu / Orca accounts a backup would collect from.
+
+    Asks the collector itself rather than re-deriving the rule, so the number
+    the UI gates on can't drift from the number the backup actually uses
+    (#2717). Counts only — never who.
+    """
+    try:
+        bambu, orca = await github_backup_service.cloud_accounts(db)
+        return len(bambu), len(orca)
+    except Exception:
+        # A settings page must still render when a credential store is
+        # unreadable; the toggle simply shows as unavailable.
+        logger.warning("Failed to count connected cloud accounts", exc_info=True)
+        return 0, 0
+
+
+@router.get("/cloud-accounts", response_model=CloudAccountCounts)
+async def get_cloud_accounts(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
+):
+    """How many cloud accounts the Cloud Profiles category would collect from.
+
+    Its own endpoint rather than a field on ``/config``, because the settings
+    form needs this before any config exists — ``/config`` answers ``null``
+    until the first save, which would leave the toggle disabled during the
+    very setup it's part of.
+    """
+    bambu, orca = await _count_cloud_accounts(db)
+    return CloudAccountCounts(bambu=bambu, orca=orca)
+
+
 def _config_to_response(config: GitHubBackupConfig) -> dict:
     """Convert config model to response dict."""
     return {

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

@@ -3082,6 +3082,7 @@ async def _try_preview_slice_filaments(
     """
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_preview import get_preview_filaments
+    from backend.app.services.slicer_api import get_stall_timeout_seconds
 
     preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
     if preferred == "orcaslicer":
@@ -3107,6 +3108,7 @@ async def _try_preview_slice_filaments(
         file_name=file_path.name,
         api_url=api_url,
         request_id=request_id,
+        timeout_seconds=await get_stall_timeout_seconds(db),
     )
 
 
@@ -3607,6 +3609,8 @@ async def _run_slicer_with_fallback(
         SlicerApiService,
         SlicerApiUnavailableError,
         SlicerInputError,
+        SlicerTimeoutError,
+        get_stall_timeout_seconds,
     )
 
     user: User | None = None
@@ -3717,7 +3721,9 @@ async def _run_slicer_with_fallback(
     # gates the toggle on the picked printer matching the design's target,
     # so this path never re-targets across printer models.
     embedded_mode = bool(request.use_embedded_settings and is_3mf)
-    service = SlicerApiService(api_url)
+    # Bounds silence rather than total slicing time (#2730), so a heavy model
+    # that keeps reporting progress runs to completion however long it takes.
+    service = SlicerApiService(api_url, timeout_seconds=await get_stall_timeout_seconds(db))
 
     # #1493: cross-nozzle-class re-slice (single <-> dual). Without
     # intervention the slicer rejects with either "G-code in unprintable
@@ -3960,6 +3966,12 @@ async def _run_slicer_with_fallback(
             used_embedded_settings = True
     except SlicerInputError as exc:
         raise HTTPException(status_code=400, detail=str(exc)) from exc
+    except SlicerTimeoutError as exc:
+        # 504, not 502: the sidecar answered for the whole run, we stopped
+        # waiting. Reported separately so the user is told the slice ran out of
+        # time and where to change that, rather than that the sidecar is
+        # unreachable — which is what a read timeout used to look like (#2730).
+        raise HTTPException(status_code=504, detail=str(exc)) from exc
     except SlicerApiServerError as exc:
         raise HTTPException(status_code=502, detail=str(exc)) from exc
     except SlicerApiUnavailableError as exc:

+ 10 - 3
backend/app/api/routes/obico.py

@@ -17,6 +17,8 @@ router = APIRouter(prefix="/obico", tags=["obico"])
 
 class TestConnectionRequest(BaseModel):
     url: str
+    # Omitted entirely = test with the saved token; "" = test with no token.
+    token: str | None = None
 
 
 @router.get("/status")
@@ -65,10 +67,15 @@ async def test_connection(
     req: TestConnectionRequest,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
-    """Ping the Obico ML API `/hc/` health endpoint. Returns ok + raw body."""
+    """Ping the Obico ML API health endpoint and check the token. Returns ok + raw body."""
     if not req.url:
-        return {"ok": False, "status_code": None, "body": None, "error": "URL is empty"}
-    return await obico_detection_service.test_connection(req.url)
+        return {"ok": False, "status_code": None, "body": None, "error": "URL is empty", "auth_ok": None}
+    token = req.token
+    if token is None:
+        # Field omitted entirely — test what the service actually uses.
+        settings = await obico_detection_service._load_settings()
+        token = settings.get("ml_token") or ""
+    return await obico_detection_service.test_connection(req.url, token)
 
 
 @router.get("/cached-frame/{nonce}")

+ 24 - 3
backend/app/api/routes/orca_cloud.py

@@ -431,6 +431,7 @@ async def _upsert_settings(db: AsyncSession, values: dict[str, str | None]) -> N
 async def _build_authenticated_service(
     db: AsyncSession,
     user: User | None,
+    clear_on_auth_failure: bool = True,
 ) -> OrcaCloudService:
     """Construct an :class:`OrcaCloudService` pre-populated with stored
     credentials. If the access token is within the refresh-leeway of expiry,
@@ -440,7 +441,24 @@ async def _build_authenticated_service(
     We don't lock around the refresh: Orca tolerates concurrent refreshes for
     ~60s (each racer gets its own valid pair on the same connection rather than
     a revoke), so a lost race here is harmless — last-write-wins on the stored
-    pair, and whichever pair we keep is valid."""
+    pair, and whichever pair we keep is valid.
+
+    ``clear_on_auth_failure`` controls what happens when the refresh is
+    rejected. Routes leave it on: the caller is a person looking at the UI, and
+    wiping the dead credentials flips the page to disconnected in front of them
+    so they can pair again. Background jobs pass ``False`` — see the caveat
+    below.
+
+    Why background callers must not clear: Orca reports every rejection with
+    one composite reason (``unknown, expired, revoked, or already used``), so
+    a genuine revocation is indistinguishable from a lost refresh-rotation
+    race. Acting destructively on a signal that can't be disambiguated is the
+    #2562 mistake in a different cloud. It also gains nothing — a route call
+    hits the same failure and clears then, at a moment the user can respond to.
+    A successful refresh is still persisted either way: by that point the old
+    refresh token is consumed, so dropping the new pair would break a working
+    pairing for real.
+    """
     creds = await _load_credentials(db, user)
     if not creds.token:
         raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
@@ -457,8 +475,11 @@ async def _build_authenticated_service(
             await svc.refresh()
         except OrcaCloudAuthError as e:
             # Refresh token was revoked or rotated out from under us. Clear
-            # the stale credentials so the UI flips to disconnected.
-            await _clear_credentials(db, user)
+            # the stale credentials so the UI flips to disconnected — unless
+            # the caller is a background job, which must not change sign-in
+            # state on its own.
+            if clear_on_auth_failure:
+                await _clear_credentials(db, user)
             raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e
         except OrcaCloudError as e:
             raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e

+ 63 - 0
backend/app/api/routes/print_queue.py

@@ -250,6 +250,24 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             response.nozzle_diameter = item.archive.nozzle_diameter
             response.sliced_for_model = item.archive.sliced_for_model
             response.bed_type = item.archive.bed_type
+            # Marks history/reprint rows whose archive carries the slicer's own
+            # live-resolved AMS-slot pick (extra_data.slicer_ams_mapping) — see
+            # `_extract_slicer_ams_mapping_json` in virtual_printer/manager.py.
+            #
+            # Only when the saved mapping was resolved against *this* row's
+            # printer: a global tray ID means nothing on another printer, so
+            # that's the exact condition under which the mapping is reused. A
+            # badge on a row where nothing gets reused would be a lie (#2700
+            # review). Model-based rows (printer_id None) never match, which is
+            # correct — the mapping is not reused there either.
+            extra = item.archive.extra_data if isinstance(item.archive.extra_data, dict) else {}
+            saved_mapping = extra.get("slicer_ams_mapping")
+            response.archive_has_slicer_ams_mapping = (
+                isinstance(saved_mapping, dict)
+                and isinstance(saved_mapping.get("mapping"), list)
+                and item.printer_id is not None
+                and saved_mapping.get("printer_id") == item.printer_id
+            )
             if item.plate_id:
                 archive_path = settings.base_dir / item.archive.file_path
                 if archive_path.exists():
@@ -643,6 +661,51 @@ async def add_to_queue(
             raise HTTPException(status_code=404, detail="Project not found")
 
     ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
+    # Reprint fallback: the caller didn't specify an explicit ams_mapping (no
+    # per-slot filament-mapping edit was made), but the archive carries the
+    # slicer's own live-resolved AMS-slot pick from the original print (see
+    # `extra_data.slicer_ams_mapping`, written by the VP-queue path via
+    # `_extract_slicer_ams_mapping_json`). Reuse it so the reprint dispatches
+    # to the exact same physical spool instead of the scheduler re-deriving a
+    # (possibly ambiguous) mapping from just the file's static type/color.
+    #
+    # Global tray IDs only mean something relative to the specific printer
+    # they were resolved against, so this only fires when the reprint targets
+    # that exact printer (`extra_data.slicer_ams_mapping.printer_id`) — never
+    # for a model-based dispatch (data.printer_id is None) or a reprint aimed
+    # at a different printer, where the same tray number can hold a
+    # completely different spool (#2700 review).
+    #
+    # It also stands down when the request carries force-color-match overrides:
+    # those are the caller asking the scheduler to match strictly against the
+    # printer's live trays, and they are only ever applied inside
+    # `_compute_ams_mapping_for_printer` — the function a stored mapping makes
+    # the scheduler skip. Same precedence as the VP-side toggle pair (#2700
+    # review).
+    #
+    # Note this is otherwise unconditional — it applies regardless of whether
+    # the physical spool in that slot has changed since the original print.
+    # #1308 covers re-verifying a stored mapping against live AMS state at
+    # dispatch time; that check is a separate PR and, once merged, will also
+    # catch a stale slot inherited through this fallback.
+    wants_live_color_match = any(
+        isinstance(o, dict) and o.get("force_color_match") for o in (data.filament_overrides or [])
+    )
+    if (
+        ams_mapping_json is None
+        and not wants_live_color_match
+        and archive
+        and archive.extra_data
+        and data.printer_id is not None
+    ):
+        saved = archive.extra_data.get("slicer_ams_mapping")
+        if (
+            isinstance(saved, dict)
+            and saved.get("printer_id") == data.printer_id
+            and isinstance(saved.get("mapping"), list)
+            and saved["mapping"]
+        ):
+            ams_mapping_json = json.dumps(saved["mapping"])
     items = []
     for i in range(quantity):
         item = PrintQueueItem(

+ 24 - 8
backend/app/api/routes/projects.py

@@ -52,6 +52,21 @@ router = APIRouter(prefix="/projects", tags=["projects"])
 
 _FAILURE_STATUSES = ("failed", "aborted", "cancelled", "stopped")
 
+# Soft-deleted archives (#1343) keep their row — and therefore their
+# ``project_id`` — after their files have been removed from disk, so that global
+# Quick Stats can still count their filament / time / cost. Nothing in this
+# module filtered on that, which left deleted prints listed on the project with
+# thumbnails pointing at files that no longer exist, and no way to unassign them
+# (the only unassign UI lives on the Archives page, which correctly hides them)
+# — #2731.
+#
+# Every project-scoped query filters them out, counts included: a project that
+# lists 11 prints must not claim 12. That is a deliberate divergence from the
+# global Quick Stats behaviour, where the whole point of the soft delete is that
+# the contribution survives. A project is a piece of work with a definite
+# membership, not a lifetime total, so a print the user deleted has left it.
+_LIVE_ARCHIVE = PrintArchive.deleted_at.is_(None)
+
 
 async def compute_project_stats(
     db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
@@ -83,7 +98,7 @@ async def compute_project_stats(
             func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"),
         )
         .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
-        .where(PrintArchive.project_id == project_id)
+        .where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
     )
     log_stats = log_stats_result.first()
     total_archives = int(log_stats.total_runs or 0)
@@ -104,7 +119,7 @@ async def compute_project_stats(
             ).label("failed_runs"),
         )
         .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
-        .where(PrintArchive.project_id == project_id)
+        .where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
     )
     items_split = items_split_result.first()
     total_items = int(items_split.total_items or 0)
@@ -212,7 +227,7 @@ async def list_projects(
                 ).label("failed_count"),
             )
             .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
-            .where(PrintArchive.project_id == project.id)
+            .where(PrintArchive.project_id == project.id, _LIVE_ARCHIVE)
         )
         log_quick = log_quick_result.first()
         archive_count = int(log_quick.archive_count or 0)
@@ -237,7 +252,7 @@ async def list_projects(
         # Get archive previews (up to 6 most recent)
         archives_result = await db.execute(
             select(PrintArchive)
-            .where(PrintArchive.project_id == project.id)
+            .where(PrintArchive.project_id == project.id, _LIVE_ARCHIVE)
             .order_by(PrintArchive.created_at.desc())
             .limit(6)
         )
@@ -365,7 +380,7 @@ async def list_templates(
     for project in templates:
         # Get archive count
         archive_count_result = await db.execute(
-            select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
+            select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id, _LIVE_ARCHIVE)
         )
         archive_count = archive_count_result.scalar() or 0
 
@@ -498,6 +513,7 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh
             select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
                 PrintArchive.project_id == child.id,
                 PrintArchive.status == "completed",
+                _LIVE_ARCHIVE,
             )
         )
         completed_count = completed_result.scalar() or 0
@@ -715,7 +731,7 @@ async def list_project_archives(
     query = (
         select(PrintArchive)
         .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
-        .where(PrintArchive.project_id == project_id)
+        .where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
         .order_by(PrintArchive.created_at.desc())
         .limit(limit)
         .offset(offset)
@@ -804,7 +820,7 @@ async def get_project_file_progress(
             func.count(PrintLogEntry.id),
         )
         .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
-        .where(PrintArchive.project_id == project_id, PrintLogEntry.status == "completed")
+        .where(PrintArchive.project_id == project_id, PrintLogEntry.status == "completed", _LIVE_ARCHIVE)
         .group_by(PrintArchive.library_file_id, PrintArchive.content_hash, PrintArchive.filename)
     )
 
@@ -1580,7 +1596,7 @@ async def get_project_timeline(
     # Get archives and add events
     archives_result = await db.execute(
         select(PrintArchive)
-        .where(PrintArchive.project_id == project_id)
+        .where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
         .order_by(PrintArchive.created_at.desc())
         .limit(limit)
     )

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

@@ -164,6 +164,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "auto_archive",
             "save_thumbnails",
             "capture_finish_photo",
+            "finish_photo_restore_plate",
             "spoolman_enabled",
             "spoolman_disable_weight_sync",
             "spoolman_report_partial_usage",

+ 155 - 20
backend/app/api/routes/support.py

@@ -9,6 +9,7 @@ import logging
 import os
 import platform
 import re
+import time
 import zipfile
 from datetime import datetime, timezone
 from pathlib import Path
@@ -300,6 +301,115 @@ def _get_container_memory_limit() -> int | None:
     return None
 
 
+# Above this RSS the heap census is skipped — see _collect_process_info.
+_GC_CENSUS_RSS_LIMIT = 2 * 1024**3
+
+
+def _collect_process_info() -> dict:
+    """Snapshot this process's resource usage, for reports about it growing.
+
+    Bundles used to carry nothing about Bambuddy's own footprint, which made
+    "memory climbs over days until the OOM killer fires" impossible to triage
+    from a bundle alone — the reporter of #2734 had to be asked to run commands
+    by hand, and the numbers that would have identified the mechanism could not
+    be recovered after the fact.
+
+    The four figures below separate the mechanisms that look identical from
+    outside:
+
+    * ``rss_bytes`` vs ``vms_bytes`` — a large virtual size against a modest
+      resident one is address space, not live data: thread stacks or allocator
+      arenas rather than a heap that keeps growing.
+    * ``num_threads`` — every leaked MQTT client reconnect would leave a paho
+      network thread behind, each reserving its stack.
+    * ``children`` — the ffmpeg-per-camera-stream leak class (#776).
+    * ``open_files`` / ``connections`` — descriptors held by streams or sockets
+      that were never closed.
+
+    Everything is best-effort: psutil raises on hardened kernels and inside
+    restricted containers, and a support bundle must still be produced when it
+    does. Child command lines are reduced to the executable name — a full
+    ffmpeg argv carries the camera URL, and with it the camera's password.
+    """
+    import psutil
+
+    out: dict = {}
+    try:
+        proc = psutil.Process()
+    except Exception:
+        return {"available": False}
+
+    out["available"] = True
+    try:
+        mem = proc.memory_info()
+        out["rss_bytes"] = mem.rss
+        out["rss_formatted"] = _format_bytes(mem.rss)
+        out["vms_bytes"] = mem.vms
+        out["vms_formatted"] = _format_bytes(mem.vms)
+    except Exception:
+        pass
+    try:
+        out["num_threads"] = proc.num_threads()
+    except Exception:
+        pass
+    try:
+        out["uptime_seconds"] = int(time.time() - proc.create_time())
+    except Exception:
+        pass
+    try:
+        out["open_files"] = len(proc.open_files())
+    except Exception:
+        pass
+    try:
+        out["connections"] = len(proc.net_connections(kind="inet"))
+    except Exception:
+        pass
+
+    # Children by executable name only. The count per name is what identifies a
+    # leak; the arguments would leak credentials.
+    try:
+        names: dict[str, int] = {}
+        for child in proc.children(recursive=True):
+            try:
+                names[child.name()] = names.get(child.name(), 0) + 1
+            except Exception:
+                names["<unknown>"] = names.get("<unknown>", 0) + 1
+        out["children_total"] = sum(names.values())
+        out["children_by_name"] = dict(sorted(names.items(), key=lambda kv: -kv[1]))
+    except Exception:
+        pass
+
+    # Live object counts by type, top 15. Identifies a heap that is growing and
+    # what it is growing with — the one thing RSS alone cannot say.
+    #
+    # Skipped above _GC_CENSUS_RSS_LIMIT. gc.get_objects() materialises a list
+    # of every tracked object, so the census costs most on exactly the process
+    # that can least afford it: a bundle generated to diagnose runaway memory
+    # must not be the allocation that tips the host over. The numbers that
+    # actually separate the mechanisms — RSS vs VMS, threads, children — are
+    # collected above and unaffected.
+    rss = out.get("rss_bytes")
+    if rss is not None and rss > _GC_CENSUS_RSS_LIMIT:
+        out["gc_census"] = (
+            f"skipped: process is using {_format_bytes(rss)}, above the "
+            f"{_format_bytes(_GC_CENSUS_RSS_LIMIT)} limit for walking the heap"
+        )
+        return out
+    try:
+        import gc
+
+        counts: dict[str, int] = {}
+        for obj in gc.get_objects():
+            name = type(obj).__name__
+            counts[name] = counts.get(name, 0) + 1
+        out["gc_tracked_objects"] = sum(counts.values())
+        out["gc_top_types"] = dict(sorted(counts.items(), key=lambda kv: -kv[1])[:15])
+    except Exception:
+        pass
+
+    return out
+
+
 def _format_bytes(size_bytes: int) -> str:
     """Format bytes into human-readable string."""
     if size_bytes < 1024:
@@ -647,20 +757,29 @@ async def _collect_slicer_api_info() -> dict:
     return info
 
 
-def _parse_obico_enabled_printers(raw: str) -> set[int]:
-    """Parse the comma-separated `obico_enabled_printers` setting. Same shape as
-    obico_detection.py uses but tolerant of legacy formats."""
+def _parse_obico_enabled_printers(raw: str | None) -> set[int] | None:
+    """Parse the `obico_enabled_printers` setting the way the detection service does.
+
+    The setting is a JSON array of printer IDs and an empty value means *all*
+    printers — see ``ObicoDetectionService._load_settings``. This used to split
+    on commas and treat empty as *none*, so a bundle from a default Obico setup
+    reported every printer as unmonitored while the service was in fact polling
+    all of them. Returns ``None`` for "all printers"; a comma-separated fallback
+    is kept in case an install ever stored the legacy shape.
+    """
     if not raw or not raw.strip():
-        return set()
+        return None
+    try:
+        parsed = json.loads(raw)
+    except (json.JSONDecodeError, TypeError):
+        parsed = None
+    if isinstance(parsed, list):
+        return {int(item) for item in parsed if isinstance(item, (int, str)) and str(item).strip().isdigit()}
     result: set[int] = set()
     for token in raw.split(","):
         token = token.strip()
-        if not token:
-            continue
-        try:
+        if token.isdigit():
             result.add(int(token))
-        except ValueError:
-            continue
     return result
 
 
@@ -690,6 +809,12 @@ async def _collect_support_info() -> dict:
         "database": {},
         "printers": [],
         "settings": {},
+        # Bambuddy's own footprint. Cheap to collect and the only thing that
+        # makes a "memory grows over days" report triageable from the bundle
+        # rather than a round trip of shell commands (#2734). Off the event
+        # loop: the heap census walks every tracked object, and a bundle
+        # request must not stall status ingest while it does.
+        "process": await asyncio.to_thread(_collect_process_info),
     }
 
     # Docker-specific info
@@ -729,18 +854,27 @@ async def _collect_support_info() -> dict:
         printers = result.scalars().all()
         statuses = printer_manager.get_all_statuses()
 
-        # Pre-load the obico per-printer enabled-list. Settings are loaded later
-        # in this function (and would overwrite this key in info["settings"]),
-        # so do a targeted query here for the per-printer flag below.
-        obico_enabled_set: set[int] = set()
+        # Pre-load the obico settings that decide which printers are monitored.
+        # Settings are loaded later in this function (and would overwrite these
+        # keys in info["settings"]), so do a targeted query here for the
+        # per-printer flag below. ``None`` means every printer is monitored.
+        obico_enabled_set: set[int] | None = None
+        obico_globally_enabled = False
         try:
-            obico_row = (
-                await db.execute(select(Settings).where(Settings.key == "obico_enabled_printers"))
-            ).scalar_one_or_none()
-            if obico_row is not None:
-                obico_enabled_set = _parse_obico_enabled_printers(obico_row.value)
+            obico_rows = {
+                row.key: row.value
+                for row in (
+                    await db.execute(
+                        select(Settings).where(Settings.key.in_(["obico_enabled_printers", "obico_enabled"]))
+                    )
+                )
+                .scalars()
+                .all()
+            }
+            obico_enabled_set = _parse_obico_enabled_printers(obico_rows.get("obico_enabled_printers"))
+            obico_globally_enabled = (obico_rows.get("obico_enabled") or "false").lower() == "true"
         except Exception:
-            logger.debug("Failed to load obico_enabled_printers", exc_info=True)
+            logger.debug("Failed to load obico settings", exc_info=True)
 
         # Check reachability in parallel
         reachability_tasks = [_check_port(p.ip_address, 8883) for p in printers]
@@ -784,7 +918,8 @@ async def _collect_support_info() -> dict:
                     "has_vt_tray": has_vt_tray,
                     "external_camera_configured": bool(printer.external_camera_url),
                     "plate_detection_enabled": printer.plate_detection_enabled,
-                    "obico_enabled": printer.id in obico_enabled_set,
+                    "obico_enabled": obico_globally_enabled
+                    and (obico_enabled_set is None or printer.id in obico_enabled_set),
                     "hms_error_count": len(state.hms_errors) if state else 0,
                     "developer_mode": state.developer_mode if state else None,
                     "nozzle_rack_count": len(state.nozzle_rack) if state else 0,

+ 6 - 0
backend/app/api/routes/virtual_printers.py

@@ -39,6 +39,7 @@ class VirtualPrinterCreate(BaseModel):
     target_printer_id: int | None = None
     auto_dispatch: bool = True
     queue_force_color_match: bool = False
+    save_ams_mapping: bool = False
     gcode_injection: bool = False
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
@@ -53,6 +54,7 @@ class VirtualPrinterUpdate(BaseModel):
     target_printer_id: int | None = None
     auto_dispatch: bool | None = None
     queue_force_color_match: bool | None = None
+    save_ams_mapping: bool | None = None
     gcode_injection: bool | None = None
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
@@ -109,6 +111,7 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
         "target_printer_id": vp.target_printer_id,
         "auto_dispatch": vp.auto_dispatch,
         "queue_force_color_match": vp.queue_force_color_match,
+        "save_ams_mapping": vp.save_ams_mapping,
         "gcode_injection": vp.gcode_injection,
         "bind_ip": vp.bind_ip,
         "remote_interface_ip": vp.remote_interface_ip,
@@ -245,6 +248,7 @@ async def create_virtual_printer(
         target_printer_id=body.target_printer_id,
         auto_dispatch=body.auto_dispatch,
         queue_force_color_match=body.queue_force_color_match,
+        save_ams_mapping=body.save_ams_mapping,
         gcode_injection=body.gcode_injection,
         bind_ip=body.bind_ip,
         remote_interface_ip=body.remote_interface_ip,
@@ -423,6 +427,8 @@ async def update_virtual_printer(
         vp.auto_dispatch = body.auto_dispatch
     if body.queue_force_color_match is not None:
         vp.queue_force_color_match = body.queue_force_color_match
+    if body.save_ams_mapping is not None:
+        vp.save_ams_mapping = body.save_ams_mapping
     if body.gcode_injection is not None:
         vp.gcode_injection = body.gcode_injection
     if body.bind_ip is not None:

+ 9 - 0
backend/app/core/database.py

@@ -1363,6 +1363,15 @@ async def run_migrations(conn):
             conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
         )
 
+    # Migration: Add save_ams_mapping column to virtual_printers. Opt-in flag:
+    # when true, VP queue-mode uploads persist the slicer's own AMS-slot pick
+    # onto the archive (`extra_data.slicer_ams_mapping`) for reuse on reprint.
+    # Default false to preserve current behaviour for upgraders.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT FALSE")
+
     # Per-VP opt-in for auto-print G-code injection (#1516). Default false so
     # existing gcode_snippets users don't silently start injecting on VP/Studio
     # Send jobs after upgrading.

+ 571 - 57
backend/app/main.py

@@ -81,6 +81,7 @@ from backend.app.core.database import async_session, engine, init_db
 from backend.app.core.tasks import spawn_background_task
 from backend.app.core.websocket import ws_manager
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.services import print_dispatch_context
 from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
 from backend.app.services.archive_purge import archive_purge_service
 from backend.app.services.bambu_ftp import (
@@ -347,6 +348,12 @@ _active_prints: dict[tuple[int, str], int] = {}
 # captures the better-framed pre-bed-drop moment without us having to force
 # timelapse on at dispatch (the #1397 mechanism that caused #1721's per-layer
 # nozzle parking on slicer profiles with Timelapse Type = Smooth).
+#
+# #2708: the bytes in here are ALWAYS already rotated by the printer's
+# camera_rotation. `on_finish_photo_moment` owns that, because one of its
+# sources (the #1867 in-print bank) is rotated before it ever reaches the
+# bank and the others are not — so the consumer can't tell them apart and
+# must not rotate again.
 _stage22_finish_frames: dict[int, bytes] = {}
 
 # #1790: per-printer producer-done event. Set by `on_finish_photo_moment` in its
@@ -359,14 +366,17 @@ _stage22_finish_frames: dict[int, bytes] = {}
 _stage22_finish_in_flight: dict[int, asyncio.Event] = {}
 
 # #1867: rolling "last in-print camera frame" per printer. Refreshed on
-# layer-change while the model is still printing, then consumed by the
-# FINISH-state finish-photo path. Firmware that never emits `stg_cur=22`
-# (A1 Mini, confirmed) only reaches `on_finish_photo_moment` at the
-# gcode_state=FINISH transition — which Bambu reports AFTER the user End
-# G-code (e.g. SwapMod plate-swap) has run, so a live grab there captures the
-# swapped/empty plate. Banking is layer-driven, so it naturally freezes at the
-# final object layer: the End G-code emits no further layer_num increases, so
-# the last banked frame is always the finished print before the swap.
+# layer-change and on print-progress advances (#2547) while the model is still
+# printing, then consumed by the FINISH-state finish-photo path when the
+# dispatcher recorded that it injected End G-code into this print. Bambu
+# reports gcode_state=FINISH AFTER the user End G-code (e.g. SwapMod
+# plate-swap) has run, so a live grab there would capture the swapped/empty
+# plate.
+#
+# The load-bearing property: both drivers are print telemetry that stops before
+# the End G-code executes — no further layer_num increases, and mc_percent
+# freezes — so the last banked frame is always the finished print before the
+# swap. Anything added as a third driver must hold that same property.
 _inprint_frame_bank: dict[int, bytes] = {}
 # Monotonic timestamp of the last banked frame per printer — throttles banking
 # so tall prints don't add a camera grab on every layer.
@@ -1052,6 +1062,7 @@ def _maybe_start_layer_timelapse(printer, printer_id: int, archive_id: int) -> b
         printer.external_camera_url,
         printer.external_camera_type or "mjpeg",
         snapshot_url=printer.external_camera_snapshot_url,
+        rotation=getattr(printer, "camera_rotation", 0),
     )
     logging.getLogger(__name__).info("Started layer timelapse for printer %s, archive %s", printer_id, archive_id)
     return True
@@ -2268,13 +2279,21 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
 async def _maybe_bank_inprint_frame(printer_id: int, layer_num: int) -> None:
     """#1867: bank a recent in-print camera frame for the finish photo.
 
-    Called on every layer change. Grabs one frame (throttled) into
-    ``_inprint_frame_bank`` so the FINISH-state finish-photo path has a
-    pre-swap image on firmware that never emits ``stg_cur=22``. Because it is
-    driven by layer_num increases, banking stops the instant printing ends and
-    the End G-code (e.g. SwapMod plate swap) runs — no further layer changes
-    arrive — so the last banked frame is the finished print, not the swapped
-    plate. Best-effort: any failure just leaves the previous banked frame.
+    Called on every layer change and (#2547) on every print-progress advance.
+    Grabs one frame (throttled) into ``_inprint_frame_bank`` so the finish-photo
+    path has a pre-End-G-code image for prints that end with a plate swap.
+
+    Both drivers are print telemetry that stops the instant printing ends: no
+    further layers, and progress freezes before the End G-code (e.g. SwapMod
+    plate swap) executes. So the last banked frame is always the finished print,
+    never the swapped plate — that property is what the #1867 path relies on and
+    it must survive any change to the throttle below.
+
+    Layer changes alone were not enough: they stop when the *final* layer
+    begins, which on a three-minute last layer left the bank stale by the whole
+    length of that layer (#2547). Progress keeps ticking through it.
+
+    Best-effort: any failure just leaves the previous banked frame.
     """
     logger = logging.getLogger(__name__)
     client = printer_manager.get_client(printer_id)
@@ -2286,12 +2305,16 @@ async def _maybe_bank_inprint_frame(printer_id: int, layer_num: int) -> None:
     if state.mc_print_sub_stage not in (None, 0):
         return
 
-    total = state.total_layers or 0
-    is_last_layer = total > 0 and layer_num >= total
+    # #2547: throttled uniformly, with no last-layer exemption. The old code
+    # bypassed the throttle on the final layer to guarantee a fresh frame there;
+    # now that progress advances also drive banking, that exemption would fire a
+    # camera grab on every percent tick of the last layer. Bambu printers accept
+    # one RTSP client at a time, so each grab contends with the live view.
     now = time.monotonic()
     last = _inprint_frame_bank_ts.get(printer_id, 0.0)
-    if not is_last_layer and (now - last) < _INPRINT_BANK_MIN_INTERVAL:
+    if (now - last) < _INPRINT_BANK_MIN_INTERVAL:
         return
+    total = state.total_layers or 0
 
     try:
         async with async_session() as db:
@@ -2321,26 +2344,9 @@ async def _maybe_bank_inprint_frame(printer_id: int, layer_num: int) -> None:
 
 def _apply_camera_rotation(image_data: bytes, printer, logger) -> bytes:
     """Apply camera rotation to snapshot image if configured."""
-    rotation = getattr(printer, "camera_rotation", 0)
-    if not rotation or rotation == 0:
-        return image_data
+    from backend.app.services.camera import apply_camera_rotation
 
-    try:
-        from io import BytesIO
-
-        from PIL import Image
-
-        img = Image.open(BytesIO(image_data))
-        # PIL rotate is counter-clockwise, so negate for clockwise rotation
-        img = img.rotate(-rotation, expand=True)
-        buf = BytesIO()
-        img.save(buf, format="JPEG", quality=90)
-        rotated = buf.getvalue()
-        logger.info("[SNAPSHOT] Applied %d° rotation: %s → %s bytes", rotation, len(image_data), len(rotated))
-        return rotated
-    except Exception as e:
-        logger.warning("[SNAPSHOT] Failed to apply rotation: %s", e)
-        return image_data
+    return apply_camera_rotation(image_data, getattr(printer, "camera_rotation", 0), logger)
 
 
 async def _send_print_start_notification(
@@ -2465,6 +2471,10 @@ async def on_print_start(printer_id: int, data: dict):
     # the previous job's banked frame.
     _inprint_frame_bank.pop(printer_id, None)
     _inprint_frame_bank_ts.pop(printer_id, None)
+    # #2547: bind (or clear) the "this print ends with injected End G-code" flag.
+    # Unconditional, so a print Bambuddy didn't dispatch drops the previous
+    # print's flag instead of inheriting it.
+    print_dispatch_context.adopt(printer_id)
 
     # Cancel any active bed cooldown waiter for this printer
     if _bed_cool_waiters.pop(printer_id, None):
@@ -3998,6 +4008,7 @@ async def _capture_finish_photo_from_timelapse(
     archive_id: int,
     archive_dir: Path,
     timeout: float | None = None,
+    rotation: int = 0,
 ) -> tuple[str | None, bool]:
     """Wait for the per-print timelapse to land on the archive and extract its
     last frame as the finish photo (#1397).
@@ -4017,11 +4028,16 @@ async def _capture_finish_photo_from_timelapse(
     video landed (whether or not extraction worked), because in that case
     waiting longer changes nothing. The caller uses that to decide between
     falling back permanently and scheduling a background upgrade.
+
+    ``rotation`` is the printer's camera_rotation, applied to the extracted
+    still (#2708) so this source agrees with every other finish-photo source.
+    The archived video itself is the printer's own file and is left alone —
+    rotating it would mean re-encoding it.
     """
     import uuid
 
     from backend.app.models.archive import PrintArchive
-    from backend.app.services.camera import extract_video_last_frame
+    from backend.app.services.camera import apply_camera_rotation_to_file, extract_video_last_frame
 
     logger = logging.getLogger(__name__)
 
@@ -4044,6 +4060,7 @@ async def _capture_finish_photo_from_timelapse(
                 filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
                 output_path = photos_dir / filename
                 if await extract_video_last_frame(video_path, output_path):
+                    await apply_camera_rotation_to_file(output_path, rotation, logger)
                     logger.info(
                         "[PHOTO-BG] Extracted finish photo from timelapse %s for archive %s",
                         video_path.name,
@@ -4068,7 +4085,7 @@ async def _capture_finish_photo_from_timelapse(
         await asyncio.sleep(poll_interval)
 
 
-async def _upgrade_finish_photo_from_timelapse(archive_id: int, archive_dir: Path) -> None:
+async def _upgrade_finish_photo_from_timelapse(archive_id: int, archive_dir: Path, rotation: int = 0) -> None:
     """Add the timelapse's last frame to an archive after the fact (#2704).
 
     The print-complete notification waits only ~60s for the video, because
@@ -4087,7 +4104,7 @@ async def _upgrade_finish_photo_from_timelapse(archive_id: int, archive_dir: Pat
     logger = logging.getLogger(__name__)
 
     filename, _ = await _capture_finish_photo_from_timelapse(
-        archive_id, archive_dir, timeout=_FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS
+        archive_id, archive_dir, timeout=_FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS, rotation=rotation
     )
     if not filename:
         logger.info("[PHOTO-UPGRADE] No timelapse frame for archive %s; keeping the live grab", archive_id)
@@ -4307,6 +4324,207 @@ async def reconcile_stale_active_prints(printer_id: int) -> int:
     return reconciled
 
 
+# #2547: clearance left between the nozzle and the top of the print when the
+# plate is commanded back into camera framing. The nozzle is parked away from
+# the part by then, so this is belt-and-braces against a max_z_height that
+# under-reports (e.g. a slicer that excludes a final Z hop).
+_PLATE_RESTORE_CLEARANCE_MM = 10.0
+# How far below the restored position to drop the plate again afterwards, so
+# the print is as reachable as Bambu's own end G-code leaves it. Matches the
+# stock `G1 Z{max_layer_z + 100}`; the firmware clamps it to the travel limit
+# on machines with less headroom.
+_PLATE_PARK_DROP_MM = 100.0
+# Feedrate for both moves. F600 is exactly what Bambu's own end G-code uses on
+# this axis, so it is a proven-safe speed for the full travel.
+_PLATE_RESTORE_FEEDRATE = 600
+# Time allowed for the plate to reach the restored position before the camera
+# grab. Sized for the ~100 mm the stock end G-code drops at F600 (10 mm/s).
+_PLATE_RESTORE_SETTLE_SECONDS = 12.0
+# How long `_background_finish_photo` waits for this producer. Must cover the
+# settle window plus a worst-case RTSP grab (15s), and stay below the
+# notification path's own photo wait so a slow producer degrades to a
+# photo-less notification rather than a missed one.
+_FINISH_PHOTO_PRODUCER_WAIT_SECONDS = _PLATE_RESTORE_SETTLE_SECONDS + 23.0
+
+
+async def _max_z_for_current_print(printer_id: int, data: dict, logger) -> float | None:
+    """Height of the print that just finished on ``printer_id``, or None (#2547).
+
+    This number becomes the target of a real Z move, so every step here refuses
+    rather than guesses. A height belonging to some *other* print is the one
+    failure that could drive the nozzle into the model: 20 mm carried onto a
+    200 mm print would command the plate up through the part.
+
+    Two independent things therefore have to agree before a height is returned:
+
+    1. **Identity.** The archive is matched by the finished print's own
+       ``subtask_name``, by equality rather than a ``LIKE``, so "Cube" can never
+       resolve to "Cube v2". Matching on "most recent archive for this printer"
+       is not good enough — ``on_print_complete`` pops the ``_active_prints``
+       binding concurrently with us, and a print Bambuddy failed to archive
+       would silently resolve to its predecessor.
+    2. **Corroboration.** The archive's layer count (parsed from the 3MF) has to
+       match the layer count the printer itself reported over MQTT for the print
+       that just ended. These come from genuinely different sources, so a
+       mismatch means the row is not this print, whatever its name says.
+
+    ``completed`` is accepted alongside ``printing`` only because
+    ``on_print_complete`` may already have flipped the status by the time we
+    run; the identity check above is what actually selects the row.
+    """
+    subtask_name = (data.get("subtask_name") or "").strip()
+    if not subtask_name:
+        # Nothing to identify the print by — refuse rather than fall back to
+        # "whatever ran last on this printer".
+        logger.info("[PLATE-RESTORE] printer %s: print has no name to match on — skipping", printer_id)
+        return None
+
+    try:
+        from backend.app.models.archive import PrintArchive
+        from backend.app.utils.threemf_tools import extract_max_z_height_from_3mf
+
+        async with async_session() as db:
+            result = await db.execute(
+                select(PrintArchive)
+                .where(
+                    PrintArchive.printer_id == printer_id,
+                    PrintArchive.status.in_(("printing", "completed")),
+                    PrintArchive.deleted_at.is_(None),
+                    or_(
+                        PrintArchive.print_name == subtask_name,
+                        PrintArchive.filename == subtask_name,
+                        PrintArchive.filename == f"{subtask_name}.3mf",
+                        PrintArchive.filename == f"{subtask_name}.gcode.3mf",
+                    ),
+                )
+                .order_by(PrintArchive.id.desc())
+                .limit(1)
+            )
+            archive = result.scalar_one_or_none()
+        if archive is None or not archive.file_path:
+            logger.info("[PLATE-RESTORE] printer %s: no archive matches %r — skipping", printer_id, subtask_name)
+            return None
+
+        client = printer_manager.get_client(printer_id)
+        reported_layers = getattr(getattr(client, "state", None), "total_layers", None)
+        if reported_layers and archive.total_layers and reported_layers != archive.total_layers:
+            logger.warning(
+                "[PLATE-RESTORE] printer %s: archive %s says %s layers but the printer reported %s "
+                "— refusing to move the plate on a height that may not be this print's",
+                printer_id,
+                archive.id,
+                archive.total_layers,
+                reported_layers,
+            )
+            return None
+
+        path = Path(archive.file_path)
+        if not path.is_absolute():
+            path = Path(app_settings.data_dir) / path
+        return await asyncio.to_thread(extract_max_z_height_from_3mf, path, archive.plate_id or 1)
+    except Exception as e:
+        logger.debug("[PLATE-RESTORE] printer %s: no usable print height: %s", printer_id, e)
+        return None
+
+
+async def _restore_plate_for_finish_photo(printer_id: int, max_z_height: float, logger) -> bool:
+    """Raise the plate back into camera framing before the finish photo (#2547).
+
+    Bambu's end G-code drops the plate ~100 mm as the last thing it does, so by
+    the time ``gcode_state`` reaches FINISH the finished print sits far below
+    the camera's natural framing — the complaint behind #1145, #1397 and #1565.
+    This commands an absolute ``G1 Z`` back to just above the last printed
+    layer.
+
+    Absolute, not relative, is the whole safety argument. ``max_z_height +
+    clearance`` is a height the toolhead was physically at seconds earlier, so
+    it is inside the travel limits by construction and leaves the nozzle above
+    the part. It is also unambiguous across model families: Z is the
+    nozzle-to-bed gap whether the bed moves (X1/P1/H2) or the toolhead does
+    (A1), so unlike the relative bed-jog path (#1334) there is no sign to get
+    wrong. ``M211`` is never touched — see the bed-jog docstring for why
+    (#2579).
+
+    Returns True if the move was sent and waited out, False if it was skipped.
+    """
+    client = printer_manager.get_client(printer_id)
+    if client is None:
+        return False
+
+    # Re-read state immediately before commanding motion. If the queue has
+    # already started the next print, the printer is no longer ours to move.
+    state = getattr(client, "state", None)
+    if state is None or state.state != "FINISH":
+        logger.info(
+            "[PLATE-RESTORE] printer %s is in state %s, not FINISH — skipping",
+            printer_id,
+            getattr(state, "state", "unknown"),
+        )
+        return False
+
+    target_z = max_z_height + _PLATE_RESTORE_CLEARANCE_MM
+    if not client.send_gcode(f"G90\nG1 Z{target_z:.2f} F{_PLATE_RESTORE_FEEDRATE}"):
+        logger.warning("[PLATE-RESTORE] printer %s: send failed — capturing where it is", printer_id)
+        return False
+
+    logger.info(
+        "[PLATE-RESTORE] printer %s: plate to Z%.2f (print top %.2f + %.1f clearance), settling %.0fs",
+        printer_id,
+        target_z,
+        max_z_height,
+        _PLATE_RESTORE_CLEARANCE_MM,
+        _PLATE_RESTORE_SETTLE_SECONDS,
+    )
+    await asyncio.sleep(_PLATE_RESTORE_SETTLE_SECONDS)
+    return True
+
+
+def _park_plate_after_finish_photo(printer_id: int, max_z_height: float, logger) -> None:
+    """Drop the plate again after the finish photo (#2547).
+
+    Without this the user walks up to a finished print sitting just under the
+    nozzle, which is exactly the position Bambu's end G-code goes out of its way
+    to avoid — awkward to lift the plate out, and easy to knock the toolhead.
+    Fire-and-forget: if it doesn't land, the plate is merely high, and the next
+    print homes anyway.
+    """
+    client = printer_manager.get_client(printer_id)
+    state = getattr(client, "state", None) if client else None
+    if client is None or state is None or state.state != "FINISH":
+        return
+    client.send_gcode(f"G90\nG1 Z{max_z_height + _PLATE_PARK_DROP_MM:.2f} F{_PLATE_RESTORE_FEEDRATE}")
+    logger.debug("[PLATE-RESTORE] printer %s: plate returned to unload height", printer_id)
+
+
+async def _plate_restore_is_blocked_by_queue(printer_id: int) -> bool:
+    """True if a queue item is about to take this printer (#2547).
+
+    The scheduler dispatches the next job the moment a print completes, and a
+    plate move interleaved with a print start is not a race worth having. The
+    state re-check in ``_restore_plate_for_finish_photo`` closes the tail of
+    this window; this closes the head of it.
+    """
+    try:
+        from backend.app.models.print_queue import PrintQueueItem
+
+        async with async_session() as db:
+            result = await db.execute(
+                select(PrintQueueItem.id)
+                .where(
+                    PrintQueueItem.printer_id == printer_id,
+                    PrintQueueItem.status.in_(("pending", "printing")),
+                )
+                .limit(1)
+            )
+            return result.scalar_one_or_none() is not None
+    except Exception as e:
+        # Fail closed: if we can't tell, don't move the plate.
+        logging.getLogger(__name__).debug(
+            "[PLATE-RESTORE] queue check failed for printer %s: %s — skipping restore", printer_id, e
+        )
+        return True
+
+
 async def on_finish_photo_moment(printer_id: int, data: dict):
     """Pre-capture a finish photo when the printer enters stage 22 / FINISH (#1721).
 
@@ -4352,6 +4570,11 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
     producer_done = asyncio.Event()
     _stage22_finish_in_flight[printer_id] = producer_done
 
+    # #2547: set once the plate has actually been raised, and read by the
+    # `finally` below. Declared out here so a failure anywhere after the move —
+    # a camera timeout, a DB error — still lowers the plate again.
+    restore_max_z: float | None = None
+
     try:
         async with async_session() as db:
             from backend.app.api.routes.settings import get_setting
@@ -4362,6 +4585,9 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
                 logger.info("[FINISH-PHOTO-MOMENT] capture_finish_photo disabled — skipping pre-capture")
                 return
 
+            restore_setting = await get_setting(db, "finish_photo_restore_plate")
+            restore_plate_enabled = restore_setting is None or restore_setting.lower() == "true"
+
             result = await db.execute(select(Printer).where(Printer.id == printer_id))
             printer = result.scalar_one_or_none()
             if printer is None:
@@ -4372,22 +4598,70 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
                 return
 
         frame_bytes: bytes | None = None
-
-        # #1867: on the FINISH-state fallback the End G-code (e.g. SwapMod
-        # plate-swap) has already run, so a live grab now captures the swapped
-        # or empty plate. Prefer the banked in-print frame — the finished
-        # print from the last object layer, before the swap. Only for
-        # `finish_state`: the `stage_22` and `last_layer` triggers fire before
-        # the swap and give cleaner (parked-toolhead) framing via a live grab.
-        if trigger == "finish_state":
+        # #2708: the banked frame arrives already rotated — it comes from
+        # `_capture_snapshot_for_notification`, which rotates before returning.
+        # Every other source below is a raw grab. Tracking which lets us store
+        # exactly one rotation in `_stage22_finish_frames` either way.
+        frame_already_rotated = False
+
+        # On the FINISH-state path the End G-code has already run, and two very
+        # different situations arrive here needing opposite answers.
+        #
+        # #1867: if Bambuddy injected End G-code into this print, a SwapMod
+        # snippet may have ejected the plate — the scene in front of the camera
+        # is no longer the finished print, and no amount of moving the plate
+        # brings it back. Use the banked in-print frame instead.
+        #
+        # #2547: otherwise the print is still sitting there, just ~100 mm lower
+        # than the camera frames well, and the toolhead is parked out of the
+        # way. That is the *best* moment available on firmware that never emits
+        # stage 22 (H2C, A1 Mini) — so capture live, after putting the plate
+        # back. Preferring the bank here unconditionally, as this code used to,
+        # is what shipped a mid-print photo with the toolhead over the part.
+        if trigger == "finish_state" and print_dispatch_context.end_gcode_injected(printer_id):
             banked = _inprint_frame_bank.get(printer_id)
             if banked:
                 frame_bytes = banked
+                frame_already_rotated = True
                 logger.info(
-                    "[FINISH-PHOTO-MOMENT] using banked in-print frame (%d bytes) — "
-                    "avoids post-swap live grab on stage-22-less firmware",
+                    "[FINISH-PHOTO-MOMENT] End G-code was injected — using banked in-print "
+                    "frame (%d bytes) instead of a post-swap live grab",
                     len(banked),
                 )
+            else:
+                logger.warning(
+                    "[FINISH-PHOTO-MOMENT] End G-code was injected for printer %s but the "
+                    "in-print bank is empty — falling back to a live grab, which may show a "
+                    "swapped or empty plate",
+                    printer_id,
+                )
+
+        # `restore_max_z` is set only once the plate is actually up, because the
+        # `finally` reads it to decide whether it owes a move back down.
+        #
+        # Never on a print whose End G-code Bambuddy injected, even when the bank
+        # came up empty above: that machine may have just ejected its plate, and
+        # driving Z into whatever a swap mechanism is doing is not a risk worth
+        # taking for a photo of a bed we already know may be bare.
+        if (
+            frame_bytes is None
+            and trigger == "finish_state"
+            and restore_plate_enabled
+            and not print_dispatch_context.end_gcode_injected(printer_id)
+        ):
+            wants_restore = await _max_z_for_current_print(printer_id, data, logger)
+            if wants_restore is None:
+                logger.info(
+                    "[PLATE-RESTORE] printer %s: print height unknown — capturing without restore",
+                    printer_id,
+                )
+            elif await _plate_restore_is_blocked_by_queue(printer_id):
+                logger.info(
+                    "[PLATE-RESTORE] printer %s has queued work — skipping plate restore",
+                    printer_id,
+                )
+            elif await _restore_plate_for_finish_photo(printer_id, wants_restore, logger):
+                restore_max_z = wants_restore
 
         if frame_bytes is None and printer.external_camera_enabled and printer.external_camera_url:
             from backend.app.api.routes.camera import live_frame_for_capture
@@ -4436,12 +4710,15 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
                     )
 
         if frame_bytes:
+            if not frame_already_rotated:
+                frame_bytes = _apply_camera_rotation(frame_bytes, printer, logger)
             _stage22_finish_frames[printer_id] = frame_bytes
         else:
             logger.warning(
                 "[FINISH-PHOTO-MOMENT] no frame captured for printer %s — post-completion fallback will retry",
                 printer_id,
             )
+
     except Exception as e:
         logger.warning(
             "[FINISH-PHOTO-MOMENT] pre-capture failed for printer %s: %s",
@@ -4449,6 +4726,13 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
             e,
         )
     finally:
+        # #2547: we raised the plate, so we own lowering it — including when the
+        # capture above failed or threw partway through.
+        if restore_max_z is not None:
+            try:
+                _park_plate_after_finish_photo(printer_id, restore_max_z, logger)
+            except Exception as e:
+                logger.warning("[PLATE-RESTORE] printer %s: could not lower plate: %s", printer_id, e)
         # #1790: always unblock the consumer's bounded wait — whether we stored
         # a frame, gave up, or hit an exception. Local ref means cleanup of the
         # dict entry by the consumer doesn't affect signalling.
@@ -5265,6 +5549,11 @@ async def on_print_complete(printer_id: int, data: dict):
 
     async def _background_finish_photo() -> str | None:
         """Capture finish photo in background. Returns photo filename if captured."""
+        # #2547: set once this function has raised the plate itself (the
+        # timelapse path, where the moment producer returned without doing it).
+        # Declared out here so the `finally` can lower it again no matter where
+        # the capture below fails.
+        plate_restored_z: float | None = None
         try:
             logger.info("[PHOTO-BG] Starting finish photo capture for archive %s", archive_id)
 
@@ -5323,6 +5612,7 @@ async def on_print_complete(printer_id: int, data: dict):
                 photo_filename, timelapse_still_pending = await _capture_finish_photo_from_timelapse(
                     archive_id=archive_id,
                     archive_dir=archive_dir,
+                    rotation=getattr(printer, "camera_rotation", 0),
                 )
 
             # #1721: replacement framing path — on_finish_photo_moment
@@ -5339,10 +5629,16 @@ async def on_print_complete(printer_id: int, data: dict):
                 # producer's still-in-flight grab (single-client RTSP
                 # on Bambu printers). Wait for the producer to finish
                 # or give up before touching the cache.
+                #
+                # #2547: 20s was enough when the producer only ever grabbed a
+                # frame. It now also raises the plate first, which costs the
+                # settle window before the grab even starts — so the budget has
+                # to cover settle + a worst-case 15s RTSP timeout, and still sit
+                # under the notification's own photo wait below.
                 in_flight = _stage22_finish_in_flight.pop(printer_id, None)
                 if in_flight is not None:
                     try:
-                        await asyncio.wait_for(in_flight.wait(), timeout=20.0)
+                        await asyncio.wait_for(in_flight.wait(), timeout=_FINISH_PHOTO_PRODUCER_WAIT_SECONDS)
                     except asyncio.TimeoutError:
                         logger.warning(
                             "[PHOTO-BG] timed out waiting for stage-22 producer for printer %s — proceeding to fallback",
@@ -5350,6 +5646,9 @@ async def on_print_complete(printer_id: int, data: dict):
                         )
                 cached_frame = _stage22_finish_frames.pop(printer_id, None)
                 if cached_frame:
+                    # Already rotated by the producer (#2708) — rotating again
+                    # here would undo the fix on the banked-frame path, whose
+                    # bytes reach the cache having been rotated once already.
                     photos_dir = archive_dir / "photos"
                     photos_dir.mkdir(parents=True, exist_ok=True)
                     timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
@@ -5362,6 +5661,37 @@ async def on_print_complete(printer_id: int, data: dict):
                         len(cached_frame),
                     )
 
+            # #2547: the timelapse path reaches the live grab below whenever the
+            # video hasn't landed in time — the documented usual outcome on
+            # P1-series, where transfers are slowest. `on_finish_photo_moment`
+            # returned early for those prints without raising the plate, so
+            # without this the photo that actually ships in the notification is
+            # of an already-dropped plate: exactly the framing #1145/#1397/#1565
+            # asked us to fix. The archive still gets the better video frame
+            # later; this is about the image the user is sent.
+            #
+            # Gated on `timelapse_was_active` precisely because that is the
+            # condition under which the producer skipped. On every other path it
+            # has already raised and lowered the plate, and repeating that here
+            # would be a second pointless round trip.
+            if (
+                not photo_filename
+                and data.get("timelapse_was_active")
+                and not print_dispatch_context.end_gcode_injected(printer_id)
+            ):
+                try:
+                    async with async_session() as db:
+                        from backend.app.api.routes.settings import get_setting
+
+                        restore_setting = await get_setting(db, "finish_photo_restore_plate")
+                    if restore_setting is None or restore_setting.lower() == "true":
+                        max_z = await _max_z_for_current_print(printer_id, data, logger)
+                        if max_z is not None and not await _plate_restore_is_blocked_by_queue(printer_id):
+                            if await _restore_plate_for_finish_photo(printer_id, max_z, logger):
+                                plate_restored_z = max_z
+                except Exception as e:
+                    logger.warning("[PLATE-RESTORE] printer %s: restore failed: %s", printer_id, e)
+
             # Fallback chain: external camera → buffered live frame →
             # fresh RTSP capture. Only runs if the timelapse path above
             # didn't already produce a photo.
@@ -5384,6 +5714,7 @@ async def on_print_complete(printer_id: int, data: dict):
                             snapshot_url=printer.external_camera_snapshot_url,
                         )
                     if frame_data:
+                        frame_data = _apply_camera_rotation(frame_data, printer, logger)
                         photos_dir = archive_dir / "photos"
                         photos_dir.mkdir(parents=True, exist_ok=True)
                         timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
@@ -5401,6 +5732,7 @@ async def on_print_complete(printer_id: int, data: dict):
                     if (active_for_printer or active_chamber_for_printer) and buffered_frame:
                         # Use frame from active stream
                         logger.info("[PHOTO-BG] Using buffered frame from active stream")
+                        buffered_frame = _apply_camera_rotation(buffered_frame, printer, logger)
                         photos_dir = archive_dir / "photos"
                         photos_dir.mkdir(parents=True, exist_ok=True)
                         timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
@@ -5418,6 +5750,7 @@ async def on_print_complete(printer_id: int, data: dict):
                             access_code=printer.access_code,
                             model=printer.model,
                             archive_dir=archive_dir,
+                            rotation=getattr(printer, "camera_rotation", 0),
                         )
 
             # Write phase: attach the photo in a fresh short-lived session.
@@ -5449,7 +5782,9 @@ async def on_print_complete(printer_id: int, data: dict):
             # gallery never lists.
             if timelapse_still_pending:
                 spawn_background_task(
-                    _upgrade_finish_photo_from_timelapse(archive_id, archive_dir),
+                    _upgrade_finish_photo_from_timelapse(
+                        archive_id, archive_dir, rotation=getattr(printer, "camera_rotation", 0)
+                    ),
                     name=f"finish-photo-upgrade-{archive_id}",
                 )
 
@@ -5457,6 +5792,15 @@ async def on_print_complete(printer_id: int, data: dict):
         except Exception as e:
             logger.warning("[PHOTO-BG] Failed: %s", e)
             return None
+        finally:
+            # #2547: we raised the plate, so we owe the move back down — even if
+            # the capture in between threw. Otherwise the user finds the print
+            # pinned under the nozzle.
+            if plate_restored_z is not None:
+                try:
+                    _park_plate_after_finish_photo(printer_id, plate_restored_z, logger)
+                except Exception as e:
+                    logger.warning("[PLATE-RESTORE] printer %s: could not lower plate: %s", printer_id, e)
 
     spawn_background_task(_background_energy_calculation(), name="background-energy-calc")
     # Photo capture task - result will be used by notifications
@@ -5659,7 +6003,22 @@ async def on_print_complete(printer_id: int, data: dict):
     # timelapse for up to 60s (#1397) — extend the budget so the notification
     # carries the correct bed-up photo instead of falling through to the
     # live-cam grab. Adds ~30s of notification latency at worst on slow links.
-    photo_wait_timeout = 75 if data.get("timelapse_was_active") else 45
+    #
+    # #2547: both budgets now have to cover a plate restore as well.
+    #
+    # Without timelapse, the wait is on the moment producer, which raises the
+    # plate before its grab — so this has to outlast that producer's own budget.
+    #
+    # With timelapse, the capture polls up to
+    # `_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS` for the video and only then
+    # falls back to a live grab, which is the case that raises the plate. At the
+    # old flat 75s that fallback was guaranteed to be cut off mid-settle, so the
+    # restore would have moved the plate for a photo nobody waited for.
+    photo_wait_timeout = (
+        _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS + _FINISH_PHOTO_PRODUCER_WAIT_SECONDS
+        if data.get("timelapse_was_active")
+        else _FINISH_PHOTO_PRODUCER_WAIT_SECONDS + 15
+    )
 
     async def _photo_then_notify():
         """Wait for photo capture, then send notification with photo URL."""
@@ -6302,6 +6661,130 @@ def stop_spoolbuddy_watchdog():
         logging.getLogger(__name__).info("SpoolBuddy watchdog stopped")
 
 
+# Dead-MQTT-session recovery
+#
+# check_staleness() covers the "connected but silent" half-broken session. It
+# does nothing once ``state.connected`` is False, and paho's own auto-reconnect
+# is the only thing left watching at that point. When paho stops making
+# progress there is no backstop at all: the #2732 bundle has a P1S drop on a
+# keep-alive timeout at 02:19 and not reconnect until 11:24 — nine hours
+# offline with the UI open the whole time, recovered only when something
+# happened to nudge it.
+#
+# This loop is that backstop. It only touches printers that had a working
+# session and lost it, and only when the MQTT port still answers — a printer
+# that is simply switched off is left to paho, since rebuilding a client
+# against an unreachable host achieves nothing and would fill the log every
+# night.
+_connection_watchdog_task: asyncio.Task | None = None
+CONNECTION_WATCHDOG_INTERVAL = 60
+# How long a printer must have been silent before we stop trusting paho.
+# Comfortably above STALE_TIMEOUT (60 s) and the max reconnect backoff (30 s),
+# so a session that is recovering on its own is never interrupted.
+CONNECTION_WATCHDOG_OFFLINE_GRACE = 300
+# Per-printer floor between rebuild attempts.
+CONNECTION_WATCHDOG_RETRY_INTERVAL = 300
+_connection_watchdog_last_attempt: dict[int, float] = {}
+
+
+async def _recover_dead_printer_sessions() -> int:
+    """Rebuild MQTT clients that have been offline too long to still be trying.
+
+    Returns the number of printers a rebuild was attempted for (for tests and
+    for the caller's logging). Never raises: one unreachable printer must not
+    stop the sweep for the rest of the farm.
+    """
+    logger = logging.getLogger(__name__)
+    from backend.app.services.printer_diagnostic import PORT_MQTT, check_port
+
+    now = time.monotonic()
+    recovered = 0
+
+    for printer_id, client in list(printer_manager._clients.items()):
+        try:
+            if client.state.connected:
+                _connection_watchdog_last_attempt.pop(printer_id, None)
+                continue
+
+            # Time since the last inbound message is the age of the last known
+            # good session — no extra bookkeeping needed, and it is the same
+            # clock is_stale() reads. 0 means this client has never had one:
+            # that is the initial-connect path, where paho retrying is the
+            # correct and only behaviour, so leave it be.
+            last_msg = client._last_message_time
+            if not last_msg:
+                continue
+            offline_for = time.time() - last_msg
+            if offline_for < CONNECTION_WATCHDOG_OFFLINE_GRACE:
+                continue
+
+            last_attempt = _connection_watchdog_last_attempt.get(printer_id)
+            if last_attempt is not None and now - last_attempt < CONNECTION_WATCHDOG_RETRY_INTERVAL:
+                continue
+
+            if not await check_port(client.ip_address, PORT_MQTT):
+                # Switched off, unplugged, or off the network. Paho's retry is
+                # the right handler; say so at debug level and move on.
+                logger.debug(
+                    "[#2732] Printer %s offline for %.0fs and its MQTT port is not answering "
+                    "— leaving the reconnect to paho",
+                    printer_id,
+                    offline_for,
+                )
+                _connection_watchdog_last_attempt[printer_id] = now
+                continue
+
+            _connection_watchdog_last_attempt[printer_id] = now
+            recovered += 1
+            logger.warning(
+                "[#2732] Printer %s has been offline for %.0fs but answers on MQTT port %d — "
+                "rebuilding the client with a fresh session (last connect error: %s)",
+                printer_id,
+                offline_for,
+                PORT_MQTT,
+                client.last_connect_error or "none recorded",
+            )
+            # Async context, so this takes the hard-reset path: fresh client_id,
+            # paho's QoS 1 queue dropped. That matters — a project_file left
+            # unacked on the dead session would otherwise replay into the new
+            # one and trip 0500_4003 on the printer (#1136).
+            client.force_reconnect_stale_session(f"offline for {offline_for:.0f}s, port still answering")
+        except Exception as e:
+            logger.warning("[#2732] Connection watchdog failed for printer %s: %s", printer_id, e)
+
+    return recovered
+
+
+async def _connection_watchdog_loop():
+    logger = logging.getLogger(__name__)
+    # Let the initial connects settle before judging anyone offline.
+    await asyncio.sleep(CONNECTION_WATCHDOG_OFFLINE_GRACE)
+    while True:
+        try:
+            await _recover_dead_printer_sessions()
+        except asyncio.CancelledError:
+            break
+        except Exception as e:
+            logger.warning("Connection watchdog sweep failed: %s", e)
+        await asyncio.sleep(CONNECTION_WATCHDOG_INTERVAL)
+
+
+def start_connection_watchdog():
+    global _connection_watchdog_task
+    if _connection_watchdog_task is None:
+        _connection_watchdog_task = asyncio.create_task(_connection_watchdog_loop())
+        logging.getLogger(__name__).info("Printer connection watchdog started")
+
+
+def stop_connection_watchdog():
+    global _connection_watchdog_task
+    if _connection_watchdog_task:
+        _connection_watchdog_task.cancel()
+        _connection_watchdog_task = None
+        _connection_watchdog_last_attempt.clear()
+        logging.getLogger(__name__).info("Printer connection watchdog stopped")
+
+
 # Camera stream orphan cleanup
 _camera_cleanup_task: asyncio.Task | None = None
 CAMERA_CLEANUP_INTERVAL = 60
@@ -6584,10 +7067,10 @@ async def lifespan(app: FastAPI):
 
         await tl_layer_change(printer_id, layer_num)
 
-        # #1867: bank a recent in-print frame so the FINISH-state finish-photo
-        # path (firmware that never emits stg_cur=22, e.g. A1 Mini) has a
-        # pre-swap image to fall back on instead of a live grab of the swapped
-        # plate. Layer-driven, so it freezes at the final object layer.
+        # #1867: bank a recent in-print frame so the finish-photo path has a
+        # pre-End-G-code image to use instead of a live grab of a swapped plate.
+        # #2547 added `on_print_progress` as a second driver — this one alone
+        # stops firing once the final layer begins.
         await _maybe_bank_inprint_frame(printer_id, layer_num)
 
         # First layer complete notification (layer_num >= 2 means layer 1 is done).
@@ -6631,6 +7114,21 @@ async def lifespan(app: FastAPI):
 
     printer_manager.set_layer_change_callback(on_layer_change)
 
+    async def on_print_progress(printer_id: int, percent: int):
+        """#2547: keep the in-print frame bank fresh through the final layer.
+
+        `on_layer_change` stops the moment the last layer starts, which on the
+        H2C capture that closed #2547 left the bank stale for the three minutes
+        that layer took. Progress is the only field that keeps advancing there,
+        and it freezes before the End G-code runs — so banking on it stays
+        inside the print and never sees a swapped plate.
+        """
+        client = printer_manager.get_client(printer_id)
+        state = client.state if client else None
+        await _maybe_bank_inprint_frame(printer_id, state.layer_num if state else 0)
+
+    printer_manager.set_print_progress_callback(on_print_progress)
+
     # Event-driven bed cooldown: fires whenever bed_temper arrives via MQTT
     async def on_bed_temp_update(printer_id: int, bed_temp: float):
         waiter = _bed_cool_waiters.get(printer_id)
@@ -6852,6 +7350,21 @@ async def lifespan(app: FastAPI):
     # Start camera stream orphan cleanup
     start_camera_cleanup()
 
+    # Start the backstop for MQTT sessions paho has stopped recovering (#2732)
+    start_connection_watchdog()
+
+    # One-shot sweep for timelapse session directories orphaned by a crash
+    # or restart that happened mid-print (in-memory session tracking can't
+    # survive that, and nothing else reaps the leftover frames/output file)
+    try:
+        from backend.app.services.layer_timelapse import cleanup_orphaned_timelapse_sessions
+
+        removed = cleanup_orphaned_timelapse_sessions()
+        if removed:
+            logging.getLogger(__name__).info("Removed %d orphaned timelapse session artifact(s)", removed)
+    except Exception as e:
+        logging.getLogger(__name__).warning("Orphaned timelapse session cleanup failed: %s", e)
+
     # Start expected-print TTL eviction (prevents memory leak when prints are
     # registered but on_print_start never fires)
     start_expected_prints_cleanup()
@@ -6892,6 +7405,7 @@ async def lifespan(app: FastAPI):
     stop_runtime_tracking()
     stop_spoolbuddy_watchdog()
     stop_camera_cleanup()
+    stop_connection_watchdog()
     from backend.app.services.loop_watchdog import stop_loop_watchdog
 
     stop_loop_watchdog()

+ 12 - 0
backend/app/models/virtual_printer.py

@@ -49,6 +49,18 @@ class VirtualPrinter(Base):
     )  # queue mode: pin per-slot type+color from the 3MF onto the queue
     # item so the scheduler refuses to dispatch onto a printer with the wrong
     # filament loaded (#1188).
+    save_ams_mapping: Mapped[bool] = mapped_column(
+        Boolean, server_default="false"
+    )  # queue mode: keep the slicer's own live-resolved AMS-slot pick (the
+    # `ams_mapping` field on the MQTT `project_file` command) instead of
+    # re-deriving one from the file's static type/color. Stamps it on the queue
+    # item so THIS print dispatches to those trays, and onto the archive's
+    # `extra_data.slicer_ams_mapping` so a later reprint can reuse the same
+    # physical spools. Off by default: taking the slicer's pick makes the
+    # scheduler skip `_compute_ams_mapping_for_printer`, and with it
+    # `prefer_lowest_filament`, its AMS-backup gate (#1766) and the
+    # inventory-remain overrides — so it stays opt-in per virtual printer
+    # rather than changing behaviour for upgraders (#2700).
     gcode_injection: Mapped[bool] = mapped_column(
         Boolean, server_default="false"
     )  # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet

+ 13 - 0
backend/app/schemas/github_backup.py

@@ -157,6 +157,19 @@ class GitHubBackupLogResponse(BaseModel):
         from_attributes = True
 
 
+class CloudAccountCounts(BaseModel):
+    """How many connected cloud accounts a backup would collect presets from.
+
+    Counts only, never identities: with auth enabled these are other users'
+    accounts, and whoever administers the backup has no business learning who
+    signed in to what. The number is enough to answer the only question the UI
+    asks — is the Cloud Profiles category worth offering at all (#2717).
+    """
+
+    bambu: int = Field(default=0, description="Connected Bambu Cloud accounts")
+    orca: int = Field(default=0, description="Connected Orca Cloud accounts")
+
+
 class GitHubBackupStatus(BaseModel):
     """Schema for current backup status."""
 

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

@@ -194,6 +194,12 @@ class PrintQueueItemResponse(BaseModel):
     # 3MFs: when `plate_id` is set, the value is the matching plate's
     # `curr_bed_type` rather than the archive-level first-plate default.
     bed_type: str | None = None
+    # True when the source archive carries the slicer's own live-resolved
+    # AMS-slot pick (extra_data.slicer_ams_mapping) *and* it was resolved
+    # against this row's own printer — the only case where dispatch actually
+    # reuses that exact physical spool instead of the scheduler re-deriving one
+    # from the file's static type/color.
+    archive_has_slicer_ams_mapping: bool = False
 
     # User tracking (Issue #206)
     created_by_id: int | None = None

+ 35 - 0
backend/app/schemas/settings.py

@@ -33,6 +33,16 @@ class AppSettings(BaseModel):
             "this print, otherwise it is deleted automatically after the photo is captured."
         ),
     )
+    finish_photo_restore_plate: bool = Field(
+        default=True,
+        description=(
+            "Raise the build plate back into camera framing before taking the finish photo. "
+            "Bambu's end G-code drops the plate ~100mm as the last thing it does, leaving the "
+            "finished print far below the camera's natural framing. Bambuddy moves it back to "
+            "just above the last printed layer, takes the photo, then lowers it again. Skipped "
+            "when the print height is unknown or another job is queued for the printer."
+        ),
+    )
     default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
     currency: str = Field(default="USD", description="Currency for cost tracking")
     energy_cost_per_kwh: float = Field(default=0.15, description="Electricity cost per kWh for energy tracking")
@@ -275,6 +285,21 @@ class AppSettings(BaseModel):
         default="",
         description="BambuStudio sidecar URL (e.g. http://localhost:3001). Empty falls back to the BAMBU_STUDIO_API_URL env var.",
     )
+    # How long to keep waiting on a slice that isn't finishing. Measured against
+    # the sidecar's progress channel, not total elapsed time — a heavy model can
+    # legitimately slice for half an hour, and a wall-clock ceiling cannot tell
+    # that apart from a stalled one (#2730). Sidecars too old to report progress
+    # fall back to using this as a total-elapsed ceiling, which is the pre-#2730
+    # behaviour with a configurable number.
+    slicer_stall_timeout_minutes: int = Field(
+        default=15,
+        ge=1,
+        le=240,
+        description=(
+            "Give up on a slice after this many minutes with no progress from the sidecar. "
+            "On sidecars that do not report progress, applies to total slicing time instead."
+        ),
+    )
 
     # Prometheus metrics endpoint
     prometheus_enabled: bool = Field(default=False, description="Enable Prometheus metrics endpoint at /metrics")
@@ -457,6 +482,13 @@ class AppSettings(BaseModel):
         default="",
         description="Self-hosted Obico ML API base URL (e.g., http://192.168.1.10:3333)",
     )
+    obico_ml_token: str = Field(
+        default="",
+        description=(
+            "Bearer token for the Obico ML API, matching the server's ML_API_TOKEN "
+            "environment variable. Empty when the server runs without one."
+        ),
+    )
     obico_sensitivity: str = Field(
         default="medium",
         description="Detection sensitivity: 'low', 'medium', or 'high' (adjusts LOW/HIGH thresholds)",
@@ -496,6 +528,7 @@ class AppSettingsUpdate(BaseModel):
     auto_archive: bool | None = None
     save_thumbnails: bool | None = None
     capture_finish_photo: bool | None = None
+    finish_photo_restore_plate: bool | None = None
     default_filament_cost: float | None = None
     currency: str | None = None
     energy_cost_per_kwh: float | None = None
@@ -565,6 +598,7 @@ class AppSettingsUpdate(BaseModel):
     use_slicer_api: bool | None = None
     orcaslicer_api_url: str | None = None
     bambu_studio_api_url: str | None = None
+    slicer_stall_timeout_minutes: int | None = Field(default=None, ge=1, le=240)
     prometheus_enabled: bool | None = None
     prometheus_token: str | None = None
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
@@ -607,6 +641,7 @@ class AppSettingsUpdate(BaseModel):
     ldap_default_group: str | None = None
     obico_enabled: bool | None = None
     obico_ml_url: str | None = None
+    obico_ml_token: str | None = None
     obico_sensitivity: str | None = None
     obico_action: str | None = None
     obico_poll_interval: int | None = Field(default=None, ge=5, le=120)

+ 36 - 0
backend/app/services/archive.py

@@ -1144,6 +1144,8 @@ class ArchiveService:
         prefer_filename_for_name: bool = False,
         plate_id: int | None = None,
         library_file_id: int | None = None,
+        slicer_ams_mapping: list[int] | None = None,
+        slicer_ams_mapping_printer_id: int | None = None,
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
 
@@ -1166,6 +1168,21 @@ class ArchiveService:
                 metadata. Used by virtual-printer flows so users who rename a job in
                 BambuStudio's "send to printer" dialog see that name instead of the
                 creator-baked title (#1152).
+            slicer_ams_mapping: The slicer's own live-resolved AMS-slot pick, to persist
+                onto `extra_data.slicer_ams_mapping` for a later reprint to reuse. Deliberately
+                a distinct parameter, not read off `print_data["ams_mapping"]` — that key is
+                populated on every MQTT print-start callback regardless of source (bambu_mqtt's
+                request-topic interception captures it for slicer-direct LAN prints too), so
+                promoting it unconditionally would stamp every archive on installs with no
+                virtual printer at all. Callers that gate this behind an opt-in (the VP-queue
+                "Save AMS mapping" toggle) pass it explicitly; everyone else leaves it unset.
+            slicer_ams_mapping_printer_id: The printer `slicer_ams_mapping`'s tray IDs were
+                resolved against. Required alongside `slicer_ams_mapping` — a global tray ID
+                only means something relative to one printer's specific AMS layout, so a
+                mapping saved without knowing which printer it came from can't be safely
+                reused later on any printer, including the same one (there'd be no way to
+                tell). A model-based VP with no fixed target printer has no valid value to
+                pass here and must leave both params unset.
         """
         # Verify printer exists if specified
         if printer_id is not None:
@@ -1254,6 +1271,25 @@ class ArchiveService:
         if print_data:
             metadata["_print_data"] = print_data
 
+        # Promote the slicer's own live-resolved AMS-slot pick, when the caller
+        # explicitly opted in (see the `slicer_ams_mapping` param docstring for
+        # why this is NOT read off `print_data["ams_mapping"]`), to a stable
+        # top-level extra_data key. Lets a later reprint reuse the exact tray
+        # the user picked/BambuStudio auto-matched at slice time instead of the
+        # scheduler re-deriving one from just the file's static type/color,
+        # which can land on the wrong physical spool when that match isn't
+        # unique. Top-level (not nested under the `_print_data` diagnostic bag)
+        # so API consumers have a single stable path:
+        # `archive.extra_data.slicer_ams_mapping`. Stored together with the
+        # printer it was resolved against — see `slicer_ams_mapping_printer_id`
+        # param docstring — so a later reprint can tell whether it's even
+        # applicable before trying to reuse it.
+        if slicer_ams_mapping and slicer_ams_mapping_printer_id is not None:
+            metadata["slicer_ams_mapping"] = {
+                "mapping": slicer_ams_mapping,
+                "printer_id": slicer_ams_mapping_printer_id,
+            }
+
         # Determine status and timestamps
         status = print_data.get("status", "completed") if print_data else "archived"
         started_at = datetime.now(timezone.utc) if status == "printing" else None

+ 133 - 32
backend/app/services/bambu_mqtt.py

@@ -307,6 +307,19 @@ _HMS_USER_ACTION_CODES: frozenset[str] = frozenset(
     }
 )
 
+# "MQTT command verification failed" — the printer's authorization/authentication
+# protection (firmware >= 01.08.03.00beta / 01.08.05.00) rejecting a control
+# command it could not verify. Queries (get_version, extrusion_cali_get,
+# pushall) still answer, so the connection looks perfectly healthy while
+# project_file, gcode_line and ams_change_filament are all silently dropped —
+# which is exactly how it presents: uploads succeed, the printer echoes our
+# subtask_id, then sits at IDLE forever (#2732).
+#
+# The 16-char form is load-bearing. This code's meaning lives in attr's low half
+# (0500) and code's high half (0001); the MMMM_EEEE short code collapses it to
+# "0500_0007", which matches nothing in any catalog.
+HMS_MQTT_VERIFY_FAILED: str = "0500050000010007"
+
 
 @dataclass
 class KProfile:
@@ -661,6 +674,7 @@ class BambuMQTTClient:
         on_print_complete: Callable[[dict], None] | None = None,
         on_ams_change: Callable[[list], None] | None = None,
         on_layer_change: Callable[[int], None] | None = None,
+        on_print_progress: Callable[[int], None] | None = None,
         on_bed_temp_update: Callable[[float], None] | None = None,
         on_drying_complete: Callable[[int], None] | None = None,
         on_print_running_observed: Callable[[dict], None] | None = None,
@@ -678,6 +692,13 @@ class BambuMQTTClient:
         self.on_print_complete = on_print_complete
         self.on_ams_change = on_ams_change
         self.on_layer_change = on_layer_change
+        # #2547: fired when `mc_percent` advances during a running print.
+        # `on_layer_change` stops firing the instant the final layer starts, so
+        # it is blind to the last few percent of a print — which is exactly the
+        # window the finish-photo frame bank needs to keep refreshing through.
+        # Progress is the one field that keeps ticking there and then freezes
+        # before the end G-code runs, so banking on it stays inside the print.
+        self.on_print_progress = on_print_progress
         self.on_bed_temp_update = on_bed_temp_update
         # #1349: fired when an AMS unit's dry_time falls from >0 to 0 — i.e.
         # the drying cycle just finished (auto- or manually-triggered).
@@ -844,6 +865,13 @@ class BambuMQTTClient:
         self._dev_mode_probe_seq: str | None = None
         self._dev_mode_probe_time: float = 0.0  # monotonic timestamp when probe was sent
         self._dev_mode_probe_failures: int = 0  # consecutive unanswered probes
+        # True while developer_mode=False came from HMS_MQTT_VERIFY_FAILED rather
+        # than from the probe or the "fun" bit. The HMS is a latch, not a level:
+        # the printer reports it until the fault clears, so when a later hms[]
+        # arrives without it (user enabled Developer Mode and restarted the
+        # printer) we drop back to "unknown" and let the probe re-run instead of
+        # leaving a permanently-wrong False behind (#2732).
+        self._dev_mode_from_hms: bool = False
         self._connect_time: float = 0.0  # monotonic timestamp of last _on_connect
 
         # Set when check_staleness() force-closes the socket to trigger reconnect.
@@ -962,7 +990,19 @@ class BambuMQTTClient:
             # regardless, but the printer publishes to device/<real-serial>/
             # report, which is case-sensitive. Surface that once so the user
             # has something actionable instead of an endless reconnect loop.
-            if self._report_messages_since_connect == 0 and not self._zero_report_hint_logged:
+            # Only meaningful once the *current* session has had time to receive
+            # something. _report_messages_since_connect is reset by _on_connect,
+            # so a reconnect that lands microseconds before this check leaves it
+            # at 0 for reasons that have nothing to do with the serial — which is
+            # how a healthy P1S ended up being told to go check its serial number
+            # 1 ms after reconnecting (#2732). Requiring STALE_TIMEOUT of silence
+            # on this session means the hint only fires when the printer really
+            # has published nothing to the topic we subscribed to.
+            # _connect_time of 0 means we have no timestamp to judge by (never went
+            # through _on_connect); fall back to the old unconditional behaviour
+            # rather than silently swallowing the hint.
+            session_too_young = self._connect_time > 0 and (time.monotonic() - self._connect_time) < self.STALE_TIMEOUT
+            if self._report_messages_since_connect == 0 and not session_too_young and not self._zero_report_hint_logged:
                 self._zero_report_hint_logged = True
                 logger.warning(
                     "[%s] Connected and subscribed, but the printer has sent zero "
@@ -2988,7 +3028,14 @@ class BambuMQTTClient:
             # Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)
             if self.state.progress > 0:
                 self._last_valid_progress = self.state.progress
+            previous_progress = self.state.progress
             self.state.progress = float(data["mc_percent"])
+            # #2547: strictly-increasing only. The firmware resets progress to 0
+            # on cancel and re-reports the same percent on most frames; neither
+            # is the print advancing, and both would make the frame bank grab a
+            # camera frame for nothing.
+            if self.state.progress > previous_progress and self._was_running and self.on_print_progress:
+                self.on_print_progress(int(self.state.progress))
         if "mc_remaining_time" in data:
             self.state.remaining_time = int(data["mc_remaining_time"])
         if "mc_print_sub_stage" in data:
@@ -3069,35 +3116,20 @@ class BambuMQTTClient:
                     new_layer,
                 )
                 self._request_push_all()
-            # #1867 last-layer finish-photo trigger. A1 Mini (and other
-            # firmware variants) skips `stg_cur=22`, so the fallback fires
-            # at gcode_state=FINISH — which runs AFTER user End G-code
-            # (e.g. SwapMod plate-swap) and captures the wrong plate.
-            # Firing on the layer_num→total_layer_num edge captures the
-            # last object layer before any end G-code executes.
-            total = self.state.total_layers or 0
-            if (
-                total > 0
-                and new_layer >= total
-                and old_layer < total
-                and self._was_running
-                and not self._finish_photo_captured
-                and self.on_finish_photo_moment
-            ):
-                self._finish_photo_captured = True
-                logger.info(
-                    f"[{self.serial_number}] FINISH PHOTO MOMENT (last-layer) — "
-                    f"layer={new_layer}/{total}, "
-                    f"timelapse_active={self._timelapse_during_print}"
-                )
-                self.on_finish_photo_moment(
-                    {
-                        "trigger": "last_layer",
-                        "filename": self._previous_gcode_file or self.state.gcode_file,
-                        "subtask_name": self.state.subtask_name,
-                        "timelapse_was_active": self._timelapse_during_print,
-                    }
-                )
+            # #2547: there is deliberately NO finish-photo trigger on the
+            # last-layer edge. `layer_num` reaching `total_layer_num` is the
+            # moment the printer *starts* the final layer, not the moment it
+            # finishes it — on the H2C capture that closed #2547 the edge
+            # arrived at 92% with `mc_remaining_time=2`, three minutes and a
+            # filament change before the print actually ended, so the photo
+            # showed the toolhead mid-print over the part. Worse, the trigger
+            # latched `_finish_photo_captured`, locking out both the stage-22
+            # and FINISH triggers below for the rest of the print.
+            #
+            # #1867 (End G-code ejects the plate before FINISH) is handled
+            # where it belongs instead: `on_finish_photo_moment` prefers the
+            # in-print frame bank when the dispatcher recorded that it injected
+            # End G-code into this print. See services/print_dispatch_context.
         if total_from_this_frame:
             # Firmware (P1S observed) resets `total_layer_num` to 0 at print
             # end — same shape as the `layer_num` reset guarded above. Applying
@@ -3747,6 +3779,7 @@ class BambuMQTTClient:
             hms_list = data["hms"]
             logger.debug("[%s] HMS data received: %s", self.serial_number, hms_list)
             self.state.hms_errors = []
+            verify_failed = False
             if isinstance(hms_list, list):
                 for hms in hms_list:
                     if isinstance(hms, dict):
@@ -3782,6 +3815,8 @@ class BambuMQTTClient:
                         # discards — that's the firmware's matching key, so try it
                         # first and fall back to the short form.
                         full_code = f"{attr:08X}{code:08X}"
+                        if full_code == HMS_MQTT_VERIFY_FAILED:
+                            verify_failed = True
                         actions = get_actions_for_error_code(self.serial_number[:3], full_code)
                         if not actions:
                             actions = get_actions_for_error_code(self.serial_number[:3], short_code.replace("_", ""))
@@ -3796,6 +3831,7 @@ class BambuMQTTClient:
                                 full_code=full_code,
                             )
                         )
+            self._apply_mqtt_verify_state(verify_failed)
 
         # Parse print_error - this is a different error format than HMS
         # print_error is a 32-bit integer where:
@@ -4496,10 +4532,64 @@ class BambuMQTTClient:
         logger.info("[%s] Probing developer mode via ams_filament_setting (seq=%s)", self.serial_number, seq)
         self._client.publish(self.topic_publish, json.dumps(command), qos=1)
 
+    def _apply_mqtt_verify_state(self, verify_failed: bool) -> None:
+        """Reconcile developer_mode with the printer's own command-verification verdict.
+
+        ``HMS_MQTT_VERIFY_FAILED`` is the only *direct* evidence we ever get that
+        control commands are being refused, so it outranks the probe in both
+        directions:
+
+        * present  → developer_mode is definitively False, whatever the probe
+          concluded. The probe can only read the response to its own
+          ``ams_filament_setting``; on P1 firmware a refusal is reported here
+          instead, so the probe answers ENABLED while every print silently dies
+          (#2732).
+        * gone again → drop the HMS-derived False back to unknown and re-arm the
+          probe, so a user who enables Developer Mode and restarts the printer
+          isn't stuck behind a verdict nothing would ever revisit.
+
+        A False that came from the probe or the ``fun`` bit is left alone — this
+        only ever unwinds its own latch.
+        """
+        if verify_failed:
+            if not self._dev_mode_from_hms:
+                logger.warning(
+                    "[%s] Printer reported HMS %s (MQTT command verification failed): it is "
+                    "rejecting control commands, so prints, temperature changes and filament "
+                    "loads will be ignored. Enable Developer Mode on the printer and restart it.",
+                    self.serial_number,
+                    HMS_MQTT_VERIFY_FAILED,
+                )
+            self._dev_mode_from_hms = True
+            self.state.developer_mode = False
+            return
+
+        if not self._dev_mode_from_hms:
+            return
+        logger.info(
+            "[%s] HMS %s cleared — re-probing developer mode",
+            self.serial_number,
+            HMS_MQTT_VERIFY_FAILED,
+        )
+        self._dev_mode_from_hms = False
+        self.state.developer_mode = None
+        self._dev_mode_probed = False
+        self._dev_mode_needs_probe = False
+
     def _handle_dev_mode_probe_response(self, data: dict):
         """Handle response to the developer mode probe command.
 
         Sets developer_mode based on whether the printer accepted or rejected the command.
+
+        Three outcomes, not two. An explicit ``success`` proves commands are
+        accepted and an explicit verify-failure proves they are not, but anything
+        else proves nothing — P1S firmware 01.10.00.00 answers this probe with a
+        bare ``{"command": "ams_filament_setting", "sequence_id": "3"}`` and no
+        ``result`` at all, while refusing every control command and reporting
+        ``HMS_MQTT_VERIFY_FAILED`` instead. Reading that empty response as ENABLED
+        is what put ``developer_mode: pass`` in the support bundle of a printer
+        that had not accepted a command all day (#2732). Leaving it unknown makes
+        the connection diagnostic report ``skip``, which is the honest answer.
         """
         self._dev_mode_probe_seq = None  # One-shot: don't match future responses
         self._dev_mode_probe_failures = 0  # Reset on any response
@@ -4509,10 +4599,21 @@ class BambuMQTTClient:
         if result == "failed" and "verify failed" in reason:
             self.state.developer_mode = False
             logger.info("[%s] Developer mode probe: DISABLED (reason=%r)", self.serial_number, reason)
-        else:
-            # Success or any other response — commands are accepted
+        elif str(result).lower() == "success":
             self.state.developer_mode = True
             logger.info("[%s] Developer mode probe: ENABLED (result=%r)", self.serial_number, result)
+        else:
+            # An HMS verdict already recorded here is real evidence; don't let an
+            # inconclusive probe response wipe it back to unknown.
+            if not self._dev_mode_from_hms:
+                self.state.developer_mode = None
+            logger.info(
+                "[%s] Developer mode probe: INCONCLUSIVE (result=%r, reason=%r) — "
+                "the printer neither confirmed nor refused the command",
+                self.serial_number,
+                result,
+                reason,
+            )
 
         if self.on_state_change:
             self.on_state_change(self.state)

+ 64 - 0
backend/app/services/camera.py

@@ -836,12 +836,72 @@ async def extract_video_last_frame(video_path: Path, output_path: Path) -> bool:
         return False
 
 
+def apply_camera_rotation(image_data: bytes, rotation: int, logger: logging.Logger) -> bytes:
+    """Apply a camera_rotation value (degrees clockwise) to a captured JPEG.
+
+    Shared by every capture path that saves a still image (notification
+    snapshots, finish photos, layer-timelapse frames) - previously only
+    wired into the notification-snapshot path, which left finish photos
+    and timelapse videos upside-down whenever camera_rotation was set.
+
+    Returns *image_data* itself (identity, not a copy) when there is nothing
+    to do or the rotate fails; callers that write to disk use that to skip a
+    pointless rewrite.
+    """
+    if not rotation:
+        return image_data
+
+    try:
+        from io import BytesIO
+
+        from PIL import Image
+
+        img = Image.open(BytesIO(image_data))
+        # PIL rotate is counter-clockwise, so negate for clockwise rotation
+        img = img.rotate(-rotation, expand=True)
+        buf = BytesIO()
+        img.save(buf, format="JPEG", quality=90)
+        rotated = buf.getvalue()
+        # Debug, not info: layer-timelapse calls this once per layer, so a tall
+        # print would otherwise put hundreds of lines in the log for something
+        # the surrounding capture already reports at debug level.
+        logger.debug("Applied %d° camera rotation: %s → %s bytes", rotation, len(image_data), len(rotated))
+        return rotated
+    except Exception as e:
+        logger.warning("Failed to apply camera rotation: %s", e)
+        return image_data
+
+
+async def apply_camera_rotation_to_file(path: Path, rotation: int, logger: logging.Logger) -> None:
+    """Rotate a JPEG that has already been written to disk, in place.
+
+    Two finish-photo sources never hold the frame as bytes - ``ffmpeg`` writes
+    the file for them, and they return only a filename - so they can't use
+    ``apply_camera_rotation`` directly. Best-effort: any failure leaves the
+    unrotated file in place, which is what the caller had before.
+    """
+    if not rotation:
+        return
+
+    try:
+        data = await asyncio.to_thread(path.read_bytes)
+        rotated = await asyncio.to_thread(apply_camera_rotation, data, rotation, logger)
+        if rotated is data:
+            # Nothing was done (the rotate failed and returned its input) -
+            # rewriting the same bytes would only risk truncating a good file.
+            return
+        await asyncio.to_thread(path.write_bytes, rotated)
+    except Exception as e:
+        logger.warning("Failed to rotate %s in place: %s", path.name, e)
+
+
 async def capture_finish_photo(
     printer_id: int,
     ip_address: str,
     access_code: str,
     model: str | None,
     archive_dir: Path,
+    rotation: int = 0,
 ) -> str | None:
     """Capture a finish photo and save it to the archive's photos folder.
 
@@ -851,6 +911,9 @@ async def capture_finish_photo(
         access_code: Printer access code
         model: Printer model
         archive_dir: Directory of the archive (where the 3MF is stored)
+        rotation: Printer's configured camera_rotation (degrees clockwise).
+            ffmpeg writes the file directly here, so the rotation is applied
+            to it afterwards rather than to bytes in hand.
 
     Returns:
         Filename of the captured photo, or None if capture failed
@@ -875,6 +938,7 @@ async def capture_finish_photo(
     )
 
     if success:
+        await apply_camera_rotation_to_file(output_path, rotation, logger)
         logger.info("Finish photo saved: %s", filename)
         return filename
     else:

+ 8 - 2
backend/app/services/export.py

@@ -99,9 +99,15 @@ class ExportService:
         Returns:
             Tuple of (file_bytes, filename, content_type)
         """
-        # Build query
+        # Build query. Soft-deleted archives (#1343) are excluded: this export
+        # is the list the user is looking at, saved to a file, and that list
+        # hides them — an export that silently contains rows the UI says are
+        # gone is worse than useless for reconciling anything (#2731).
         query = (
-            select(PrintArchive).options(selectinload(PrintArchive.project)).order_by(PrintArchive.created_at.desc())
+            select(PrintArchive)
+            .options(selectinload(PrintArchive.project))
+            .where(PrintArchive.deleted_at.is_(None))
+            .order_by(PrintArchive.created_at.desc())
         )
 
         # Apply filters

+ 200 - 25
backend/app/services/external_camera.py

@@ -8,6 +8,7 @@ to ensure they are well-formed before use.
 """
 
 import asyncio
+import functools
 import logging
 import re
 import shutil
@@ -175,6 +176,70 @@ def get_ffmpeg_path() -> str | None:
     return None
 
 
+# In-flight one-shot captures, keyed by (url, camera_type, snapshot_url) —
+# the tuple that actually identifies the physical resource being contended
+# (#2707 comment thread, following #2705's shape for the built-in path).
+#
+# V4L2 USB devices allow exactly one open handle, and is_stream_active() /
+# try_get_active_buffered_frame() (#2707) only stop a one-shot capturer from
+# competing with the fan-out live view. They do nothing for capturer-vs-
+# capturer with no viewer attached, where every consumer correctly concludes
+# it isn't competing with a viewer and then collides with the others -
+# exactly the #2705 report, just for this module's callers instead of
+# capture_camera_frame_bytes()'s (Obico polling, the in-print frame bank,
+# the finish-photo moment, plate detection, and the notification snapshot
+# all reach capture_frame() independently).
+#
+# snapshot_url is part of the key (not just url/camera_type) because it
+# routes to a completely different endpoint (#1177) - two printers that
+# share a camera_url but differ only in snapshot_url must not coalesce.
+_inflight_captures: dict[tuple[str, str, str | None], asyncio.Task[bytes | None]] = {}
+
+
+def capture_in_flight(url: str, camera_type: str, snapshot_url: str | None = None) -> bool:
+    """Return True iff a one-shot capture for this key is running right now.
+
+    Mirrors camera.py's capture_in_flight() for the built-in path - for a
+    caller that needs to know it will JOIN someone else's capture rather
+    than open its own connection. Ordinary consumers should ignore this:
+    they want "a recent frame", and capture_frame() already does the right
+    thing for them.
+    """
+    task = _inflight_captures.get((url, camera_type, snapshot_url))
+    return task is not None and not task.done()
+
+
+def _discard_inflight_capture(key: tuple[str, str, str | None], task: asyncio.Task) -> None:
+    """Done-callback: drop the finished task from the in-flight registry.
+
+    Guarded on identity so a slow task that finishes after a newer capture
+    has registered for the same key can't evict its successor.
+
+    Also retrieves the exception, if any: the leader normally awaits the
+    task and would surface it, but a leader whose own caller was cancelled
+    leaves nobody to collect it, and an unretrieved task exception is
+    logged by asyncio as a warning with a traceback at an arbitrary later
+    point otherwise.
+    """
+    if _inflight_captures.get(key) is task:
+        del _inflight_captures[key]
+    if not task.cancelled() and task.exception() is not None:
+        logger.debug("In-flight external-camera capture for %s ended in an exception", _log_key(key))
+
+
+def _log_key(key: tuple[str, str, str | None]) -> str:
+    """Render an in-flight key for a log line, with credentials redacted.
+
+    Unlike camera.py's coalescing — which is keyed by IP address and so has
+    nothing to hide — these keys carry the camera URL, and an RTSP camera URL
+    routinely embeds ``user:pass@``. Redact before truncating: slicing first
+    can cut the URL short of the ``@`` the pattern anchors on and leave the
+    password in the log, which is why every other URL log in this module does
+    it in this order.
+    """
+    return redact_url_credentials(key[0])[:50] if key[0] else "None"
+
+
 async def capture_frame(
     url: str,
     camera_type: str,
@@ -186,7 +251,10 @@ async def capture_frame(
     Args:
         url: Live-stream URL (MJPEG stream, RTSP URL, HTTP snapshot URL, or USB device path).
         camera_type: "mjpeg", "rtsp", "snapshot", or "usb".
-        timeout: Connection timeout in seconds.
+        timeout: Connection timeout in seconds. Applies to this caller's own
+            wait, including when it joins another caller's capture - call
+            sites disagree about the value, and a follower must not silently
+            inherit the leader's deadline in either direction.
         snapshot_url: Optional override for single-frame capture. When set, fetched
             via plain HTTP GET regardless of `camera_type`. Bypasses MJPEG warm-up
             handling on sources that expose a dedicated frame endpoint (e.g. go2rtc's
@@ -195,27 +263,120 @@ async def capture_frame(
 
     Returns:
         JPEG bytes or None on failure
+
+    Concurrent callers for the same (url, camera_type, snapshot_url) share
+    one capture (#2705-shape fix, filed for the external-camera path as a
+    follow-up on #2707): the first opens the connection, everyone arriving
+    while it's in flight awaits the same result. This coalesces; it does
+    not cache - a call that arrives after the previous capture finished
+    always captures fresh, since plate detection and the finish-photo path
+    judge a running print from these frames and a stale one there is worse
+    than a slow one (#1397).
     """
-    if snapshot_url:
-        # Redact before truncating — slicing first can cut the URL short of the
-        # ``@`` the pattern anchors on and leave the password in the log.
-        logger.debug("capture_frame using snapshot override url=%s...", redact_url_credentials(snapshot_url)[:50])
-        return await _capture_snapshot(snapshot_url, timeout)
-    logger.debug(
-        "capture_frame called: type=%s, url=%s...",
-        camera_type,
-        redact_url_credentials(url)[:50] if url else "None",
-    )
-    if camera_type == "mjpeg":
-        return await _capture_mjpeg_frame(url, timeout)
-    elif camera_type == "rtsp":
-        return await _capture_rtsp_frame(url, timeout)
-    elif camera_type == "snapshot":
-        return await _capture_snapshot(url, timeout)
-    elif camera_type == "usb":
-        return await _capture_usb_frame(url, timeout)
+    key = (url, camera_type, snapshot_url)
+
+    # A follower whose leader fails takes a turn of its own rather than
+    # inheriting a failure it never had a chance to avoid - by then the
+    # leader has finished, so there's no connection left to compete with.
+    # Bounded at two rounds: if the capture we joined AND its replacement
+    # both failed, a third attempt won't help, and this caller has already
+    # spent its patience.
+    for _ in range(2):
+        leader = _inflight_captures.get(key)
+        if leader is None or leader.done():
+            break
+        try:
+            frame = await asyncio.wait_for(asyncio.shield(leader), timeout=timeout)
+        except TimeoutError:
+            # shield() keeps the capture running for whoever else is still
+            # waiting on it - giving up is this caller's decision alone.
+            logger.warning(
+                "Gave up waiting %ss on the in-flight external-camera capture for %s", timeout, _log_key(key)
+            )
+            return None
+        except asyncio.CancelledError:
+            # Distinguish "the capture I joined was cancelled" from "I was
+            # cancelled". Only the former is ours to recover from.
+            if not leader.cancelled():
+                raise
+            logger.info("In-flight external-camera capture for %s was cancelled; capturing our own", _log_key(key))
+            continue
+        if frame is not None:
+            logger.debug(
+                "Reusing in-flight external-camera capture for %s: %d bytes (no second connection opened)",
+                _log_key(key),
+                len(frame),
+            )
+            return frame
+        logger.debug("In-flight external-camera capture for %s failed; capturing our own", _log_key(key))
     else:
-        logger.warning("Unknown camera type: %s", camera_type)
+        return None
+
+    task = asyncio.create_task(_capture_frame_uncoalesced(url, camera_type, timeout, snapshot_url))
+    _inflight_captures[key] = task
+    task.add_done_callback(functools.partial(_discard_inflight_capture, key))
+    # No wait_for here: this caller IS the capture, and each dispatched
+    # _capture_* function already enforces `timeout` internally, where it
+    # can also kill the ffmpeg process - a second deadline on top would
+    # abandon the subprocess instead of killing it. shield() so a cancelled
+    # leader (a client navigating away mid-request is routine) doesn't take
+    # the capture down with it - followers already waiting on it still get
+    # their frame.
+    return await asyncio.shield(task)
+
+
+async def _capture_frame_uncoalesced(
+    url: str,
+    camera_type: str,
+    timeout: int,
+    snapshot_url: str | None,
+) -> bytes | None:
+    """Open a connection and capture one frame. See capture_frame().
+
+    Callers want that wrapper, not this: it opens a connection
+    unconditionally, which is the collision #2705/#2707 are about.
+
+    Failure is reported as ``None``, never as an exception. That is load-
+    bearing now that captures are shared: the coalescing wrapper hands one
+    task's outcome to every caller waiting on it, and it can only give a
+    follower its own turn for an outcome it can recognise. An exception
+    escaping here would instead propagate to every follower at once —
+    turning one caller's failure into N — and none of them would retry.
+    The per-type helpers below each catch what they expect and return None,
+    but they catch narrowly (``aiohttp.ClientError``/``OSError``/timeouts),
+    so this is the structural guarantee rather than one contingent on their
+    coverage. Mirrors ``_capture_camera_frame_bytes_uncoalesced`` in
+    camera.py, which ends in the same blanket catch for the same reason.
+    """
+    try:
+        if snapshot_url:
+            # Redact before truncating — slicing first can cut the URL short of the
+            # ``@`` the pattern anchors on and leave the password in the log.
+            logger.debug("capture_frame using snapshot override url=%s...", redact_url_credentials(snapshot_url)[:50])
+            return await _capture_snapshot(snapshot_url, timeout)
+        logger.debug(
+            "capture_frame called: type=%s, url=%s...",
+            camera_type,
+            redact_url_credentials(url)[:50] if url else "None",
+        )
+        if camera_type == "mjpeg":
+            return await _capture_mjpeg_frame(url, timeout)
+        elif camera_type == "rtsp":
+            return await _capture_rtsp_frame(url, timeout)
+        elif camera_type == "snapshot":
+            return await _capture_snapshot(url, timeout)
+        elif camera_type == "usb":
+            return await _capture_usb_frame(url, timeout)
+        else:
+            logger.warning("Unknown camera type: %s", camera_type)
+            return None
+    except asyncio.CancelledError:
+        # Cancellation is not a capture failure and must stay distinguishable:
+        # the wrapper checks ``leader.cancelled()`` to decide whether a
+        # follower may take its own turn.
+        raise
+    except Exception:
+        logger.exception("External camera capture failed for %s", redact_url_credentials(url)[:50] if url else "None")
         return None
 
 
@@ -566,12 +727,26 @@ async def test_connection(url: str, camera_type: str) -> dict:
     """Test camera connection.
 
     Returns:
-        Dict with {success: bool, error?: str, resolution?: str}
+        Dict with {success: bool, error?: str, resolution?: str, coalesced: bool}
+
+    ``coalesced`` is True when the frame came from a capture that was already
+    running rather than from a connection this test opened. Captures are shared
+    (see ``capture_frame``), so a test that lands while Obico is polling — or
+    while any other one-shot consumer is mid-capture — gets that frame back and
+    would otherwise report a healthy connection it never made, which is the one
+    answer a *connection test* must not give silently. Forcing an uncoalesced
+    capture here would be worse: it would open the second handle to a
+    single-reader device that this whole mechanism exists to prevent. So the
+    test still shares, and says so. Mirrors the ``coalesced_capture`` code the
+    built-in diagnostic reports for the same situation (camera_diagnose.py).
     """
     logger.info("Testing camera connection: type=%s, url=%s...", camera_type, redact_url_credentials(url)[:50])
+    # Sampled before the call, while it can still distinguish "someone else is
+    # mid-capture" from "I am the one capturing".
+    coalesced = capture_in_flight(url, camera_type)
     try:
         frame = await capture_frame(url, camera_type, timeout=10)
-        logger.info("Capture result: %s bytes", len(frame) if frame else 0)
+        logger.info("Capture result: %s bytes%s", len(frame) if frame else 0, " (coalesced)" if coalesced else "")
 
         if frame:
             # Try to get resolution from JPEG header
@@ -590,15 +765,15 @@ async def test_connection(url: str, camera_type: str) -> dict:
             except (IndexError, ValueError):
                 pass  # Resolution detection is optional; fall back to default
 
-            return {"success": True, "resolution": resolution}
+            return {"success": True, "resolution": resolution, "coalesced": coalesced}
         else:
-            return {"success": False, "error": "Failed to capture frame from camera"}
+            return {"success": False, "error": "Failed to capture frame from camera", "coalesced": coalesced}
 
     except Exception as e:
         # Sanitize error message - don't expose internal details
         error_type = type(e).__name__
         logger.error("Camera connection test failed: %s", e)
-        return {"success": False, "error": f"Connection failed: {error_type}"}
+        return {"success": False, "error": f"Connection failed: {error_type}", "coalesced": coalesced}
 
 
 async def generate_mjpeg_stream(

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

@@ -55,8 +55,15 @@ class FailureAnalysisService:
         if project_id:
             from backend.app.models.archive import PrintArchive
 
+            # Soft-deleted archives (#1343) keep their project_id, so without
+            # this the failure rate for a project still counts prints the user
+            # deleted from it — and disagrees with the project's own numbers,
+            # which now exclude them (#2731).
             project_archive_ids = await self.db.execute(
-                select(PrintArchive.id).where(PrintArchive.project_id == project_id)
+                select(PrintArchive.id).where(
+                    PrintArchive.project_id == project_id,
+                    PrintArchive.deleted_at.is_(None),
+                )
             )
             archive_ids = [row[0] for row in project_archive_ids.fetchall()]
             if archive_ids:

+ 335 - 58
backend/app/services/github_backup.py

@@ -8,7 +8,7 @@ import logging
 from datetime import datetime, timedelta, timezone
 
 import httpx
-from sqlalchemy import desc, select
+from sqlalchemy import desc, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.database import async_session
@@ -18,11 +18,61 @@ from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.spool import Spool
 from backend.app.models.spool_usage_history import SpoolUsageHistory
+from backend.app.models.user import User
 from backend.app.services.git_providers.factory import get_provider_backend
 from backend.app.services.printer_manager import printer_manager
 
 logger = logging.getLogger(__name__)
 
+# Bambu's listing endpoint is keyed by preset type and calls process presets
+# "print". Same mapping as `routes/cloud.py` — kept in step with it, since a
+# divergence here silently drops a whole preset type from every backup.
+_BAMBU_PRESET_TYPES = {
+    "filament": "filament",
+    "printer": "printer",
+    "print": "process",
+}
+
+
+def _bambu_preset_record(setting_id, our_type: str, entry: dict, detail: dict) -> dict:
+    """One Bambu preset as stored in the backup: metadata plus the payload.
+
+    ``base_id`` and ``setting`` are the two fields ``BambuCloudService.
+    create_setting`` needs, so a restore can rebuild the preset rather than
+    just list it.
+
+    ``user_id`` from the listing is deliberately dropped. It identifies the
+    account and adds nothing to a rebuild, and backup repositories can be
+    public.
+    """
+    return {
+        "setting_id": str(setting_id),
+        "name": detail.get("name") or entry.get("name") or "Unknown",
+        "type": our_type,
+        "version": detail.get("version") or entry.get("version"),
+        "updated_time": entry.get("updated_time"),
+        "base_id": detail.get("base_id"),
+        "filament_id": detail.get("filament_id"),
+        "setting": detail.get("setting") or {},
+    }
+
+
+def _orca_profile_record(entry: dict) -> dict:
+    """One Orca profile as stored in the backup.
+
+    ``content`` is kept whole rather than picked apart: it is the profile, the
+    sync API hands it over inline, and Orca owns its shape. Narrowing it here
+    would mean guessing which keys a future restore needs.
+    """
+    return {
+        "id": str(entry.get("id")) if entry.get("id") is not None else None,
+        "name": entry.get("name"),
+        "updated_time": entry.get("updated_time"),
+        "created_time": entry.get("created_time"),
+        "content": entry.get("content"),
+    }
+
+
 # Schedule intervals in seconds
 SCHEDULE_INTERVALS = {
     "hourly": 3600,
@@ -279,11 +329,13 @@ class GitHubBackupService:
         {
             "backup_metadata.json": {...},
             "kprofiles/{serial}/{nozzle}.json": {...},
-            "cloud_profiles/filament.json": [...],
-            "cloud_profiles/printer.json": [...],
-            "cloud_profiles/process.json": [...],
+            "cloud_profiles/bambu/{account}/{filament,printer,process}.json": {...},
+            "cloud_profiles/orca/{account}/{filament,printer,process}.json": {...},
             "settings/app_settings.json": {...},
         }
+
+        ``{account}`` is ``global`` when auth is disabled, otherwise
+        ``user-{id}`` — one directory per connected cloud account (#2717).
         """
         files: dict[str, dict | list] = {}
 
@@ -306,10 +358,20 @@ class GitHubBackupService:
             self._backup_progress = "Collecting K-profiles from printers..."
             await self._collect_kprofiles(db, files)
 
-        # Collect cloud profiles
+        # Collect cloud profiles. `contents.cloud_profiles` is corrected below
+        # from what was configured to what was actually written — it claimed
+        # `true` on every backup, including the ones that collected nothing
+        # (#2717), which is exactly the signal a restore needs to be able to
+        # trust.
         if config.backup_cloud_profiles:
-            self._backup_progress = "Collecting cloud profiles from Bambu Cloud..."
-            await self._collect_cloud_profiles(db, files)
+            self._backup_progress = "Collecting cloud profiles from Bambu Cloud and Orca Cloud..."
+            cloud_summary = await self._collect_cloud_profiles(db, files)
+            collected = bool(cloud_summary.get("bambu") or cloud_summary.get("orca"))
+            metadata["contents"]["cloud_profiles"] = collected
+            if collected:
+                # Per-cloud, per-account counts, so a restore can tell an empty
+                # account from one that failed to collect.
+                metadata["cloud_profiles"] = cloud_summary
 
         # Collect app settings
         if config.backup_settings:
@@ -374,68 +436,283 @@ class GitHubBackupService:
             if printer_profiles:
                 logger.info("Collected K-profiles for %s: %s", serial, printer_profiles)
 
-    async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
-        """Collect Bambu Cloud profiles if authenticated."""
-        # Backup runs without a user context, so fall back to the auth-disabled
-        # Settings storage. ``build_authenticated_cloud`` honours the stored
-        # region so China-region tokens are validated against api.bambulab.cn.
+    async def _collect_cloud_profiles(self, db: AsyncSession, files: dict) -> dict:
+        """Collect slicer presets from every connected cloud account.
+
+        Two clouds, and on an auth-enabled install any number of accounts in
+        each: Bambu Cloud tokens live on ``User.cloud_token`` and Orca Cloud
+        tokens on ``User.orca_cloud_token``, falling back to the global
+        ``Settings`` table only when auth is disabled. The previous version
+        asked for the auth-disabled store unconditionally, so it collected
+        nothing at all on any install with auth on (#2717).
+
+        Layout is one directory per cloud per account, both clouds grouped the
+        same way so a restore reads them identically::
+
+            cloud_profiles/bambu/user-3/{filament,printer,process}.json
+            cloud_profiles/orca/user-3/{filament,printer,process}.json
+
+        Accounts are keyed by Bambuddy user id (``global`` when auth is off),
+        never by email — a backup repository can be public.
+
+        Returns a per-cloud summary for ``backup_metadata.json`` so the
+        metadata records what was actually collected rather than what was
+        merely enabled.
+        """
+        summary: dict = {"bambu": {}, "orca": {}}
+
+        bambu_accounts, orca_accounts = await self.cloud_accounts(db)
+        if not bambu_accounts and not orca_accounts:
+            # Enabled but nothing to collect. Deliberately a warning: the INFO
+            # line this replaces read as a successful collection of nothing,
+            # which is how #2717 went unnoticed through every backup.
+            logger.warning(
+                "Cloud profiles are enabled for backup, but no Bambu Cloud or Orca Cloud "
+                "account is connected — nothing to collect."
+            )
+            return summary
+
+        for account_key, user in bambu_accounts:
+            try:
+                counts = await self._collect_bambu_profiles(db, files, account_key, user)
+            except Exception:
+                logger.warning("Failed to collect Bambu Cloud profiles for %s", account_key, exc_info=True)
+                continue
+            if counts:
+                summary["bambu"][account_key] = counts
+
+        for account_key, user in orca_accounts:
+            try:
+                counts = await self._collect_orca_profiles(db, files, account_key, user)
+            except Exception:
+                logger.warning("Failed to collect Orca Cloud profiles for %s", account_key, exc_info=True)
+                continue
+            if counts:
+                summary["orca"][account_key] = counts
+
+        if not summary["bambu"] and not summary["orca"]:
+            logger.warning(
+                "Cloud profiles are enabled and %d Bambu / %d Orca account(s) are connected, "
+                "but no presets were collected — see the per-account warnings above.",
+                len(bambu_accounts),
+                len(orca_accounts),
+            )
+        else:
+            logger.info("Collected cloud profiles: %s", summary)
+        return summary
+
+    async def cloud_accounts(self, db: AsyncSession) -> tuple[list, list]:
+        """Enumerate connected accounts as ``(account_key, user_or_None)`` per cloud.
+
+        With auth enabled every user holds their own credentials, so a backup
+        that only looked at the global store saw none of them. With auth
+        disabled there is a single global row and no ``User`` at all, which is
+        what ``user=None`` means to both clouds' credential loaders.
+
+        Both stores are read regardless: a ``Settings`` row survives enabling
+        auth later, and dropping it silently would lose that account's presets.
+        """
+        from backend.app.api.routes.cloud import get_stored_token
+        from backend.app.api.routes.orca_cloud import _load_credentials
+
+        bambu: list = []
+        orca: list = []
+
+        global_token, _email, _region = await get_stored_token(db, None)
+        if global_token:
+            bambu.append(("global", None))
+        global_orca = await _load_credentials(db, None)
+        if global_orca.token:
+            orca.append(("global", None))
+
+        result = await db.execute(
+            select(User).where(or_(User.cloud_token.isnot(None), User.orca_cloud_token.isnot(None)))
+        )
+        for user in result.scalars().all():
+            if user.cloud_token:
+                bambu.append((f"user-{user.id}", user))
+            if user.orca_cloud_token:
+                orca.append((f"user-{user.id}", user))
+
+        return bambu, orca
+
+    async def _collect_bambu_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict:
+        """Collect one Bambu Cloud account's custom presets, with their payloads.
+
+        The listing endpoint is keyed by preset type, each holding ``private``
+        and ``public`` lists — there is no flat ``setting`` array, and the
+        entries carry no ``type`` of their own, which is why the type comes
+        from the outer key here exactly as it does in ``routes/cloud.py``.
+        Bambu calls process presets ``print``.
+
+        ``public`` is skipped: those are Bambu's own bundled catalogue, the
+        same hundreds of entries for every user, re-downloadable at any time
+        and not recreatable under your account anyway. Backing them up would
+        churn the repository on every run for nothing.
+
+        Each private preset then costs one ``get_setting_detail`` call, because
+        the listing carries only metadata. Without ``base_id`` and ``setting``
+        the backup is a list of names, not something a restore can rebuild
+        from. Bounded by the number of *custom* presets, and the backup already
+        makes a round-trip per printer for K-profiles.
+        """
         from backend.app.api.routes.cloud import build_authenticated_cloud
 
-        cloud = await build_authenticated_cloud(db, user=None)
+        cloud = await build_authenticated_cloud(db, user=user)
         if cloud is None or not cloud.is_authenticated:
-            if cloud is not None:
-                await cloud.close()
-            logger.info("Cloud not authenticated, skipping cloud profiles")
-            return
+            logger.info("Bambu Cloud not authenticated for %s, skipping", account_key)
+            return {}
 
+        counts: dict = {}
         try:
             settings = await cloud.get_slicer_settings()
-            if not settings:
-                return
-
-            # Separate by type
-            filament_settings = []
-            printer_settings = []
-            process_settings = []
-
-            for setting in settings.get("setting", []) if isinstance(settings.get("setting"), list) else []:
-                setting_type = setting.get("type", "")
-                if setting_type == "filament":
-                    filament_settings.append(setting)
-                elif setting_type == "printer":
-                    printer_settings.append(setting)
-                elif setting_type == "process":
-                    process_settings.append(setting)
-
-            if filament_settings:
-                files["cloud_profiles/filament.json"] = {
-                    "version": "1.0",
-                    "profiles": filament_settings,
-                }
+            if not isinstance(settings, dict) or not settings:
+                logger.warning("Bambu Cloud returned no slicer settings for %s", account_key)
+                return {}
+
+            failed = 0
+            for api_key, our_type in _BAMBU_PRESET_TYPES.items():
+                type_data = settings.get(api_key)
+                if not isinstance(type_data, dict):
+                    continue
+                private = type_data.get("private")
+                if not isinstance(private, list) or not private:
+                    continue
+
+                profiles = []
+                for entry in private:
+                    setting_id = entry.get("setting_id") or entry.get("id")
+                    if not setting_id:
+                        continue
+                    try:
+                        detail = await cloud.get_setting_detail(str(setting_id))
+                    except Exception as e:
+                        # One unreadable preset must not cost the rest of the
+                        # account, but it must not vanish quietly either.
+                        failed += 1
+                        logger.warning(
+                            "Failed to fetch Bambu Cloud preset %s (%s) for %s: %s",
+                            setting_id,
+                            entry.get("name", "unnamed"),
+                            account_key,
+                            e,
+                        )
+                        continue
+                    profiles.append(_bambu_preset_record(setting_id, our_type, entry, detail))
+
+                if profiles:
+                    files[f"cloud_profiles/bambu/{account_key}/{our_type}.json"] = {
+                        "version": "2.0",
+                        "cloud": "bambu",
+                        "type": our_type,
+                        "profiles": profiles,
+                    }
+                    counts[our_type] = len(profiles)
 
-            if printer_settings:
-                files["cloud_profiles/printer.json"] = {
-                    "version": "1.0",
-                    "profiles": printer_settings,
-                }
+            if failed:
+                counts["failed"] = failed
+            return counts
+        finally:
+            await cloud.close()
 
-            if process_settings:
-                files["cloud_profiles/process.json"] = {
-                    "version": "1.0",
-                    "profiles": process_settings,
-                }
+    async def _collect_orca_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict:
+        """Collect one Orca Cloud account's profiles, grouped the same three ways.
+
+        Cheaper than Bambu: the sync-pull listing already carries each
+        profile's full ``content``, so there is no per-profile fetch.
+
+        The type lives at ``content.type`` and is mapped through the same
+        ``_ORCA_TYPE_TO_BAMBU`` table the Orca tab uses, so the backup groups
+        exactly as the UI does. Where that route *drops* a profile whose type
+        it can't map, this writes it to ``other.json`` instead — a backup that
+        silently omits a profile because Orca added a type is the same class of
+        bug as #2717 itself.
+
+        Uses the route layer's ``_build_authenticated_service`` rather than
+        re-implementing the refresh: the Orca refresh token is single-use and
+        rotating, and that helper already persists the new pair atomically
+        before returning.
+
+        Passes ``clear_on_auth_failure=False``, so a rejected refresh skips the
+        account instead of disconnecting it. A backup is an observer; it should
+        not change anyone's sign-in state on a schedule, least of all on a
+        rejection reason Orca does not disambiguate. The next time the user
+        opens the Orca Profiles page that route clears the dead pairing anyway,
+        with the user present to pair again.
+        """
+        from fastapi import HTTPException
 
-            logger.info(
-                "Collected cloud profiles: %d filament, %d printer, %d process",
-                len(filament_settings),
-                len(printer_settings),
-                len(process_settings),
-            )
+        from backend.app.api.routes.orca_cloud import (
+            _ORCA_TYPE_TO_BAMBU,
+            _build_authenticated_service,
+        )
+
+        try:
+            svc = await _build_authenticated_service(db, user, clear_on_auth_failure=False)
+        except HTTPException as e:
+            # Either way the stored credentials are untouched and this account
+            # is skipped, not disconnected — but the two need different advice.
+            # A rejected refresh will not fix itself and needs the user to pair
+            # again; an unreachable Orca is very likely gone by the next run.
+            if e.status_code == 401:
+                logger.warning(
+                    "Orca Cloud rejected the stored session for %s, so its profiles are not in this "
+                    "backup. Later runs will skip it too until the account is paired again under "
+                    "Profiles > Orca Cloud Profiles — which is also where the dead credentials get "
+                    "cleared. Cause: %s",
+                    account_key,
+                    e.detail,
+                )
+            else:
+                logger.warning(
+                    "Orca Cloud unreachable for %s, skipping its profiles this run: %s",
+                    account_key,
+                    e.detail,
+                )
+            return {}
+        except Exception as e:
+            logger.warning("Orca Cloud not usable for %s: %s", account_key, e, exc_info=True)
+            return {}
 
-        except Exception:
-            logger.warning("Failed to collect cloud profiles", exc_info=True)
+        counts: dict = {}
+        try:
+            raw_profiles = await svc.list_profiles()
+            grouped: dict[str, list] = {}
+            unknown_types: dict[str, int] = {}
+
+            for entry in raw_profiles:
+                if not isinstance(entry, dict):
+                    continue
+                content = entry.get("content")
+                raw_type = content.get("type") if isinstance(content, dict) else None
+                our_type = _ORCA_TYPE_TO_BAMBU.get(str(raw_type)) if raw_type is not None else None
+                if our_type is None:
+                    unknown_types[str(raw_type) if raw_type is not None else "<missing>"] = (
+                        unknown_types.get(str(raw_type) if raw_type is not None else "<missing>", 0) + 1
+                    )
+                    our_type = "other"
+                grouped.setdefault(our_type, []).append(_orca_profile_record(entry))
+
+            for our_type, profiles in grouped.items():
+                files[f"cloud_profiles/orca/{account_key}/{our_type}.json"] = {
+                    "version": "2.0",
+                    "cloud": "orca",
+                    "type": our_type,
+                    "profiles": profiles,
+                }
+                counts[our_type] = len(profiles)
+
+            if unknown_types:
+                logger.warning(
+                    "Orca Cloud sent %d profile(s) for %s with unmapped content.type values %s — "
+                    "backed up to other.json rather than dropped.",
+                    sum(unknown_types.values()),
+                    account_key,
+                    unknown_types,
+                )
+            return counts
         finally:
-            await cloud.close()
+            await svc.close()
 
     async def _collect_settings(self, db: AsyncSession, files: dict):
         """Collect app settings."""

+ 115 - 0
backend/app/services/layer_timelapse.py

@@ -6,11 +6,13 @@ Captures a frame on each layer change and stitches them into a video on print co
 import asyncio
 import logging
 import shutil
+import time
 from dataclasses import dataclass, field
 from datetime import datetime
 from pathlib import Path
 
 from backend.app.core.config import settings
+from backend.app.services.camera import apply_camera_rotation
 from backend.app.services.external_camera import capture_frame
 
 logger = logging.getLogger(__name__)
@@ -18,6 +20,15 @@ logger = logging.getLogger(__name__)
 # Active timelapse sessions: {printer_id: TimelapseSession}
 _active_sessions: dict[int, "TimelapseSession"] = {}
 
+# Sessions whose frames are being stitched right now: {printer_id: session_id}.
+# on_print_complete removes the session from _active_sessions *before* handing
+# frames_dir to ffmpeg, so for the length of a stitch (up to 300s) nothing in
+# _active_sessions marks that directory as in use. Without this second registry
+# the only thing standing between an in-progress stitch and
+# cleanup_orphaned_timelapse_sessions() is the age margin — whose default is
+# exactly the stitch timeout, so there is no headroom at all.
+_finalizing_sessions: dict[int, str] = {}
+
 
 def get_ffmpeg_path() -> str | None:
     """Get the path to ffmpeg executable."""
@@ -41,6 +52,7 @@ class TimelapseSession:
     camera_url: str
     camera_type: str
     snapshot_url: str | None = None  # Optional single-frame override; #1177
+    rotation: int = 0  # Printer's configured camera_rotation, degrees clockwise
     last_layer: int = -1
     frame_count: int = 0
     session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
@@ -88,6 +100,8 @@ class TimelapseSession:
             else:
                 frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
             if frame_data:
+                if self.rotation:
+                    frame_data = await asyncio.to_thread(apply_camera_rotation, frame_data, self.rotation, logger)
                 frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
                 await asyncio.to_thread(frame_path.write_bytes, frame_data)
                 self.frame_count += 1
@@ -206,6 +220,7 @@ def start_session(
     url: str,
     cam_type: str,
     snapshot_url: str | None = None,
+    rotation: int = 0,
 ) -> TimelapseSession:
     """Start new timelapse session for a printer.
 
@@ -216,6 +231,8 @@ def start_session(
         cam_type: Camera type ("mjpeg", "rtsp", "snapshot")
         snapshot_url: Optional single-frame URL override; when set, layer captures
             fetch from it directly instead of opening the live stream. #1177.
+        rotation: Printer's configured camera_rotation (degrees clockwise),
+            applied to every captured frame before it's saved.
 
     Returns:
         The new TimelapseSession
@@ -229,6 +246,7 @@ def start_session(
         camera_url=url,
         camera_type=cam_type,
         snapshot_url=snapshot_url,
+        rotation=rotation,
     )
     _active_sessions[printer_id] = session
     logger.info("Started timelapse session for printer %s", printer_id)
@@ -273,6 +291,12 @@ async def on_print_complete(printer_id: int) -> Path | None:
     # Create output path in parent of frames dir
     output_path = session.frames_dir.parent / f"timelapse_{session.session_id}.mp4"
 
+    # The session is already out of _active_sessions, so mark it finalizing for
+    # the length of the stitch — otherwise a sweep running now sees a frames
+    # directory that matches no session and whose mtime is the last layer's
+    # write, which on a tall print's final layer is easily older than the age
+    # margin, and deletes ffmpeg's input from under it.
+    _finalizing_sessions[printer_id] = session.session_id
     try:
         success = await session.stitch(output_path)
         if success:
@@ -286,6 +310,8 @@ async def on_print_complete(printer_id: int) -> Path | None:
         logger.error("Timelapse completion failed: %s", e)
         session.cleanup()
         return None
+    finally:
+        _finalizing_sessions.pop(printer_id, None)
 
 
 def cancel_session(printer_id: int):
@@ -303,3 +329,92 @@ def cancel_session(printer_id: int):
 def get_active_sessions() -> dict[int, TimelapseSession]:
     """Get all active timelapse sessions."""
     return _active_sessions.copy()
+
+
+def cleanup_orphaned_timelapse_sessions(min_age_seconds: float = 300) -> int:
+    """Remove timelapse_frames/<printer_id>/* left behind by a crash or
+    restart that happened while a session was active.
+
+    _active_sessions is in-memory only, so a process restart loses track of
+    any in-flight session without ever calling cancel_session()/cleanup() -
+    the frames directory (and, if stitching had already produced output
+    before the restart, a stray `timelapse_<session_id>.mp4`) are then
+    orphaned on disk with nothing else to reap them (unlike the ffmpeg
+    orphan janitor in routes/camera.py, there was no equivalent here).
+
+    Safe to call once at startup: normal operation always cleans up via
+    on_print_complete/cancel_session, so anything found here predates this
+    process - and a restart-recovered print doesn't get a new timelapse
+    session either (`_maybe_start_layer_timelapse` is only wired into fresh
+    PRINT_START events, see #1353), so an orphaned directory can never be
+    resumed.
+
+    Also safe to call mid-run, which needs all three guards rather than the
+    age margin alone:
+
+    * `_active_sessions` covers a session that is still capturing.
+    * `_finalizing_sessions` covers the stitch window. on_print_complete drops
+      the session from `_active_sessions` before handing frames_dir to ffmpeg,
+      so without this the directory matches no session for up to 300s while
+      being actively read.
+    * `min_age_seconds` covers the remaining gap - a session in the middle of
+      being created, and the stitched `.mp4` between ffmpeg finishing it and
+      the caller attaching and unlinking it. Both are freshly written, so the
+      margin has real headroom there; it did NOT have any for the stitch
+      window, whose length is bounded by the same 300s.
+
+    Returns the number of orphaned directories/files removed.
+    """
+    base_dir = settings.base_dir / "timelapse_frames"
+    if not base_dir.exists():
+        return 0
+
+    now = time.time()
+    removed = 0
+    for printer_dir in base_dir.iterdir():
+        if not printer_dir.is_dir():
+            continue
+        try:
+            printer_id = int(printer_dir.name)
+        except ValueError:
+            continue
+
+        active_session = _active_sessions.get(printer_id)
+        in_use_session_ids = {
+            active_session.session_id if active_session else None,
+            _finalizing_sessions.get(printer_id),
+        } - {None}
+
+        for entry in printer_dir.iterdir():
+            # Frame dirs are named "<session_id>/"; stitched-but-not-yet-
+            # attached output files are "timelapse_<session_id>.mp4" (see
+            # on_print_complete's output_path). Anything else under here was
+            # not written by this module, so leave it alone rather than
+            # deleting a file on the strength of its age.
+            if entry.is_dir():
+                entry_session_id = entry.name
+            elif entry.name.startswith("timelapse_") and entry.name.endswith(".mp4"):
+                entry_session_id = entry.name[len("timelapse_") : -len(".mp4")]
+            else:
+                continue
+            if entry_session_id in in_use_session_ids:
+                continue
+            try:
+                if now - entry.stat().st_mtime < min_age_seconds:
+                    continue
+            except OSError:
+                continue
+            try:
+                # No ignore_errors: it would swallow a failed removal while the
+                # count and the log line below still claimed success, and that
+                # log is the only evidence an operator has of what was deleted.
+                if entry.is_dir():
+                    shutil.rmtree(entry)
+                else:
+                    entry.unlink(missing_ok=True)
+                removed += 1
+                logger.info("Removed orphaned timelapse artifact: %s", entry)
+            except OSError as e:
+                logger.warning("Failed to remove orphaned timelapse artifact %s: %s", entry, e)
+
+    return removed

+ 87 - 12
backend/app/services/obico_detection.py

@@ -44,6 +44,19 @@ _frame_cache: dict[str, tuple[bytes, float]] = {}
 _frame_cache_lock = asyncio.Lock()
 
 
+def auth_headers(token: str | None) -> dict[str, str]:
+    """Bearer header for the ML API, or nothing when no token is configured.
+
+    Obico's ML API gates ``/p/`` behind ``ML_API_TOKEN`` (``ml_api/auth.py``):
+    with the variable set it answers a bare 401 to any request whose
+    ``Authorization`` header isn't ``Bearer <token>``, and with it unset it
+    ignores the header entirely. Sending nothing when unconfigured keeps the
+    request byte-identical to what shipped before the setting existed.
+    """
+    token = (token or "").strip()
+    return {"Authorization": f"Bearer {token}"} if token else {}
+
+
 def _prune_frame_cache() -> None:
     """Drop entries older than FRAME_CACHE_TTL. Called under the cache lock."""
     now = time.monotonic()
@@ -111,6 +124,7 @@ class ObicoDetectionService:
         keys = [
             "obico_enabled",
             "obico_ml_url",
+            "obico_ml_token",
             "obico_sensitivity",
             "obico_action",
             "obico_poll_interval",
@@ -133,6 +147,7 @@ class ObicoDetectionService:
         return {
             "enabled": rows.get("obico_enabled", "false").lower() == "true",
             "ml_url": (rows.get("obico_ml_url") or "").rstrip("/"),
+            "ml_token": (rows.get("obico_ml_token") or "").strip(),
             "sensitivity": rows.get("obico_sensitivity", "medium"),
             "action": rows.get("obico_action", "notify"),
             "poll_interval": int(rows.get("obico_poll_interval", "10")),
@@ -279,7 +294,23 @@ class ObicoDetectionService:
 
         try:
             async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client:
-                resp = await client.get(ml_url, params={"img": snapshot_url})
+                resp = await client.get(
+                    ml_url,
+                    params={"img": snapshot_url},
+                    headers=auth_headers(settings.get("ml_token")),
+                )
+                if resp.status_code == 401:
+                    # The server runs with ML_API_TOKEN set and rejected ours.
+                    # Say so plainly: the health endpoint is ungated, so "Test
+                    # Connection" passes against exactly this configuration and
+                    # a raw 401 gives the user nothing to act on (#2733).
+                    self._last_error = (
+                        "Obico ML API rejected the token (401). Set Settings → Failure Detection → "
+                        "ML API Token to the ML_API_TOKEN the server runs with, or clear ML_API_TOKEN "
+                        "on the server."
+                    )
+                    logger.warning("%s (printer %s)", self._last_error, printer_id)
+                    return
                 resp.raise_for_status()
                 payload = resp.json()
         except Exception as e:
@@ -364,8 +395,8 @@ class ObicoDetectionService:
             "history": list(self._history),
         }
 
-    async def test_connection(self, url: str) -> dict:
-        """Ping the ML API health endpoint. Returns {ok, status_code, body, error}.
+    async def test_connection(self, url: str, token: str = "") -> dict:
+        """Ping the ML API and check the token. Returns {ok, status_code, body, error, auth_ok}.
 
         The stored ``obico_ml_url`` setting is validated at the schema layer,
         but this route takes its URL from the request body, so the same
@@ -374,27 +405,71 @@ class ObicoDetectionService:
         is returned to the caller (it is the health signal — the endpoint
         answers "ok"), which is exactly why the destination must be inside
         policy before the request is made.
+
+        ``token`` is used verbatim — resolving "not supplied" to the saved
+        setting is the route's job, so this stays a pure outbound call.
+
+        Health alone cannot answer whether the token works, because Obico
+        gates ``/p/`` but leaves ``/hc/`` open — which is how a token-protected
+        server passed this test while every detection call came back 401
+        (#2733). So a second, side-effect-free probe follows: ``/p/`` with no
+        ``img`` parameter. The auth decorator runs before the handler, so 401
+        means the token was rejected and 422 ("Invalid request params") means
+        it was accepted. No inference work is done either way.
         """
         from backend.app.api.routes._url_safety import assert_safe_lan_service_url
 
         try:
             assert_safe_lan_service_url(url, label="Obico ML URL")
         except ValueError as exc:
-            return {"ok": False, "status_code": None, "body": None, "error": str(exc)}
+            return {"ok": False, "status_code": None, "body": None, "error": str(exc), "auth_ok": None}
 
-        target = f"{url.rstrip('/')}/hc/"
+        headers = auth_headers(token)
+
+        base = url.rstrip("/")
         try:
             async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client:
-                resp = await client.get(target)
-            body = resp.text.strip()
+                resp = await client.get(f"{base}/hc/", headers=headers)
+                body = resp.text.strip()
+                healthy = resp.status_code == 200 and body.lower() == "ok"
+                if not healthy:
+                    return {
+                        "ok": False,
+                        "status_code": resp.status_code,
+                        "body": body,
+                        "error": None,
+                        "auth_ok": None,
+                    }
+
+                auth_ok: bool | None
+                try:
+                    probe = await client.get(f"{base}/p/", headers=headers)
+                    auth_ok = probe.status_code != 401
+                except Exception:
+                    # The health check already succeeded, so don't fail the
+                    # whole test on the probe — report the token as unknown.
+                    auth_ok = None
+        except Exception as e:
+            return {
+                "ok": False,
+                "status_code": None,
+                "body": None,
+                "error": str(e) or type(e).__name__,
+                "auth_ok": None,
+            }
+
+        if auth_ok is False:
             return {
-                "ok": resp.status_code == 200 and body.lower() == "ok",
-                "status_code": resp.status_code,
+                "ok": False,
+                "status_code": 401,
                 "body": body,
-                "error": None,
+                "error": (
+                    "The ML API is reachable but rejected the token. It runs with ML_API_TOKEN set — "
+                    "enter that value as the ML API Token, or clear ML_API_TOKEN on the server."
+                ),
+                "auth_ok": False,
             }
-        except Exception as e:
-            return {"ok": False, "status_code": None, "body": None, "error": str(e) or type(e).__name__}
+        return {"ok": True, "status_code": resp.status_code, "body": body, "error": None, "auth_ok": auth_ok}
 
 
 obico_detection_service = ObicoDetectionService()

+ 68 - 0
backend/app/services/print_dispatch_context.py

@@ -0,0 +1,68 @@
+"""Whether Bambuddy injected End G-code into the print now running (#2547).
+
+The finish-photo path has to know one thing at print completion that no MQTT
+field reports: did this print end with user End G-code? If it did, a SwapMod
+snippet may already have ejected the plate, so the scene in front of the camera
+at ``gcode_state=FINISH`` is not the finished print and the photo must come from
+the in-print frame bank instead (#1867).
+
+Only the dispatcher ever sees this, so it is recorded here in two steps:
+
+1. ``mark_pending`` when the scheduler injects an End G-code snippet.
+2. ``adopt`` when the printer reports a print starting, which moves the pending
+   flag onto the running print and consumes it.
+
+The two steps exist so the flag can never outlive its print. A print Bambuddy
+did not dispatch — started from the slicer, the SD card, or the printer's own
+screen — finds no pending flag and correctly adopts ``False``, instead of
+inheriting the answer from whatever ran before it.
+
+In-memory and best-effort: a restart mid-print loses the flag, and ``False`` is
+the safe way to be wrong (a live grab that might show a swapped plate, rather
+than silently substituting a mid-print frame).
+"""
+
+from __future__ import annotations
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+# Printers the scheduler has injected End G-code for, awaiting a print start.
+_pending: set[int] = set()
+# Printers whose *currently running* print has injected End G-code.
+_active: set[int] = set()
+
+
+def mark_pending(printer_id: int) -> None:
+    """Record that the job now being sent to ``printer_id`` has End G-code."""
+    _pending.add(printer_id)
+    logger.debug("[DISPATCH-CTX] printer %s: End G-code injected, awaiting print start", printer_id)
+
+
+def adopt(printer_id: int) -> bool:
+    """Bind any pending flag to the print that just started, and return it.
+
+    Called once per print start. Always writes ``_active`` — including the
+    ``False`` case — so a print Bambuddy didn't dispatch clears its
+    predecessor's flag rather than inheriting it.
+    """
+    injected = printer_id in _pending
+    _pending.discard(printer_id)
+    if injected:
+        _active.add(printer_id)
+        logger.debug("[DISPATCH-CTX] printer %s: running print has injected End G-code", printer_id)
+    else:
+        _active.discard(printer_id)
+    return injected
+
+
+def end_gcode_injected(printer_id: int) -> bool:
+    """True if the print currently running on ``printer_id`` has End G-code."""
+    return printer_id in _active
+
+
+def clear(printer_id: int) -> None:
+    """Forget everything about this printer (disconnect, removal, tests)."""
+    _pending.discard(printer_id)
+    _active.discard(printer_id)

+ 96 - 2
backend/app/services/print_scheduler.py

@@ -23,6 +23,7 @@ from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.services import print_dispatch_context
 from backend.app.services.bambu_ftp import (
     UploadCancelled,
     cache_3mf_download,
@@ -31,6 +32,7 @@ from backend.app.services.bambu_ftp import (
     upload_file_async,
     with_ftp_retry,
 )
+from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
@@ -173,6 +175,25 @@ def _mapping_is_all_unresolved(mapping: list | None) -> bool:
     return all(t is None or (isinstance(t, int) and t < 0) for t in mapping)
 
 
+def _mqtt_commands_rejected(status) -> bool:
+    """True when the printer is currently reporting that it refused a command.
+
+    ``HMS_MQTT_VERIFY_FAILED`` means the firmware's authorization check rejected
+    a control command it could not verify. Queries still answer, so the printer
+    looks connected and idle while project_file, gcode_line and
+    ams_change_filament are all dropped — no amount of waiting or re-uploading
+    changes that (#2732).
+
+    Tolerates a missing status and errors without a ``full_code`` (the 8-char
+    ``print_error`` path builds HMSError differently), so this is safe to call on
+    every watchdog poll.
+    """
+    for err in getattr(status, "hms_errors", None) or []:
+        if getattr(err, "full_code", "") == HMS_MQTT_VERIFY_FAILED:
+            return True
+    return False
+
+
 def _installed_nozzle_diameters(status) -> list[float]:
     """Parse the installed nozzle diameters from a PrinterState (#1899).
 
@@ -2966,6 +2987,7 @@ class PrintScheduler:
         queue_item_id: int,
         printer_id: int,
         created_by_id: int | None,
+        reason: str = "Printer accepted the file but never started printing",
     ) -> None:
         """Tell the user the queue item was failed after exhausting its dispatch retries.
 
@@ -2973,6 +2995,10 @@ class PrintScheduler:
         its own — hence the fresh one here. Best-effort throughout: the row is
         already marked failed and that is the load-bearing part; a notification
         provider being down must not resurrect the retry loop we just stopped.
+
+        ``reason`` defaults to the exhausted-retries wording. The command-rejected
+        path passes its own, because "accepted the file but never started" is the
+        opposite of what happened there — the printer refused it outright (#2732).
         """
         try:
             async with async_session() as db:
@@ -2985,7 +3011,7 @@ class PrintScheduler:
                     job_name=job_name,
                     printer_id=printer_id,
                     printer_name=printer.name if printer else "Unknown",
-                    reason="Printer accepted the file but never started printing",
+                    reason=reason,
                     db=db,
                 )
         except Exception as e:
@@ -3336,6 +3362,10 @@ class PrintScheduler:
 
         # G-code injection for auto-print systems (#422)
         injected_path = None
+        # #2547: tracked separately from `injected_path`, which is also set when
+        # only a START snippet was injected. Only an END snippet changes what the
+        # camera sees at print completion.
+        end_gcode_injected = False
         if item.gcode_injection:
             try:
                 snippets_raw = await self._get_setting(db, "gcode_snippets")
@@ -3352,6 +3382,7 @@ class PrintScheduler:
                         )
                         if injected_path:
                             file_path = injected_path
+                            end_gcode_injected = bool(end_gc)
                             logger.info("Queue item %s: G-code injected for model %s", item.id, printer.model)
                         else:
                             logger.warning(
@@ -3360,6 +3391,13 @@ class PrintScheduler:
             except Exception as e:
                 logger.warning("Queue item %s: G-code injection failed, using original: %s", item.id, e)
 
+        # #2547: the finish-photo path can't learn from telemetry that this print
+        # ends with user End G-code — which means the plate may be gone by the
+        # time FINISH arrives (#1867). Flag it here; `on_print_start` binds it to
+        # the print once the printer confirms it running.
+        if end_gcode_injected:
+            print_dispatch_context.mark_pending(printer.id)
+
         # Upload to root directory (not /cache/) - the start_print command references
         # files by name only (ftp://{filename}), so they must be in the root
         remote_filename = derive_remote_filename(filename)
@@ -3844,9 +3882,20 @@ class PrintScheduler:
 
         Phase A timeout raised from 45 s → 90 s as belt-and-braces for slow
         transitions that also don't emit an early subtask_id tick.
+
+        Both phases also watch for ``HMS_MQTT_VERIFY_FAILED``. A printer that
+        refuses to verify our commands will never start this job or any other,
+        so waiting out the full 270 s and re-uploading the 3MF twice more only
+        burns an upload slot the rest of the farm is queued behind — that path
+        is for a printer that might still come good, which this one cannot
+        (#2732). It fails the item on the spot with the actual reason instead.
         """
         last_status = None
         landed_on_subtask = False
+        # Latched, not level-tested: state.hms_errors is rebuilt from scratch on
+        # every push carrying an `hms` key, so the fault can come and go between
+        # 3-second polls. Seeing it once inside the dispatch window is enough.
+        command_rejected = False
         deadline = time.monotonic() + timeout
         while time.monotonic() < deadline:
             await asyncio.sleep(poll_interval)
@@ -3877,6 +3926,13 @@ class PrintScheduler:
                 except Exception:
                     pass
                 return
+            # Checked only after the active-state exit above: a stale HMS left
+            # over from an earlier job must never abort a print that is visibly
+            # running. An actually-refused command leaves the printer idle, so
+            # this ordering costs the detection nothing.
+            if _mqtt_commands_rejected(status):
+                command_rejected = True
+                break
             if pre_subtask_id is not None and status.subtask_id is not None and status.subtask_id != pre_subtask_id:
                 # Phase A exit — printer accepted the file (subtask_id flipped
                 # to our submission id). Don't return yet: the printer may
@@ -3886,7 +3942,7 @@ class PrintScheduler:
                 landed_on_subtask = True
                 break
 
-        if landed_on_subtask:
+        if landed_on_subtask and not command_rejected:
             phase_b_deadline = time.monotonic() + phase_b_timeout
             while time.monotonic() < phase_b_deadline:
                 await asyncio.sleep(poll_interval)
@@ -3906,6 +3962,11 @@ class PrintScheduler:
                     except Exception:
                         pass
                     return
+                # Same ordering rule as Phase A: a running print wins over a
+                # lingering HMS.
+                if _mqtt_commands_rejected(status):
+                    command_rejected = True
+                    break
 
         # No active-state transition. Revert the item so the scheduler can retry.
         # Drop the in-memory hold so the retry isn't blocked by it.
@@ -3935,6 +3996,20 @@ class PrintScheduler:
                 return "already_moved_on"
             item.dispatch_attempts = (item.dispatch_attempts or 0) + 1
             item.started_at = None
+            if command_rejected:
+                # No retry budget for this one: the printer refused to verify the
+                # command, and re-uploading the same 3MF to the same printer will
+                # be refused the same way. Fail now with the fix rather than after
+                # three laps of a message about SD cards (#2732).
+                item.status = "failed"
+                item.error_message = (
+                    "The printer rejected the print command: MQTT command verification failed "
+                    "(HMS 0500-0500-0001-0007). Enable Developer Mode on the printer, restart it, "
+                    "then start the job again."
+                )
+                item.completed_at = datetime.now(timezone.utc)
+                await db.commit()
+                return "command_rejected"
             if item.dispatch_attempts >= DISPATCH_MAX_ATTEMPTS:
                 item.status = "failed"
                 item.error_message = (
@@ -3969,6 +4044,25 @@ class PrintScheduler:
             return
 
         total_timeout = timeout + (phase_b_timeout if landed_on_subtask else 0.0)
+        if revert_outcome == "command_rejected":
+            logger.error(
+                "Queue item %s: printer %d reported HMS %s (MQTT command verification "
+                "failed) — the print command was rejected, not lost. Failing the item "
+                "without retrying; enable Developer Mode on the printer and restart it (#2732)",
+                queue_item_id,
+                printer_id,
+                HMS_MQTT_VERIFY_FAILED,
+            )
+            await scheduler._notify_dispatch_gave_up(
+                queue_item_id,
+                printer_id,
+                created_by_id,
+                reason="Printer rejected the print command (MQTT command verification failed)",
+            )
+            # Same reasoning as the landed_on_subtask path below: the file is on
+            # the printer and a forced reconnect would only add 0500_4003 to a
+            # problem that has nothing to do with the MQTT session (#1150).
+            return
         if revert_outcome == "gave_up":
             logger.error(
                 "Queue item %s: printer %d never started the print after %d dispatch "

+ 6 - 0
backend/app/services/printer_diagnostic.py

@@ -57,6 +57,12 @@ async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT)
         return False
 
 
+# Public alias. The connection watchdog probes the MQTT port before rebuilding a
+# client, so it can tell "the printer is switched off" (leave it alone, paho will
+# keep retrying) from "the printer is answering but our session is dead" (#2732).
+check_port = _check_port
+
+
 def _auth_reason_params(reason: str | None) -> dict:
     """Map a client's CONNACK-refusal slug onto the check's `params.reason`.
 

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

@@ -325,6 +325,7 @@ class PrinterManager:
         self._on_status_change: Callable[[int, PrinterState], None] | None = None
         self._on_ams_change: Callable[[int, list], None] | None = None
         self._on_layer_change: Callable[[int, int], None] | None = None
+        self._on_print_progress: Callable[[int, int], None] | None = None
         self._on_bed_temp_update: Callable[[int, float], None] | None = None
         self._on_drying_complete: Callable[[int, int], None] | None = None
         self._on_assignment_verified: Callable[[int, int, int, bool, dict], None] | None = None
@@ -548,6 +549,15 @@ class PrinterManager:
         """Set callback for layer change events. Receives (printer_id, layer_num)."""
         self._on_layer_change = callback
 
+    def set_print_progress_callback(self, callback: Callable[[int, int], None]):
+        """Set callback for print-progress advances (#2547).
+
+        Receives (printer_id, percent) each time `mc_percent` increases during a
+        running print — including the final layer, where layer-change events
+        have already stopped.
+        """
+        self._on_print_progress = callback
+
     def set_bed_temp_update_callback(self, callback: Callable[[int, float], None]):
         """Set callback for bed temperature updates. Receives (printer_id, bed_temp)."""
         self._on_bed_temp_update = callback
@@ -624,6 +634,10 @@ class PrinterManager:
             if self._on_layer_change:
                 self._schedule_async(self._on_layer_change(printer_id, layer_num))
 
+        def on_print_progress(percent: int):
+            if self._on_print_progress:
+                self._schedule_async(self._on_print_progress(printer_id, percent))
+
         def on_bed_temp_update(bed_temp: float):
             if self._on_bed_temp_update:
                 self._schedule_async(self._on_bed_temp_update(printer_id, bed_temp))
@@ -646,6 +660,7 @@ class PrinterManager:
             on_print_complete=on_print_complete,
             on_ams_change=on_ams_change,
             on_layer_change=on_layer_change,
+            on_print_progress=on_print_progress,
             on_bed_temp_update=on_bed_temp_update,
             on_drying_complete=on_drying_complete,
             on_print_running_observed=on_print_running_observed,

+ 6 - 1
backend/app/services/slice_preview.py

@@ -63,6 +63,7 @@ async def get_preview_filaments(
     file_name: str,
     api_url: str,
     request_id: str | None = None,
+    timeout_seconds: float | None = None,
 ) -> list[dict] | None:
     """Run a preview slice for ``plate_id``, parse the resulting slice_info,
     and return the per-plate filament list.
@@ -92,7 +93,11 @@ async def get_preview_filaments(
             return cached
 
         try:
-            async with SlicerApiService(base_url=api_url) as svc:
+            # Preview slices are bounded the same way as real ones (#2730):
+            # a heavy plate can take a long time and must not be cut off
+            # while the slicer is visibly working.
+            svc_kwargs = {} if timeout_seconds is None else {"timeout_seconds": timeout_seconds}
+            async with SlicerApiService(base_url=api_url, **svc_kwargs) as svc:
                 result = await svc.slice_without_profiles(
                     model_bytes=file_bytes,
                     model_filename=file_name,

+ 211 - 50
backend/app/services/slicer_api.py

@@ -11,6 +11,7 @@ under the hood, response body is raw G-code or 3MF with metadata in the
 import asyncio
 import io
 import logging
+import time
 import zipfile
 from collections.abc import Callable
 from typing import NamedTuple
@@ -40,6 +41,18 @@ class SlicerInputError(SlicerApiError):
     """Sidecar rejected the input as invalid (4xx)."""
 
 
+class SlicerTimeoutError(SlicerApiError):
+    """We gave up waiting on a slice that never finished.
+
+    Kept apart from ``SlicerApiUnavailableError`` because they call for
+    opposite reactions and used to be reported as the same thing: an
+    ``httpx.ReadTimeout`` is a subclass of ``RequestError``, so a slice that
+    simply took a long time surfaced as "Slicer sidecar unreachable" — sending
+    the reporter of #2730 off to check a sidecar that was reachable throughout
+    and still slicing when we hung up on it.
+    """
+
+
 class SliceResult(NamedTuple):
     """Result of a slice operation."""
 
@@ -51,6 +64,36 @@ class SliceResult(NamedTuple):
 
 _shared_http_client: httpx.AsyncClient | None = None
 
+# Fallback for callers that don't pass one (tests, and any path that runs
+# without a DB session to read the setting from). The user-facing value is
+# ``slicer_stall_timeout_minutes`` under Settings -> Workflow -> Slicer.
+DEFAULT_SLICE_STALL_TIMEOUT_SECONDS = 15 * 60.0
+
+# How often the progress poller ticks. Also the granularity of the stall check,
+# since a missed tick is what the stall clock is counting.
+_PROGRESS_POLL_INTERVAL = 1.0
+
+
+async def get_stall_timeout_seconds(db) -> float:
+    """Read ``slicer_stall_timeout_minutes`` (Settings -> Workflow -> Slicer).
+
+    Falls back to the default on anything unparseable rather than failing the
+    slice — a bad settings row must not be the reason a print doesn't happen.
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    try:
+        raw = await get_setting(db, "slicer_stall_timeout_minutes")
+    except Exception:
+        return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
+    try:
+        minutes = int(str(raw).strip())
+    except (TypeError, ValueError):
+        return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
+    if minutes < 1:
+        return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
+    return float(minutes) * 60.0
+
 
 def _format_sidecar_error(response: httpx.Response) -> str:
     """Build a human-readable error string from a sidecar 4xx/5xx response.
@@ -149,6 +192,65 @@ def _guess_model_content_type(filename: str) -> str:
     return "application/octet-stream"
 
 
+class _Liveness:
+    """Tracks when the slicer last showed a sign of life.
+
+    ``deadline`` is what the slice waits against, and it moves forward on every
+    genuine progress update. A slice therefore fails only after the configured
+    window of *silence*, however long the whole thing has been running (#2730).
+
+    ``progress_supported`` stays False for sidecars that never answer the
+    progress endpoint. Those give us nothing to judge liveness by, so the caller
+    treats the same window as a total-elapsed ceiling rather than pretending a
+    stall can be detected.
+    """
+
+    def __init__(self, window_seconds: float, poll_interval: float = _PROGRESS_POLL_INTERVAL) -> None:
+        # Liveness can only be observed as often as the poller ticks, so a
+        # window shorter than a few ticks would expire in the gap between two
+        # polls and fail every slice instantly, however healthy. The settings
+        # schema already floors the user-facing value at a minute; this guards
+        # the constructor, which tests and any future caller can pass anything.
+        self.window_seconds = max(window_seconds, poll_interval * 3)
+        self.progress_supported = False
+        self.started_at = time.monotonic()
+        self._last_alive = self.started_at
+
+    def saw_progress_endpoint(self) -> None:
+        self.progress_supported = True
+
+    def mark_alive(self) -> None:
+        self._last_alive = time.monotonic()
+
+    @property
+    def deadline(self) -> float:
+        """Monotonic time at which we stop waiting."""
+        base = self._last_alive if self.progress_supported else self.started_at
+        return base + self.window_seconds
+
+    def silent_for(self) -> float:
+        return time.monotonic() - self._last_alive
+
+    def elapsed(self) -> float:
+        return time.monotonic() - self.started_at
+
+    def timeout_message(self) -> str:
+        minutes = self.window_seconds / 60
+        if self.progress_supported:
+            return (
+                f"The slicer stopped reporting progress for {minutes:.0f} minutes "
+                f"(slicing had been running for {self.elapsed() / 60:.0f} minutes). "
+                "Raise 'Slicer stall timeout' under Settings -> Workflow -> Slicer if this model "
+                "legitimately needs longer between progress updates."
+            )
+        return (
+            f"Slicing did not finish within {minutes:.0f} minutes, and this sidecar does not "
+            "report progress, so there was no way to tell a slow model from a stalled one. "
+            "Raise 'Slicer stall timeout' under Settings -> Workflow -> Slicer, or update the "
+            "sidecar to a version that reports progress."
+        )
+
+
 class SlicerApiService:
     """Talks to an OrcaSlicer / BambuStudio API sidecar."""
 
@@ -157,10 +259,25 @@ class SlicerApiService:
         base_url: str,
         *,
         client: httpx.AsyncClient | None = None,
-        timeout_seconds: float = 300.0,
+        timeout_seconds: float = DEFAULT_SLICE_STALL_TIMEOUT_SECONDS,
     ) -> None:
+        """``timeout_seconds`` bounds *silence*, not total slicing time (#2730).
+
+        While a slice is running Bambuddy polls the sidecar's progress channel
+        once a second, so it can tell a model that is merely slow from one that
+        has stopped: the clock is reset by every progress update, and only runs
+        out when the slicer has said nothing for this long. A heavy model that
+        keeps reporting will run to completion however long it takes.
+
+        Sidecars too old to report progress have no liveness signal to offer, so
+        for those the same number bounds total elapsed time — the pre-#2730
+        behaviour, but configurable and no longer five minutes flat.
+        """
         self.base_url = base_url.rstrip("/")
         self.timeout_seconds = timeout_seconds
+        # Instance-level so tests can compress the timing; production always
+        # uses the module default.
+        self.progress_poll_interval = _PROGRESS_POLL_INTERVAL
         if client is not None:
             self._client = client
             self._owns_client = False
@@ -217,6 +334,8 @@ class SlicerApiService:
         self,
         request_id: str,
         on_progress: Callable[[dict], None],
+        *,
+        liveness: "_Liveness | None" = None,
     ) -> None:
         """Poll the sidecar's progress endpoint at ~1Hz and forward each
         snapshot to ``on_progress``. Runs until cancelled.
@@ -232,14 +351,27 @@ class SlicerApiService:
         slice grace expiry) just costs a few wasted GETs that the cancel
         will stop. Network errors and non-JSON 5xx are swallowed; the
         next tick retries.
+
+        When ``liveness`` is supplied this doubles as the stall watchdog: every
+        200 carrying a *changed* payload marks the slicer alive, which is what
+        keeps the slice's deadline moving (#2730). An unchanged payload
+        deliberately does not count — the sidecar re-serves its last snapshot on
+        every poll, so treating a repeat as progress would leave the watchdog
+        unable to detect a stall at all.
         """
         url = f"{self.base_url}/slice/progress/{request_id}"
+        last_payload: dict | None = None
         while True:
             try:
                 response = await self._client.get(url, timeout=5.0)
                 if response.status_code == 200:
                     payload = response.json()
                     if isinstance(payload, dict):
+                        if liveness is not None:
+                            liveness.saw_progress_endpoint()
+                            if payload != last_payload:
+                                liveness.mark_alive()
+                        last_payload = payload
                         on_progress(payload)
                 # 404 / other 4xx = no progress available (yet, or ever
                 # for older sidecars). Keep polling — the outer slice
@@ -249,10 +381,85 @@ class SlicerApiService:
                 # returns a non-JSON 5xx. Don't crash the poller.
                 pass
             try:
-                await asyncio.sleep(1.0)
+                await asyncio.sleep(self.progress_poll_interval)
             except asyncio.CancelledError:
                 return
 
+    async def _post_slice(
+        self,
+        *,
+        files: list | dict,
+        data: dict,
+        request_id: str | None,
+        on_progress: Callable[[dict], None] | None,
+    ) -> httpx.Response:
+        """POST /slice, supervised by the progress channel rather than a clock.
+
+        Before #2730 this was a plain ``httpx`` call with a flat 300 s timeout on
+        every phase. A genuinely heavy model — the reporter's was a MakerWorld
+        model that Bambu Studio also took a long time over — hit the ceiling
+        while it was still slicing perfectly happily, and because
+        ``httpx.ReadTimeout`` is a ``RequestError`` it was reported as "Slicer
+        sidecar unreachable". Meanwhile Bambuddy was polling the sidecar's
+        progress endpoint once a second and could see the thing working.
+
+        So the read timeout comes off the HTTP call and the poller supervises
+        instead: the deadline is pushed forward by every progress update, and
+        only a genuine silence ends the wait. Connect and pool keep short
+        timeouts — a sidecar that won't accept the connection at all is
+        unreachable, and should still say so quickly.
+        """
+        liveness = _Liveness(self.timeout_seconds, self.progress_poll_interval)
+
+        # Poll whenever we have a request_id, even if the caller wants no
+        # progress callbacks: the poll is what makes stall detection possible,
+        # and one GET per second is cheaper than a wrongly-cancelled slice.
+        progress_task: asyncio.Task | None = None
+        if request_id is not None:
+            progress_task = asyncio.create_task(
+                self._poll_progress(request_id, on_progress or (lambda _payload: None), liveness=liveness),
+                name=f"slicer-progress-{request_id}",
+            )
+
+        post_task = asyncio.create_task(
+            self._client.post(
+                f"{self.base_url}/slice",
+                files=files,
+                data=data,
+                timeout=httpx.Timeout(connect=30.0, read=None, write=None, pool=30.0),
+            ),
+            name="slicer-slice-post",
+        )
+
+        try:
+            while True:
+                remaining = liveness.deadline - time.monotonic()
+                if remaining <= 0:
+                    post_task.cancel()
+                    logger.warning(
+                        "Slice abandoned after %.0fs (silent for %.0fs, progress channel %s)",
+                        liveness.elapsed(),
+                        liveness.silent_for(),
+                        "available" if liveness.progress_supported else "unavailable",
+                    )
+                    raise SlicerTimeoutError(liveness.timeout_message())
+                # Re-check at poll granularity so a progress update that lands
+                # mid-wait extends the deadline promptly.
+                done, _pending = await asyncio.wait({post_task}, timeout=min(remaining, self.progress_poll_interval))
+                if post_task in done:
+                    break
+        finally:
+            if progress_task is not None:
+                progress_task.cancel()
+            # Await both so neither is left pending — a cancelled POST still
+            # needs its connection released back to the pool.
+            await asyncio.gather(post_task, progress_task or asyncio.sleep(0), return_exceptions=True)
+
+        try:
+            return post_task.result()
+        except httpx.RequestError as exc:
+            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
+
     async def slice_with_profiles(
         self,
         *,
@@ -328,30 +535,7 @@ class SlicerApiService:
         # and surfaces structured updates via on_progress. Uses a
         # short-tick poll (1s) since the slicer emits stage changes
         # several times per minute on complex models.
-        progress_task: asyncio.Task | None = None
-        if request_id is not None and on_progress is not None:
-            progress_task = asyncio.create_task(
-                self._poll_progress(request_id, on_progress),
-                name=f"slicer-progress-{request_id}",
-            )
-
-        try:
-            response = await self._client.post(
-                f"{self.base_url}/slice",
-                files=files,
-                data=data,
-                timeout=self.timeout_seconds,
-            )
-        except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
-        finally:
-            if progress_task is not None:
-                progress_task.cancel()
-                try:
-                    await progress_task
-                except (asyncio.CancelledError, Exception):
-                    pass  # Polling errors must not fail the slice.
-
+        response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
         return _handle_slice_response(response, export_3mf=export_3mf)
 
     async def slice_without_profiles(
@@ -396,30 +580,7 @@ class SlicerApiService:
         # embedded-settings fallback path triggered by an Orca/Bambu CLI
         # segfault on complex H2D models — both want to keep updating
         # the user's toast through the slow operation.
-        progress_task: asyncio.Task | None = None
-        if request_id is not None and on_progress is not None:
-            progress_task = asyncio.create_task(
-                self._poll_progress(request_id, on_progress),
-                name=f"slicer-progress-{request_id}",
-            )
-
-        try:
-            response = await self._client.post(
-                f"{self.base_url}/slice",
-                files=files,
-                data=data,
-                timeout=self.timeout_seconds,
-            )
-        except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
-        finally:
-            if progress_task is not None:
-                progress_task.cancel()
-                try:
-                    await progress_task
-                except (asyncio.CancelledError, Exception):
-                    pass
-
+        response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
         return _handle_slice_response(response, export_3mf=export_3mf)
 
 

+ 205 - 7
backend/app/services/virtual_printer/manager.py

@@ -5,6 +5,7 @@ bound to its dedicated IP address, regardless of mode.
 """
 
 import asyncio
+import json
 import logging
 import time
 from collections.abc import Callable
@@ -154,6 +155,60 @@ def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str |
     return None
 
 
+def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str) -> str | None:
+    """Pull the slicer's own AMS-slot pick out of a captured project_file payload.
+
+    BambuStudio/OrcaSlicer resolves the physical AMS tray for each filament
+    live, right before sending — either automatically or via the slicer's
+    manual per-filament AMS-slot assignment dialog — and embeds the result as
+    ``ams_mapping`` (``list[int]``, position = slot_id-1, value = global tray
+    ID) directly in the MQTT ``project_file`` command. Confirmed by wire
+    capture: the field is present and already in the exact shape
+    ``PrintQueueItem.ams_mapping`` expects.
+
+    The VP-queue path previously never read this — every queued print had the
+    scheduler re-derive a mapping from just the 3MF's static type/color at
+    dispatch time (`PrintScheduler._compute_ams_mapping_for_printer`), discarding
+    the slicer's already-correct, live-resolved pick. That re-derivation can
+    land on the wrong physical spool whenever the file's type+color match
+    isn't unique (e.g. two spools of the same color) or the file's own
+    filament-slot color wasn't what the user actually intended for that
+    particular print. Capturing it here — mirroring the existing
+    ``nozzle_mapping`` passthrough for H2C rack-swap models (#1780) — lets the
+    scheduler's "already resolved, don't touch it" branch in
+    ``_ensure_ams_mapping`` use the slicer's own choice unchanged.
+
+    That branch skipping ``_compute_ams_mapping_for_printer`` is also what
+    makes this a trade rather than a pure win: ``prefer_lowest_filament``, its
+    AMS-filament-backup gate (#1766), the inventory-remain overrides and the
+    per-slot force-color overrides all live inside that function. Callers are
+    responsible for the gating — this parser only says what the slicer sent.
+
+    Returns ``None`` when the field is absent, unparsable, or the classic
+    "all -1" unresolved-race sentinel (#2589) — never worth trusting over a
+    fresh live computation.
+    """
+    raw = data.get("ams_mapping")
+    if raw is None:
+        return None
+    if isinstance(raw, str):
+        try:
+            raw = json.loads(raw)
+        except json.JSONDecodeError:
+            logger.warning("%s Slicer ams_mapping is unparseable JSON, dropping: %r", log_prefix, raw)
+            return None
+    # bool is a subclass of int in Python — isinstance(True, int) is True —
+    # so it must be excluded explicitly, or [True, False] would pass as a
+    # valid mapping.
+    if not isinstance(raw, list) or not raw or not all(isinstance(v, int) and not isinstance(v, bool) for v in raw):
+        return None
+    if all(v < 0 for v in raw):
+        # #2589 sentinel — every slot unresolved. Let the scheduler compute a
+        # fresh mapping from live AMS state instead of trusting this.
+        return None
+    return json.dumps(raw)
+
+
 def _get_serial_for_model(model: str, serial_suffix: str) -> str:
     """Get serial number for the given model and suffix."""
     prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
@@ -181,6 +236,7 @@ class VirtualPrinterInstance:
         target_printer_id: int | None = None,
         auto_dispatch: bool = True,
         queue_force_color_match: bool = False,
+        save_ams_mapping: bool = False,
         gcode_injection: bool = False,
         bind_ip: str = "",
         remote_interface_ip: str = "",
@@ -204,6 +260,7 @@ class VirtualPrinterInstance:
         self.target_printer_id = target_printer_id
         self.auto_dispatch = auto_dispatch
         self.queue_force_color_match = queue_force_color_match
+        self.save_ams_mapping = save_ams_mapping
         self.gcode_injection = gcode_injection
         self.bind_ip = bind_ip
         self.remote_interface_ip = remote_interface_ip
@@ -416,8 +473,9 @@ class VirtualPrinterInstance:
         row was already written with settings defaults. This method runs
         on the late MQTT path: it looks up the most recent queue items
         committed for this filename and patches in the slicer's
-        ``nozzle_mapping`` + workflow flags, but only while the items are
-        still ``pending`` (scheduler hasn't dispatched them yet).
+        ``nozzle_mapping`` + ``ams_mapping`` + workflow flags, but only
+        while the items are still ``pending`` (scheduler hasn't dispatched
+        them yet).
         """
         if not self._session_factory:
             return
@@ -469,12 +527,34 @@ class VirtualPrinterInstance:
             if raw is not None:
                 patch["nozzle_mapping"] = json.dumps(raw)
 
-        if not patch:
+        # Same two gates as the immediate path in `_add_to_print_queue`: a
+        # model-based VP has no live AMS layout for the slicer to have resolved
+        # tray IDs against, and taking the slicer's pick at all is the per-VP
+        # `save_ams_mapping` opt-in (it makes the scheduler skip
+        # `_compute_ams_mapping_for_printer`, and with it prefer-lowest and the
+        # #1766 backup gate).
+        ams_mapping_json = (
+            _extract_slicer_ams_mapping_json(data, f"[VP {self.name}] Late MQTT")
+            if self.target_printer_id is not None and self.save_ams_mapping
+            else None
+        )
+        # `Force color match` still wins for this dispatch — see the same
+        # decision in `_add_to_print_queue`. The archive patch below is
+        # deliberately not gated on it: persisting the pick for later reprints
+        # is exactly what the toggle promises.
+        if ams_mapping_json is not None and not self.queue_force_color_match:
+            patch["ams_mapping"] = ams_mapping_json
+
+        # `ams_mapping_json` alone is enough to keep going even when `patch` is
+        # empty: with `Force color match` on it never reaches the queue item,
+        # but it still has to be written onto the archive below.
+        if not patch and ams_mapping_json is None:
             self._recent_queue_items.pop(stash_key, None)
             return
 
         from sqlalchemy import select, update
 
+        from backend.app.models.archive import PrintArchive
         from backend.app.models.print_queue import PrintQueueItem
 
         try:
@@ -482,23 +562,49 @@ class VirtualPrinterInstance:
                 # Only stamp items still pending; once the scheduler has
                 # picked the row up we can't safely race the dispatcher.
                 result = await db.execute(
-                    select(PrintQueueItem.id).where(
+                    select(PrintQueueItem.id, PrintQueueItem.archive_id).where(
                         PrintQueueItem.id.in_(queue_item_ids),
                         PrintQueueItem.status == "pending",
                     )
                 )
-                eligible_ids = [row[0] for row in result.all()]
+                rows = result.all()
+                eligible_ids = [row[0] for row in rows]
                 if not eligible_ids:
                     self._recent_queue_items.pop(stash_key, None)
                     return
-                await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
+                if patch:
+                    await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
+
+                # The archive was already created (with no slicer_ams_mapping)
+                # before this late MQTT arrived — see
+                # `_extract_slicer_ams_mapping_json`'s docstring. Patch it here
+                # too so a reprint later still picks up the slicer's pick, and
+                # the "AMS mapping from slicer" badge reflects reality instead
+                # of staying stuck on the archive's initial (empty) snapshot.
+                # Already gated on `save_ams_mapping` above, and deliberately
+                # NOT on `queue_force_color_match`: that toggle decides how
+                # *this* print is matched, not whether the pick is worth
+                # keeping for a later reprint.
+                if ams_mapping_json is not None:
+                    archive_ids = {row[1] for row in rows if row[1] is not None}
+                    if archive_ids:
+                        archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id.in_(archive_ids)))
+                        for archive in archive_result.scalars().all():
+                            extra = dict(archive.extra_data or {})
+                            extra["slicer_ams_mapping"] = {
+                                "mapping": json.loads(ams_mapping_json),
+                                "printer_id": self.target_printer_id,
+                            }
+                            archive.extra_data = extra
+
                 await db.commit()
                 logger.info(
-                    "[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s",
+                    "[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s%s",
                     self.name,
                     stash_key,
                     sorted(patch.keys()),
                     eligible_ids,
+                    " and saved the slicer's AMS pick onto the archive" if ams_mapping_json is not None else "",
                 )
         except Exception as e:
             logger.error(
@@ -834,6 +940,60 @@ class VirtualPrinterInstance:
                         if raw is not None:
                             nozzle_mapping_json = json.dumps(raw)
 
+                # Slicer's own live-resolved AMS-slot pick (see docstring on
+                # `_extract_slicer_ams_mapping_json`). Stamped onto every plate
+                # below, same treatment as nozzle_mapping_json above — when
+                # present it makes `_ensure_ams_mapping` skip its own
+                # type/color re-derivation entirely and dispatch use exactly
+                # the tray the slicer/user picked.
+                #
+                # Two gates, both required:
+                #
+                # 1. This VP must target one fixed printer. A model-based
+                #    ("Any <model>") VP has no MQTT bridge to a real printer,
+                #    so the slicer has no live AMS layout to resolve tray IDs
+                #    against — whatever it sends here is meaningless (or,
+                #    worse, coincidentally valid for the wrong printer once
+                #    the scheduler later picks one).
+                # 2. The per-VP `save_ams_mapping` opt-in must be on. Taking
+                #    the slicer's pick means `_ensure_ams_mapping` returns
+                #    early and `_compute_ams_mapping_for_printer` never runs —
+                #    and that function is where `prefer_lowest_filament`, its
+                #    AMS-filament-backup gate (#1766) and the inventory-remain
+                #    overrides live. Honouring the slicer unconditionally would
+                #    silently retire all of that for every existing queue-mode
+                #    VP on upgrade, so it's opt-in like every other queue-mode
+                #    behaviour toggle (#2700 review).
+                #
+                # Either gate failing leaves it unset, and the scheduler's
+                # normal type/color re-derivation runs against whichever
+                # printer actually gets the job.
+                ams_mapping_json: str | None = None
+                if slicer_opts is not None and self.target_printer_id is not None and self.save_ams_mapping:
+                    ams_mapping_json = _extract_slicer_ams_mapping_json(slicer_opts, f"[VP {self.name}]")
+
+                # `Force color match` is the user asking Bambuddy to do the
+                # matching strictly, against the printer's live trays. Its only
+                # effect on a fixed-printer item is via the per-slot
+                # `filament_overrides` written below, which are consumed inside
+                # `_compute_ams_mapping_for_printer` — the exact function a
+                # stored mapping skips. So when both toggles are on, the
+                # explicit strictness wins for *this* dispatch and the slicer's
+                # pick is still persisted onto the archive for later reprints,
+                # which is what `Save AMS mapping` actually promises (#2700
+                # review).
+                queue_ams_mapping_json = ams_mapping_json
+                if queue_ams_mapping_json is not None and self.queue_force_color_match:
+                    logger.info(
+                        "[VP %s] Saved the slicer's AMS pick to the archive but not onto the queue item(s): "
+                        "'Force color match' is on, so the scheduler matches against live trays for this print.",
+                        self.name,
+                    )
+                    queue_ams_mapping_json = None
+
+                # Parsed once for the per-plate length check in the loop below.
+                queue_ams_mapping = json.loads(queue_ams_mapping_json) if queue_ams_mapping_json else None
+
                 service = ArchiveService(db)
                 archive = await service.archive_print(
                     printer_id=None,
@@ -844,6 +1004,14 @@ class VirtualPrinterInstance:
                         "source_ip": source_ip,
                     },
                     prefer_filename_for_name=prefer_filename,
+                    # Slicer's own live AMS-slot pick -- promoted to
+                    # `extra_data.slicer_ams_mapping` by archive_print() so a
+                    # later reprint can reuse it. Already gated on the per-VP
+                    # `save_ams_mapping` opt-in above. Tagged with the printer
+                    # it was resolved against so a later reprint on a
+                    # *different* printer knows not to reuse it (#2700 review).
+                    slicer_ams_mapping=(json.loads(ams_mapping_json) if ams_mapping_json else None),
+                    slicer_ams_mapping_printer_id=self.target_printer_id,
                 )
                 if archive:
                     logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
@@ -925,6 +1093,31 @@ class VirtualPrinterInstance:
                                 if overrides:
                                     filament_overrides_json = json.dumps(overrides)
 
+                        # The slicer's mapping is indexed by the 3MF's own
+                        # file-global slot ids (position = slot_id - 1), so one
+                        # array covers every plate of a multi-plate Send All —
+                        # each plate just reads the entries for the slots it
+                        # actually prints. What must be checked is that it
+                        # reaches that far: a mapping shorter than this plate's
+                        # highest slot id can't address the plate's own slots,
+                        # and `_ensure_ams_mapping` would keep it anyway
+                        # because it only rejects an all-unresolved mapping. Fall
+                        # back to a computed mapping for that plate instead
+                        # (#2700 review).
+                        plate_ams_mapping_json = queue_ams_mapping_json
+                        if queue_ams_mapping is not None and requirements:
+                            max_slot_id = max((r.get("slot_id") or 0) for r in requirements)
+                            if max_slot_id > len(queue_ams_mapping):
+                                logger.warning(
+                                    "[VP %s] Slicer ams_mapping has %d entries but plate %s needs slot %d; "
+                                    "dropping it for this plate so the scheduler computes one from live AMS state.",
+                                    self.name,
+                                    len(queue_ams_mapping),
+                                    plate_id,
+                                    max_slot_id,
+                                )
+                                plate_ams_mapping_json = None
+
                         queue_item = PrintQueueItem(
                             printer_id=self.target_printer_id,
                             target_model=target_model,
@@ -950,6 +1143,9 @@ class VirtualPrinterInstance:
                             # the same nozzle pick across plates rather than only the
                             # first one (mirrors the #1697 / #1188 per-plate loop fix).
                             nozzle_mapping=nozzle_mapping_json,
+                            # Slicer's own live AMS-slot pick, when present —
+                            # see `_extract_slicer_ams_mapping_json`.
+                            ams_mapping=plate_ams_mapping_json,
                         )
                         db.add(queue_item)
                         await db.flush()  # populate queue_item.id before logging
@@ -1547,6 +1743,7 @@ class VirtualPrinterManager:
                 # instance silently keeps the old value until process
                 # restart (#1552 follow-up family).
                 or instance.queue_force_color_match != vp.queue_force_color_match
+                or instance.save_ams_mapping != vp.save_ams_mapping
                 or instance.gcode_injection != vp.gcode_injection
                 or proxy_target_changed
             )
@@ -1601,6 +1798,7 @@ class VirtualPrinterManager:
                     target_printer_id=vp.target_printer_id,
                     auto_dispatch=vp.auto_dispatch,
                     queue_force_color_match=vp.queue_force_color_match,
+                    save_ams_mapping=vp.save_ams_mapping,
                     gcode_injection=vp.gcode_injection,
                     bind_ip=vp.bind_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",

+ 65 - 14
backend/app/utils/threemf_tools.py

@@ -702,6 +702,68 @@ def _parse_3mf_gcode_header(content: str) -> dict[str, str]:
     return header
 
 
+def _select_plate_gcode_name(names: list[str], plate_id: int | None) -> str | None:
+    """Pick a plate's ``.gcode`` member out of a 3MF namelist.
+
+    Prefers ``plate_<id>.gcode``, then falls back to the first ``.gcode``
+    member so single-plate files — and files from slicers that don't use the
+    plate naming convention — still resolve.
+    """
+    gcodes = [n for n in names if n.endswith(".gcode")]
+    if not gcodes:
+        return None
+    if plate_id is not None:
+        suffix = f"plate_{plate_id}.gcode"
+        for name in gcodes:
+            if name.endswith(suffix):
+                return name
+    return gcodes[0]
+
+
+# The header block sits at the very top of the plate G-code. Read only that
+# much: a sliced plate is routinely tens of megabytes and `ZipFile.read()`
+# would inflate all of it to reach ~40 lines.
+_HEADER_READ_LIMIT_BYTES = 64 * 1024
+
+
+def extract_max_z_height_from_3mf(file_path: Path, plate_id: int | None = None) -> float | None:
+    """Return the plate's ``max_z_height`` in mm, or None if not knowable.
+
+    This is the Z the toolhead sat at for the final layer — the same value
+    Bambu's own end G-code adds its bed-drop offset to (``G1 Z{max_layer_z +
+    100}``). #2547 uses it to put the plate back into camera framing before the
+    finish photo, which is only safe because it is a height the printer was
+    physically at seconds earlier.
+
+    None means "don't know" and callers must treat it as such rather than
+    substituting a default: the file may be unreadable, carry no plate G-code,
+    or come from a slicer that writes no ``max_z_height`` header. Guessing a
+    height here would command a Z move to somewhere the nozzle has never been.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            target = _select_plate_gcode_name(zf.namelist(), plate_id)
+            if target is None:
+                return None
+            with zf.open(target, "r") as fh:
+                head = fh.read(_HEADER_READ_LIMIT_BYTES)
+    except (OSError, zipfile.BadZipFile, KeyError) as e:
+        logger.debug("max_z_height: cannot read %s: %s", file_path, e)
+        return None
+
+    raw = _parse_3mf_gcode_header(head.decode("utf-8", errors="ignore")).get("max_z_height")
+    if raw is None:
+        return None
+    try:
+        value = float(raw)
+    except ValueError:
+        logger.debug("max_z_height: unusable value %r in %s", raw, file_path)
+        return None
+    # Zero or negative means the header key is present but meaningless. Passed
+    # on as a height it would become a move *toward* the bed, so drop it.
+    return value if value > 0 else None
+
+
 def _substitute_placeholders(snippet: str, header: dict[str, str]) -> str:
     """Replace `{var}` placeholders with header values, leaving unknowns intact."""
 
@@ -802,21 +864,10 @@ def inject_gcode_into_3mf(
     try:
         # Find the target gcode file inside the 3MF
         with zipfile.ZipFile(source_path, "r") as zf:
-            all_gcode = [f for f in zf.namelist() if f.endswith(".gcode")]
-            if not all_gcode:
-                return None
-
-            # Try plate-specific gcode file first
-            target_gcode = None
-            plate_pattern = f"plate_{plate_id}.gcode"
-            for f in all_gcode:
-                if f.endswith(plate_pattern):
-                    target_gcode = f
-                    break
-
-            # Fall back to first gcode file
+            # Plate-specific gcode first, else the first one in the file.
+            target_gcode = _select_plate_gcode_name(zf.namelist(), plate_id)
             if target_gcode is None:
-                target_gcode = all_gcode[0]
+                return None
 
             # Read and modify gcode content
             gcode_content = zf.read(target_gcode).decode("utf-8", errors="ignore")

+ 92 - 0
backend/tests/integration/test_archives_api.py

@@ -1743,3 +1743,95 @@ class TestUploadSourceThreeMF:
         assert "outside the data directory" in response.json()["detail"]
         # Did not write anything under the bogus /tmp/source/ either.
         assert not (Path("/tmp") / "source").exists() or not (Path("/tmp") / "source" / "totally_outside.3mf").exists()  # nosec B108
+
+
+class TestSoftDeletedArchivesAreExcluded:
+    """Soft-deleted archives (#1343) must not leak into export or analysis (#2731).
+
+    The soft delete keeps the row so global Quick Stats can still count it, but
+    the archive is gone from every listing. Two consumers never got the memo:
+    the CSV export handed back rows the UI says do not exist, and per-project
+    failure analysis kept counting prints the user had deleted from the project
+    — disagreeing with the project's own figures.
+    """
+
+    @staticmethod
+    async def _soft_delete(db_session, archive) -> int:
+        from datetime import datetime, timezone
+
+        archive_id = archive.id
+        archive.deleted_at = datetime.now(timezone.utc)
+        await db_session.commit()
+        return archive_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_export_omits_soft_deleted_archives(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        printer = await printer_factory()
+        await archive_factory(printer.id, print_name="Kept Print")
+        gone = await archive_factory(printer.id, print_name="Deleted Print")
+        await self._soft_delete(db_session, gone)
+
+        response = await async_client.get("/api/v1/archives/export?format=csv")
+
+        assert response.status_code == 200
+        body = response.text
+        assert "Kept Print" in body
+        assert "Deleted Print" not in body
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_project_failure_analysis_omits_soft_deleted_archives(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        from backend.app.models.project import Project
+
+        project = Project(name="Analysis Project")
+        db_session.add(project)
+        await db_session.commit()
+        await db_session.refresh(project)
+        project_id = project.id
+
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            print_name="Kept Failure",
+            status="failed",
+            failure_reason="bed_adhesion",
+            project_id=project_id,
+        )
+        gone = await archive_factory(
+            printer.id,
+            print_name="Deleted Failure",
+            status="failed",
+            failure_reason="filament_runout",
+            project_id=project_id,
+        )
+        await self._soft_delete(db_session, gone)
+
+        response = await async_client.get(f"/api/v1/archives/analysis/failures?project_id={project_id}")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["failed_prints"] == 1
+        assert result["failures_by_reason"] == {"bed_adhesion": 1}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unscoped_failure_analysis_is_unchanged(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Only the project-scoped path filters. Global analysis still counts
+        every run, including orphans, exactly as #1390 established."""
+        printer = await printer_factory()
+        gone = await archive_factory(
+            printer.id, print_name="Deleted Failure", status="failed", failure_reason="filament_runout"
+        )
+        await self._soft_delete(db_session, gone)
+
+        response = await async_client.get("/api/v1/archives/analysis/failures")
+
+        assert response.status_code == 200
+        assert response.json()["failed_prints"] == 1

+ 26 - 1
backend/tests/integration/test_library_slice_api.py

@@ -69,6 +69,17 @@ def _install_mock_sidecar(handler: Callable[[httpx.Request], httpx.Response]) ->
     return client
 
 
+def _is_slice_post(request: httpx.Request) -> bool:
+    """True for the slice call itself, false for the progress polls beside it.
+
+    Since #2730 a slice is supervised by a 1 Hz poll of
+    ``GET /slice/progress/{id}``, which shares this mock transport. Tests that
+    count *slice attempts* — primary vs embedded-settings fallback — have to
+    exclude those, or the count becomes a measure of how long the test took.
+    """
+    return request.method == "POST" and request.url.path.endswith("/slice")
+
+
 async def _wait_for_job(client: AsyncClient, job_id: int, timeout: float = 5.0) -> dict:
     """Poll `/api/v1/slice-jobs/{id}` until the job hits a terminal state.
 
@@ -414,6 +425,8 @@ class TestSliceLibraryFile:
         call_count = {"n": 0}
 
         def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
             call_count["n"] += 1
             # First call: profile triplet present → simulate CLI 5xx
             if call_count["n"] == 1:
@@ -454,7 +467,9 @@ class TestSliceLibraryFile:
         # STL has no embedded settings — the CLI 5xx is terminal.
         call_count = {"n": 0}
 
-        def handler(_: httpx.Request) -> httpx.Response:
+        def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
             call_count["n"] += 1
             return httpx.Response(
                 status_code=500,
@@ -568,6 +583,8 @@ class TestSliceLibraryFile:
         call_count = {"n": 0}
 
         def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
             call_count["n"] += 1
             captured["body"] = request.content
             return httpx.Response(
@@ -777,6 +794,8 @@ class TestCrossClassSliceAllLoop:
         captured_requests: list[dict] = []
 
         def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
             # Multipart bodies aren't trivially parseable here; pull
             # the plate field by string search since the helper sends
             # ``name="plate"`` immediately followed by the value.
@@ -1463,6 +1482,8 @@ class TestSliceSlicerRejection:
         call_count = {"n": 0}
 
         def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
             call_count["n"] += 1
             return httpx.Response(
                 status_code=500,
@@ -1721,6 +1742,8 @@ class TestUnusedSlotSubstitutionOnSinglePlateSource:
         captured: list[list[str]] = []
 
         def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
             captured.append(self._filament_names_sent(request.content))
             return httpx.Response(
                 status_code=200,
@@ -1818,6 +1841,8 @@ class TestUnusedSlotSubstitutionOnSinglePlateSource:
         captured: list[list[str]] = []
 
         def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
             captured.append(self._filament_names_sent(request.content))
             return httpx.Response(
                 status_code=200,

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

@@ -258,6 +258,211 @@ class TestPrintQueueAPI:
         assert result["archive_id"] == archive.id
         assert result["ams_mapping"] == [5, -1, 2, -1]
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_falls_back_to_archive_slicer_ams_mapping_when_unset(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """When the caller sends no explicit ams_mapping, but the archive
+        carries the slicer's own saved pick for this exact printer
+        (extra_data.slicer_ams_mapping, written by a VP with "Save AMS
+        mapping" on), the queue item should inherit it — the same
+        exact-physical-spool reuse the "Mapping" button gives you, but
+        automatic when nothing was hand-edited.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
+        )
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] == [5, -1, 2, -1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_ignores_archive_slicer_ams_mapping_for_different_printer(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """A saved mapping's tray IDs only mean something relative to the
+        printer they were resolved against. Reprinting the same archive on a
+        *different* printer must not inherit it — tray 5 on printer A can
+        hold a completely different spool than tray 5 on printer B.
+        """
+        origin_printer = await printer_factory()
+        other_printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
+        )
+
+        data = {
+            "printer_id": other_printer.id,
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_ignores_archive_slicer_ams_mapping_for_model_based_dispatch(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """A model-based item (no fixed printer_id) can't know in advance
+        which printer the scheduler will pick, so a saved mapping resolved
+        against one specific printer must never be inherited here either.
+        """
+        origin_printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
+        )
+
+        data = {
+            "target_model": "X1C",
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_explicit_ams_mapping_wins_over_archive_fallback(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """An explicit ams_mapping in the request (e.g. from the filament
+        mapping panel) must take priority over the archive's saved slicer
+        pick — the fallback only fires when the caller sent nothing at all.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
+        )
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "ams_mapping": [9, -1, 1, -1],
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] == [9, -1, 1, -1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_archive_extra_data_without_slicer_mapping_key_not_used(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """extra_data present but without a slicer_ams_mapping key (the
+        common case — most archives have other metadata but no saved slicer
+        mapping) must not accidentally trip the fallback."""
+        printer = await printer_factory()
+        archive = await archive_factory(extra_data={"filament_slots": []})
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_force_color_match_overrides_beat_the_archive_fallback(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Force-color-match overrides are the caller asking the scheduler to
+        match strictly against the printer's live trays, and they are only ever
+        applied inside `_compute_ams_mapping_for_printer` — the function a
+        stored mapping makes the scheduler skip. Inheriting the saved mapping
+        here would silently retire the strictness that was just requested
+        (#2700 review).
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
+        )
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "filament_overrides": [
+                {"slot_id": 1, "type": "PLA", "color": "#FF0000", "force_color_match": True},
+            ],
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_plain_overrides_still_allow_the_archive_fallback(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Only force_color_match stands the fallback down. A plain preference
+        override is a filament swap, not a request for live colour matching, so
+        the saved mapping is still the best starting point.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
+        )
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "filament_overrides": [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}],
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] == [5, -1, 2, -1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_response_flags_saved_mapping_only_for_its_own_printer(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """`archive_has_slicer_ams_mapping` drives a badge that claims the
+        print reuses the slicer's exact trays. Global tray IDs mean nothing on
+        another printer, so the flag must be false for a row targeting one —
+        otherwise the badge is there while nothing is reused (#2700 review).
+        """
+        origin_printer = await printer_factory()
+        other_printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
+        )
+
+        own = await async_client.post(
+            "/api/v1/queue/", json={"printer_id": origin_printer.id, "archive_id": archive.id}
+        )
+        assert own.status_code == 200
+        assert own.json()["archive_has_slicer_ams_mapping"] is True
+
+        foreign = await async_client.post(
+            "/api/v1/queue/", json={"printer_id": other_printer.id, "archive_id": archive.id}
+        )
+        assert foreign.status_code == 200
+        assert foreign.json()["archive_has_slicer_ams_mapping"] is False
+
+        # Model-based: the scheduler hasn't picked a printer yet, so the
+        # mapping is not reused there either.
+        model_based = await async_client.post("/api/v1/queue/", json={"target_model": "X1C", "archive_id": archive.id})
+        assert model_based.status_code == 200
+        assert model_based.json()["archive_has_slicer_ams_mapping"] is False
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_add_to_queue_with_plate_id(

+ 203 - 0
backend/tests/integration/test_projects_api.py

@@ -1617,3 +1617,206 @@ class TestProjectFileProgress:
         projects_by_file = dict(result.all())
         assert projects_by_file[linked_file.id] == project.id
         assert projects_by_file[root_file.id] is None
+
+
+class TestSoftDeletedArchivesLeaveTheProject:
+    """Deleting a print removes it from its project, everywhere (#2731).
+
+    The default archive delete is soft (#1343): the files go, the row stays so
+    global Quick Stats keeps counting its filament / time / cost. Nothing in the
+    projects module filtered on that, so a deleted print stayed listed on the
+    project with a thumbnail pointing at a file that no longer existed — and
+    could not be unassigned, because the only unassign UI lives on the Archives
+    page, which correctly hides it.
+
+    Unlike Quick Stats, project *counts* exclude it too. A project is a piece of
+    work with a definite membership, not a lifetime total, so a project that
+    lists one print must not claim two.
+    """
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            defaults = {"name": "Deleted Archive Project", "color": "#FF0000"}
+            defaults.update(kwargs)
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create_project
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Archive + matching PrintLogEntry, as production always writes both."""
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+            from backend.app.models.print_log import PrintLogEntry
+
+            defaults = {
+                "filename": "test.3mf",
+                "file_path": "test/test.3mf",
+                "file_size": 1000,
+                "print_name": "Test Print",
+                "status": "completed",
+                "quantity": 1,
+                "thumbnail_path": "test/thumb.png",
+            }
+            defaults.update(kwargs)
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+
+            db_session.add(
+                PrintLogEntry(
+                    archive_id=archive.id,
+                    print_name=archive.print_name,
+                    status=archive.status,
+                    filament_used_grams=10.0,
+                )
+            )
+            await db_session.commit()
+            return archive
+
+        return _create_archive
+
+    @staticmethod
+    async def _soft_delete(db_session, archive) -> int:
+        """Soft-delete *archive* and return its id.
+
+        The commit expires the instance, so reading an attribute off it
+        afterwards is lazy IO outside the greenlet context (MissingGreenlet).
+        Callers take the id from here instead.
+        """
+        from datetime import datetime, timezone
+
+        archive_id = archive.id
+        archive.deleted_at = datetime.now(timezone.utc)
+        await db_session.commit()
+        return archive_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleted_archive_is_not_listed_on_the_project(
+        self, async_client: AsyncClient, project_factory, archive_factory, db_session
+    ):
+        """The reported symptom: a card with a broken preview image."""
+        project = await project_factory()
+        await archive_factory(project_id=project.id, print_name="Kept")
+        gone = await archive_factory(project_id=project.id, print_name="Deleted")
+        await self._soft_delete(db_session, gone)
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/archives")
+        assert response.status_code == 200
+        assert [a["print_name"] for a in response.json()] == ["Kept"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleted_archive_is_not_a_preview_on_the_project_card(
+        self, async_client: AsyncClient, project_factory, archive_factory, db_session
+    ):
+        """The overview page renders these as thumbnails too, so it broke there
+        as well — not just on the detail page."""
+        project = await project_factory()
+        gone = await archive_factory(project_id=project.id, print_name="Deleted")
+        await self._soft_delete(db_session, gone)
+
+        response = await async_client.get("/api/v1/projects/")
+        assert response.status_code == 200
+        row = next(p for p in response.json() if p["id"] == project.id)
+        assert row["archives"] == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_project_counts_exclude_the_deleted_archive(
+        self, async_client: AsyncClient, project_factory, archive_factory, db_session
+    ):
+        """The list shows one print, so the count must say one."""
+        project = await project_factory()
+        await archive_factory(project_id=project.id, print_name="Kept")
+        gone = await archive_factory(project_id=project.id, print_name="Deleted")
+        await self._soft_delete(db_session, gone)
+
+        response = await async_client.get("/api/v1/projects/")
+        row = next(p for p in response.json() if p["id"] == project.id)
+        assert row["archive_count"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_project_stats_exclude_the_deleted_archive(
+        self, async_client: AsyncClient, project_factory, archive_factory, db_session
+    ):
+        """Deliberate divergence from #1343: the contribution leaves the project
+        even though it stays in global Quick Stats."""
+        project = await project_factory()
+        await archive_factory(project_id=project.id, print_name="Kept")
+        gone = await archive_factory(project_id=project.id, print_name="Deleted")
+        await self._soft_delete(db_session, gone)
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert response.status_code == 200
+        stats = response.json()["stats"]
+        assert stats["total_archives"] == 1
+        assert stats["total_filament_grams"] == pytest.approx(10.0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleted_archive_is_not_in_the_project_timeline(
+        self, async_client: AsyncClient, project_factory, archive_factory, db_session
+    ):
+        """A timeline entry for it links to an archive that 404s when clicked."""
+        project = await project_factory()
+        gone = await archive_factory(project_id=project.id, print_name="Deleted")
+        await self._soft_delete(db_session, gone)
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/timeline")
+        assert response.status_code == 200
+        assert not any(e.get("description") == "Deleted" for e in response.json())
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_live_archive_is_untouched_by_all_of_this(
+        self, async_client: AsyncClient, project_factory, archive_factory
+    ):
+        """The filter must not cost a project its actual prints."""
+        project = await project_factory()
+        await archive_factory(project_id=project.id, print_name="Kept")
+
+        listing = await async_client.get(f"/api/v1/projects/{project.id}/archives")
+        assert [a["print_name"] for a in listing.json()] == ["Kept"]
+
+        stats = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert stats.json()["stats"]["total_archives"] == 1
+
+        row = next(p for p in (await async_client.get("/api/v1/projects/")).json() if p["id"] == project.id)
+        assert row["archive_count"] == 1
+        assert len(row["archives"]) == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unassigning_an_already_orphaned_link_still_works(
+        self, async_client: AsyncClient, project_factory, archive_factory, db_session
+    ):
+        """The listings hide it, but the API must still be able to clear the
+        link — that is the repair path for rows written before this fix."""
+        from sqlalchemy import select
+
+        from backend.app.models.archive import PrintArchive
+
+        project = await project_factory()
+        gone = await archive_factory(project_id=project.id, print_name="Deleted")
+        gone_id = await self._soft_delete(db_session, gone)
+
+        response = await async_client.post(
+            f"/api/v1/projects/{project.id}/remove-archives", json={"archive_ids": [gone_id]}
+        )
+        assert response.status_code == 200
+
+        db_session.expire_all()
+        result = await db_session.execute(select(PrintArchive.project_id).where(PrintArchive.id == gone_id))
+        assert result.scalar_one() is None

+ 236 - 60
backend/tests/unit/services/test_bambu_mqtt.py

@@ -3547,6 +3547,108 @@ class TestDeveloperModeDetection:
         assert mqtt_client.state.developer_mode is False
 
 
+class TestMqttCommandVerificationFailed:
+    """HMS 0500_0500_0001_0007 is the printer refusing to verify our commands (#2732).
+
+    A P1S on firmware 01.10.00.00 answers queries normally while dropping every
+    control command, so nothing else in the connection looks wrong. This HMS is
+    the only direct evidence, which makes it authoritative over the probe.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="01S00A000000000",
+            access_code="12345678",
+        )
+
+    @staticmethod
+    def _hms_payload(*entries):
+        return {"print": {"gcode_state": "IDLE", "hms": list(entries)}}
+
+    # attr 0x05000500, code 0x00010007 — the values a real P1S sends.
+    VERIFY_FAILED = {"attr": 83887360, "code": 65543}
+    OTHER_FAULT = {"attr": 0x03000200, "code": 0x00018012}
+
+    def test_hms_forces_developer_mode_false(self, mqtt_client):
+        mqtt_client.state.developer_mode = True  # what the probe wrongly concluded
+        mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
+        assert mqtt_client.state.developer_mode is False
+
+    def test_hms_is_surfaced_with_its_full_code(self, mqtt_client):
+        """The short code collapses to a useless 0500_0007 — full_code must survive."""
+        from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
+
+        mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
+        assert [e.full_code for e in mqtt_client.state.hms_errors] == [HMS_MQTT_VERIFY_FAILED]
+
+    def test_unrelated_hms_does_not_touch_developer_mode(self, mqtt_client):
+        mqtt_client.state.developer_mode = True
+        mqtt_client._process_message(self._hms_payload(self.OTHER_FAULT))
+        assert mqtt_client.state.developer_mode is True
+
+    def test_clearing_the_hms_re_arms_the_probe(self, mqtt_client):
+        """Enabling Developer Mode and restarting must not leave a stuck False."""
+        mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
+        assert mqtt_client.state.developer_mode is False
+        mqtt_client._dev_mode_probed = True
+
+        mqtt_client._process_message(self._hms_payload())
+        assert mqtt_client.state.developer_mode is None
+        assert mqtt_client._dev_mode_probed is False
+
+    def test_empty_hms_leaves_a_probe_verdict_alone(self, mqtt_client):
+        """Only the HMS-derived latch self-clears; a probe's False is not ours to undo."""
+        mqtt_client.state.developer_mode = False  # from an explicit probe refusal
+        mqtt_client._process_message(self._hms_payload())
+        assert mqtt_client.state.developer_mode is False
+
+    def test_inconclusive_probe_does_not_overwrite_the_hms_verdict(self, mqtt_client):
+        mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
+        mqtt_client._handle_dev_mode_probe_response({"command": "ams_filament_setting", "sequence_id": "3"})
+        assert mqtt_client.state.developer_mode is False
+
+
+class TestDeveloperModeProbeInconclusive:
+    """An empty probe response proves nothing and must not read as ENABLED (#2732)."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+
+    def test_empty_result_stays_unknown(self, mqtt_client):
+        """P1S 01.10.00.00 echoes the command back with no `result` field at all."""
+        mqtt_client._handle_dev_mode_probe_response({"command": "ams_filament_setting", "sequence_id": "3"})
+        assert mqtt_client.state.developer_mode is None
+
+    def test_explicit_success_still_enables(self, mqtt_client):
+        mqtt_client._handle_dev_mode_probe_response({"sequence_id": "3", "result": "success"})
+        assert mqtt_client.state.developer_mode is True
+
+    def test_verify_failure_still_disables(self, mqtt_client):
+        mqtt_client._handle_dev_mode_probe_response(
+            {"sequence_id": "3", "result": "failed", "reason": "mqtt message verify failed"}
+        )
+        assert mqtt_client.state.developer_mode is False
+
+    def test_inconclusive_response_still_clears_probe_bookkeeping(self, mqtt_client):
+        """Whatever the verdict, the response ends the probe (no retry storm)."""
+        mqtt_client._dev_mode_probe_seq = "3"
+        mqtt_client._dev_mode_probe_failures = 1
+        mqtt_client._handle_dev_mode_probe_response({"sequence_id": "3", "result": ""})
+        assert mqtt_client._dev_mode_probe_seq is None
+        assert mqtt_client._dev_mode_probe_failures == 0
+
+
 class TestDeveloperModeProbeTimeout:
     """Tests for developer mode probe timeout, retry, and forced reconnect (#887).
 
@@ -4670,6 +4772,44 @@ class TestStaleReconnect:
             mqtt_client.check_staleness()
         assert not any("zero status reports" in r.getMessage() for r in caplog.records)
 
+    def test_check_staleness_no_serial_hint_right_after_reconnect(self, mqtt_client, caplog):
+        """#2732 — _report_messages_since_connect is reset by _on_connect, so a
+        reconnect landing just before the staleness check leaves it at 0 for
+        reasons that have nothing to do with the serial. A healthy P1S was being
+        told to check its serial number 1 ms after reconnecting."""
+        import logging
+        import time
+
+        mqtt_client.state.connected = True
+        mqtt_client._last_message_time = time.time() - 120
+        mqtt_client._report_messages_since_connect = 0
+        mqtt_client._connect_time = time.monotonic()  # fresh session
+
+        with caplog.at_level(logging.WARNING):
+            mqtt_client.check_staleness()
+
+        assert mqtt_client._zero_report_hint_logged is False
+        assert not any("zero status reports" in r.getMessage() for r in caplog.records)
+        # The stale reconnect itself still happens — only the hint is suppressed.
+        assert mqtt_client._stale_reconnecting is True
+
+    def test_check_staleness_serial_hint_when_session_old_enough(self, mqtt_client, caplog):
+        """A session that has been up past the stale window and still received
+        nothing is the case the hint was written for."""
+        import logging
+        import time
+
+        mqtt_client.state.connected = True
+        mqtt_client._last_message_time = time.time() - 120
+        mqtt_client._report_messages_since_connect = 0
+        mqtt_client._connect_time = time.monotonic() - 120
+
+        with caplog.at_level(logging.WARNING):
+            mqtt_client.check_staleness()
+
+        assert mqtt_client._zero_report_hint_logged is True
+        assert any("zero status reports" in r.getMessage() for r in caplog.records)
+
     def test_check_staleness_no_serial_hint_when_reports_received(self, mqtt_client, caplog):
         """A stale connection that DID receive reports (a normal mid-session
         quiet gap) must not log the serial-number hint."""
@@ -6327,14 +6467,19 @@ class TestTrayNowH2SExternalSpoolOverride:
         assert mqtt_client.state.tray_now == 255
 
 
-class TestLastLayerFinishPhotoTrigger:
-    """Tests for #1867: layer_num→total_layer_num edge fires the finish-photo
-    moment before user End G-code (e.g. SwapMod) executes.
+class TestNoLastLayerFinishPhotoTrigger:
+    """#2547: the layer_num→total_layer_num edge must NOT trigger a photo.
+
+    That edge is the moment the printer *starts* the final layer. On the H2C
+    capture that closed #2547 it arrived at 92% with `mc_remaining_time=2`,
+    three minutes and one filament change before the print actually ended, so
+    the photo showed the toolhead mid-print over the part. It also latched
+    `_finish_photo_captured`, which locked out the two triggers that fire at a
+    real end-of-print — so these tests pin both halves: the edge is silent, and
+    the later triggers still work after it has passed.
 
-    A1 Mini firmware skips stg_cur=22 entirely, so the FINISH-state fallback
-    fires after end G-code has already moved the plate. The last-layer edge
-    is the earliest reliable "print finished" signal available across all
-    Bambu printer variants.
+    #1867 (End G-code ejecting the plate before FINISH) is handled in
+    `on_finish_photo_moment` via `print_dispatch_context`, not here.
     """
 
     @pytest.fixture
@@ -6351,92 +6496,123 @@ class TestLastLayerFinishPhotoTrigger:
         client.state.layer_num = 99
         return client
 
-    def test_fires_when_layer_reaches_total(self, mqtt_client):
+    def test_reaching_the_last_layer_fires_nothing(self, mqtt_client):
         events = []
         mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
 
         mqtt_client._process_message({"print": {"layer_num": 100}})
 
-        assert len(events) == 1
-        assert events[0]["trigger"] == "last_layer"
-        assert mqtt_client._finish_photo_captured is True
-
-    def test_does_not_fire_when_layer_still_below_total(self, mqtt_client):
-        events = []
-        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
-
-        mqtt_client._process_message({"print": {"layer_num": 99}})
-
         assert events == []
         assert mqtt_client._finish_photo_captured is False
 
-    def test_edge_only_no_double_fire(self, mqtt_client):
-        """Once fired, subsequent messages at layer_num == total must not
-        re-fire (the guard flips _finish_photo_captured to True)."""
+    def test_stage_22_still_fires_after_the_last_layer_started(self, mqtt_client):
+        """The regression the removed trigger caused: stage 22 is the good
+        moment on firmware that emits it, and it arrives *after* the last-layer
+        edge. The old latch swallowed it."""
         events = []
         mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
 
         mqtt_client._process_message({"print": {"layer_num": 100}})
-        mqtt_client._process_message({"print": {"layer_num": 100}})
-        mqtt_client._process_message({"print": {"layer_num": 100}})
+        mqtt_client.state.progress = 100
+        mqtt_client._process_message({"print": {"stg_cur": 22}})
 
-        assert len(events) == 1
+        assert [e["trigger"] for e in events] == ["stage_22"]
 
-    def test_does_not_fire_when_not_running(self, mqtt_client):
-        """If the print never went through RUNNING (Bambuddy restart mid-print,
-        firmware replay), _was_running is False and no photo trigger fires."""
-        mqtt_client._was_running = False
+    def test_finish_state_still_fires_after_the_last_layer_started(self, mqtt_client):
+        """H2C/A1 Mini never emit stage 22, so FINISH is their only moment —
+        and it is now reachable, where the latch used to block it."""
         events = []
+        completion_events = []
         mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
+        mqtt_client.on_print_complete = lambda data: completion_events.append(data)
+        mqtt_client._previous_gcode_state = "RUNNING"
 
         mqtt_client._process_message({"print": {"layer_num": 100}})
+        mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
 
-        assert events == []
+        assert [e["trigger"] for e in events] == ["finish_state"]
+        assert len(completion_events) == 1
 
-    def test_does_not_fire_when_total_layers_unknown(self, mqtt_client):
-        """total=0 (before slicer metadata arrives) must never satisfy the
-        `new_layer >= total` condition."""
-        mqtt_client.state.total_layers = 0
-        mqtt_client.state.layer_num = 0
+    def test_no_photo_trigger_fires_repeatedly_across_the_last_layer(self, mqtt_client):
+        """A three-minute last layer publishes many frames at layer_num ==
+        total. None of them may produce a moment."""
         events = []
         mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
 
-        mqtt_client._process_message({"print": {"layer_num": 0}})
+        for percent in (92, 93, 94, 95, 97, 98, 99):
+            mqtt_client._process_message({"print": {"layer_num": 100, "mc_percent": percent}})
 
         assert events == []
 
-    def test_stage_22_skipped_after_last_layer_already_fired(self, mqtt_client):
-        """Once the last-layer trigger has set _finish_photo_captured, the
-        stage-22 hook that runs later on AMS printers must be a no-op."""
-        events = []
-        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
 
-        mqtt_client._process_message({"print": {"layer_num": 100}})
-        assert len(events) == 1
+class TestPrintProgressCallback:
+    """#2547: `on_print_progress` keeps the finish-photo frame bank fresh.
 
-        mqtt_client.state.progress = 100
-        mqtt_client._process_message({"print": {"stg_cur": 22}})
+    Layer changes stop firing the instant the final layer begins, so the bank
+    would otherwise stay stale for the whole length of that layer. Progress is
+    the field that keeps advancing there — and it freezes before the End G-code
+    runs, which is what keeps a swapped plate out of the bank (#1867).
+    """
 
-        assert len(events) == 1
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
 
-    def test_finish_state_fallback_skipped_after_last_layer_fired(self, mqtt_client):
-        """The gcode_state=FINISH fallback (which fires after end G-code on
-        every printer) must be suppressed once the last-layer edge fired.
-        This is the #1867 regression check — SwapMod plate must be captured
-        by last_layer, NOT by the post-End-G-code FINISH fallback."""
-        events = []
-        completion_events = []
-        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
-        mqtt_client.on_print_complete = lambda data: completion_events.append(data)
-        mqtt_client._previous_gcode_state = "RUNNING"
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        client._was_running = True
+        return client
 
-        mqtt_client._process_message({"print": {"layer_num": 100}})
-        assert events[0]["trigger"] == "last_layer"
+    def test_fires_on_each_advance(self, mqtt_client):
+        seen = []
+        mqtt_client.on_print_progress = seen.append
 
-        mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
+        for percent in (92, 93, 94):
+            mqtt_client._process_message({"print": {"mc_percent": percent}})
 
-        assert len(events) == 1
-        assert len(completion_events) == 1
+        assert seen == [92, 93, 94]
+
+    def test_does_not_fire_when_progress_is_unchanged(self, mqtt_client):
+        """Most frames repeat the same percent; each one would otherwise cost a
+        camera grab that contends with the live view."""
+        seen = []
+        mqtt_client.on_print_progress = seen.append
+
+        mqtt_client._process_message({"print": {"mc_percent": 92}})
+        mqtt_client._process_message({"print": {"mc_percent": 92}})
+        mqtt_client._process_message({"print": {"mc_percent": 92}})
+
+        assert seen == [92]
+
+    def test_does_not_fire_when_progress_goes_backwards(self, mqtt_client):
+        """Firmware resets progress to 0 on cancel — that is not the print
+        advancing, and banking a frame there would be banking a cancelled bed."""
+        seen = []
+        mqtt_client.on_print_progress = seen.append
+
+        mqtt_client._process_message({"print": {"mc_percent": 92}})
+        mqtt_client._process_message({"print": {"mc_percent": 0}})
+
+        assert seen == [92]
+
+    def test_does_not_fire_when_no_print_is_running(self, mqtt_client):
+        mqtt_client._was_running = False
+        seen = []
+        mqtt_client.on_print_progress = seen.append
+
+        mqtt_client._process_message({"print": {"mc_percent": 92}})
+
+        assert seen == []
+
+    def test_absent_callback_is_not_an_error(self, mqtt_client):
+        mqtt_client.on_print_progress = None
+
+        mqtt_client._process_message({"print": {"mc_percent": 92}})
+
+        assert mqtt_client.state.progress == 92
 
 
 class TestPresumedPowerOffRecovery:

+ 146 - 0
backend/tests/unit/services/test_camera_rotation.py

@@ -0,0 +1,146 @@
+"""Tests for the shared camera-rotation helpers (#2708).
+
+Every other test of a rotating path patches ``apply_camera_rotation`` out and
+asserts the call, which proves the wiring but not the rotation. These drive
+the real PIL round trip, so a flipped sign or a dropped ``expand=True`` fails
+here rather than shipping.
+"""
+
+import io
+import logging
+
+import pytest
+from PIL import Image
+
+from backend.app.services.camera import apply_camera_rotation, apply_camera_rotation_to_file
+
+logger = logging.getLogger(__name__)
+
+
+def _jpeg(width: int, height: int, corner: tuple[int, int, int] = (255, 0, 0)) -> bytes:
+    """A JPEG with one distinctly coloured pixel block in the top-left corner,
+    so which way it turned is observable and not just the dimensions."""
+    img = Image.new("RGB", (width, height), (0, 0, 255))
+    for x in range(min(8, width)):
+        for y in range(min(8, height)):
+            img.putpixel((x, y), corner)
+    buf = io.BytesIO()
+    img.save(buf, format="JPEG", quality=95)
+    return buf.getvalue()
+
+
+def _open(data: bytes) -> Image.Image:
+    return Image.open(io.BytesIO(data))
+
+
+def _brightest_corner(img: Image.Image) -> str:
+    """Which corner holds the red block, sampled a few pixels in to stay clear
+    of JPEG ringing at the edges."""
+    w, h = img.size
+    probes = {
+        "top-left": (3, 3),
+        "top-right": (w - 4, 3),
+        "bottom-left": (3, h - 4),
+        "bottom-right": (w - 4, h - 4),
+    }
+    return max(probes, key=lambda name: img.getpixel(probes[name])[0] - img.getpixel(probes[name])[2])
+
+
+class TestApplyCameraRotation:
+    def test_zero_rotation_returns_the_input_object(self):
+        """Not merely equal — identity. apply_camera_rotation_to_file uses this
+        to decide there is nothing to write back."""
+        src = _jpeg(64, 32)
+        assert apply_camera_rotation(src, 0, logger) is src
+
+    def test_90_degrees_turns_clockwise(self):
+        """camera_rotation is documented as degrees *clockwise*, and PIL's
+        rotate() is counter-clockwise — the helper negates to compensate. A
+        lost negation would send the corner to bottom-right instead."""
+        src = _jpeg(64, 32)
+        assert _brightest_corner(_open(src)) == "top-left"
+
+        out = _open(apply_camera_rotation(src, 90, logger))
+        assert out.size == (32, 64)  # expand=True, so the frame is not cropped
+        assert _brightest_corner(out) == "top-right"
+
+    def test_270_degrees_turns_the_other_way(self):
+        out = _open(apply_camera_rotation(_jpeg(64, 32), 270, logger))
+        assert out.size == (32, 64)
+        assert _brightest_corner(out) == "bottom-left"
+
+    def test_180_degrees_keeps_the_dimensions_and_flips_the_corner(self):
+        out = _open(apply_camera_rotation(_jpeg(64, 32), 180, logger))
+        assert out.size == (64, 32)
+        assert _brightest_corner(out) == "bottom-right"
+
+    def test_applying_180_twice_is_the_bug_that_was_fixed(self):
+        """The regression this guards: two rotations cancel out and the photo
+        is upside-down again. Kept as a test so the invariant that
+        _stage22_finish_frames holds exactly one rotation has a stated reason.
+        """
+        src = _jpeg(64, 32)
+        once = apply_camera_rotation(src, 180, logger)
+        twice = apply_camera_rotation(once, 180, logger)
+        assert _brightest_corner(_open(once)) == "bottom-right"
+        assert _brightest_corner(_open(twice)) == "top-left"  # back to the original
+
+    def test_undecodable_bytes_return_unchanged(self):
+        """A capture path must not lose a frame because the rotate failed —
+        an unrotated photo beats no photo."""
+        junk = b"not a jpeg at all"
+        assert apply_camera_rotation(junk, 90, logger) is junk
+
+    def test_a_failed_rotate_is_logged_as_a_warning(self, caplog):
+        with caplog.at_level(logging.WARNING, logger=__name__):
+            apply_camera_rotation(b"not a jpeg at all", 90, logger)
+        assert any("Failed to apply camera rotation" in r.message for r in caplog.records)
+
+    def test_a_successful_rotate_does_not_log_at_info(self, caplog):
+        """Layer-timelapse calls this once per layer; at INFO a tall print
+        would bury the log."""
+        with caplog.at_level(logging.INFO, logger=__name__):
+            apply_camera_rotation(_jpeg(64, 32), 90, logger)
+        assert caplog.records == []
+
+
+class TestApplyCameraRotationToFile:
+    """The two finish-photo sources that let ffmpeg write the file and never
+    hold the bytes: capture_finish_photo and the timelapse last-frame extract."""
+
+    @pytest.mark.asyncio
+    async def test_rotates_in_place(self, tmp_path):
+        path = tmp_path / "finish.jpg"
+        path.write_bytes(_jpeg(64, 32))
+
+        await apply_camera_rotation_to_file(path, 90, logger)
+
+        out = _open(path.read_bytes())
+        assert out.size == (32, 64)
+        assert _brightest_corner(out) == "top-right"
+
+    @pytest.mark.asyncio
+    async def test_zero_rotation_leaves_the_file_untouched(self, tmp_path):
+        path = tmp_path / "finish.jpg"
+        original = _jpeg(64, 32)
+        path.write_bytes(original)
+
+        await apply_camera_rotation_to_file(path, 0, logger)
+
+        assert path.read_bytes() == original
+
+    @pytest.mark.asyncio
+    async def test_a_file_that_cannot_be_rotated_is_left_intact(self, tmp_path):
+        """Not truncated, not deleted — the caller's unrotated photo survives."""
+        path = tmp_path / "finish.jpg"
+        path.write_bytes(b"not a jpeg at all")
+
+        await apply_camera_rotation_to_file(path, 90, logger)
+
+        assert path.read_bytes() == b"not a jpeg at all"
+
+    @pytest.mark.asyncio
+    async def test_a_missing_file_does_not_raise(self, tmp_path):
+        """Best-effort: this runs after the capture reported success, and must
+        not turn a delivered photo into a failed one."""
+        await apply_camera_rotation_to_file(tmp_path / "gone.jpg", 90, logger)

+ 493 - 0
backend/tests/unit/services/test_external_camera_capture_coalescing.py

@@ -0,0 +1,493 @@
+"""Single-flight coalescing of one-shot external-camera captures (#2705-shape
+fix, filed against the external-camera path as a follow-up on #2707).
+
+V4L2 USB devices allow exactly one open handle - the same one-connection
+limit #2705 covers for Bambu firmware. The #2707 guards (``is_stream_active``
+/ ``try_get_active_buffered_frame``) only keep a one-shot capturer from
+competing with the fan-out live view; nothing kept the capturers from
+competing with EACH OTHER when no viewer is attached, so an Obico poll and
+the in-print frame bank (say) could each open their own connection to the
+same USB device and collide.
+
+These tests drive ``capture_frame`` at the public boundary and count how
+many times the underlying capture ran, since "how many connections did we
+open" is the entire point of the fix. Mirrors
+``test_camera_capture_coalescing.py``'s structure for the built-in path.
+"""
+
+import asyncio
+
+import pytest
+
+from backend.app.services import external_camera as ec_module
+from backend.app.services.external_camera import capture_frame, capture_in_flight
+
+FRAME_A = b"\xff\xd8" + b"a" * 200 + b"\xff\xd9"
+FRAME_B = b"\xff\xd8" + b"b" * 200 + b"\xff\xd9"
+
+
+@pytest.fixture(autouse=True)
+def _clear_inflight():
+    """The registry is module-global; don't leak tasks between tests."""
+    ec_module._inflight_captures.clear()
+    yield
+    ec_module._inflight_captures.clear()
+
+
+class RecordingCapture:
+    """Stand-in for the real capture, recording each call.
+
+    ``gate`` (when set) holds every capture open until released, which is how
+    these tests create the overlap window that used to produce two
+    connections.
+    """
+
+    def __init__(self, frames=(FRAME_A, FRAME_B), gate: asyncio.Event | None = None):
+        self.calls: list[tuple[str, str, str | None, int]] = []
+        self._frames = list(frames)
+        self._gate = gate
+        self.started = asyncio.Event()
+
+    async def __call__(self, url, camera_type, timeout, snapshot_url):
+        self.calls.append((url, camera_type, snapshot_url, timeout))
+        self.started.set()
+        if self._gate is not None:
+            await self._gate.wait()
+        return self._frames.pop(0) if self._frames else None
+
+    @property
+    def count(self) -> int:
+        return len(self.calls)
+
+
+@pytest.fixture
+def patch_capture(monkeypatch):
+    def _install(capture):
+        monkeypatch.setattr(ec_module, "_capture_frame_uncoalesced", capture)
+        return capture
+
+    return _install
+
+
+async def _let_leader_start(capture: RecordingCapture) -> None:
+    """Wait until the leader is inside the capture, so the next caller joins it.
+
+    Without this the second caller can reach the registry before the first
+    has even been scheduled, which tests a different (and uninteresting) race.
+    """
+    await asyncio.wait_for(capture.started.wait(), timeout=1)
+
+
+@pytest.mark.asyncio
+async def test_simultaneous_callers_share_one_capture(patch_capture):
+    """The reported collision: two consumers, one connection, two frames."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    leader = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=20))
+    await _let_leader_start(capture)
+    follower = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=15))
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert await leader == FRAME_A
+    assert await follower == FRAME_A
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_five_callers_one_capture(patch_capture):
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    first = asyncio.create_task(capture_frame("/dev/video1", "usb"))
+    await _let_leader_start(capture)
+    rest = [asyncio.create_task(capture_frame("/dev/video1", "usb")) for _ in range(4)]
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert await asyncio.gather(first, *rest) == [FRAME_A] * 5
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_different_cameras_do_not_coalesce(patch_capture):
+    """The one-connection limit is per camera, so the key must be too."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    one = asyncio.create_task(capture_frame("/dev/video1", "usb"))
+    await _let_leader_start(capture)
+    two = asyncio.create_task(capture_frame("/dev/video2", "usb"))
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert {await one, await two} == {FRAME_A, FRAME_B}
+    assert capture.count == 2
+    assert {url for url, *_ in capture.calls} == {"/dev/video1", "/dev/video2"}
+
+
+@pytest.mark.asyncio
+async def test_different_snapshot_url_does_not_coalesce(patch_capture):
+    """#1177's snapshot_url override routes to a different endpoint entirely -
+    two printers sharing a camera_url but differing only in snapshot_url must
+    not share a capture."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    one = asyncio.create_task(capture_frame("http://cam/", "mjpeg", snapshot_url="http://cam/frame1.jpg"))
+    await _let_leader_start(capture)
+    two = asyncio.create_task(capture_frame("http://cam/", "mjpeg", snapshot_url="http://cam/frame2.jpg"))
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert {await one, await two} == {FRAME_A, FRAME_B}
+    assert capture.count == 2
+
+
+@pytest.mark.asyncio
+async def test_coalescing_is_not_caching(patch_capture):
+    """Sequential callers each capture fresh.
+
+    Deliberate: plate detection and the finish-photo path decide things about
+    a running print from these frames, and #1397 was a finish photo a few
+    seconds stale showing the bed already lowered.
+    """
+    capture = patch_capture(RecordingCapture())
+
+    assert await capture_frame("/dev/video1", "usb") == FRAME_A
+    assert await capture_frame("/dev/video1", "usb") == FRAME_B
+    assert capture.count == 2
+
+
+@pytest.mark.asyncio
+async def test_registry_is_empty_after_a_capture_finishes(patch_capture):
+    """No leak, and nothing left behind for the next caller to join."""
+    patch_capture(RecordingCapture())
+
+    await capture_frame("/dev/video1", "usb")
+    await asyncio.sleep(0)  # let the done-callback run
+
+    assert ec_module._inflight_captures == {}
+    assert capture_in_flight("/dev/video1", "usb") is False
+
+
+@pytest.mark.asyncio
+async def test_failed_leader_does_not_poison_its_followers(patch_capture):
+    """A follower that never got its own attempt gets one when the leader fails.
+
+    Safe by then: the leader has finished, so there is no connection to
+    compete with. This also covers the follower whose timeout is LONGER than
+    the leader's — it isn't cut short by someone else's deadline.
+    """
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(frames=(None, FRAME_B), gate=gate))
+
+    leader = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=10))
+    await _let_leader_start(capture)
+    follower = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=20))
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert await leader is None
+    assert await follower == FRAME_B
+    assert capture.count == 2
+
+
+@pytest.mark.asyncio
+async def test_two_consecutive_failures_give_up(patch_capture):
+    """Bounded retry: a follower doesn't chase failing captures forever.
+
+    Two followers behind a failing leader. The first takes its own turn, the
+    second joins THAT capture, and when it fails too the second gives up
+    rather than opening a third connection.
+    """
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(frames=(None, None), gate=gate))
+
+    leader = asyncio.create_task(capture_frame("/dev/video1", "usb"))
+    await _let_leader_start(capture)
+    first = asyncio.create_task(capture_frame("/dev/video1", "usb"))
+    await asyncio.sleep(0)
+    second = asyncio.create_task(capture_frame("/dev/video1", "usb"))
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert await leader is None
+    assert await first is None
+    assert await second is None
+    # The leader's capture plus one retry — not one per disappointed caller.
+    assert capture.count == 2
+
+
+@pytest.mark.asyncio
+async def test_follower_timeout_does_not_sabotage_the_capture(patch_capture):
+    """A follower giving up leaves the capture running for everyone else.
+
+    Call sites disagree about the timeout, so a follower must be able to
+    abandon a join without cancelling a capture other callers are still
+    waiting on.
+    """
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    leader = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=30))
+    await _let_leader_start(capture)
+    impatient = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=0.01))
+    patient = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=30))
+
+    assert await impatient is None  # gave up on its own deadline
+    gate.set()
+
+    assert await leader == FRAME_A
+    assert await patient == FRAME_A  # unaffected by the one that walked away
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_cancelled_leader_still_delivers_to_followers(patch_capture):
+    """Snapshot/capture requests get cancelled routinely (client navigates
+    away mid-request). The follower must not lose the frame because the
+    caller that happened to open the connection went away."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    leader = asyncio.create_task(capture_frame("/dev/video1", "usb"))
+    await _let_leader_start(capture)
+    follower = asyncio.create_task(capture_frame("/dev/video1", "usb"))
+    await asyncio.sleep(0)
+
+    leader.cancel()
+    with pytest.raises(asyncio.CancelledError):
+        await leader
+    gate.set()
+
+    assert await follower == FRAME_A
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_cancelling_a_follower_leaves_the_leader_alone(patch_capture):
+    """The mirror case: the follower's cancellation is its own business."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    leader = asyncio.create_task(capture_frame("/dev/video1", "usb"))
+    await _let_leader_start(capture)
+    follower = asyncio.create_task(capture_frame("/dev/video1", "usb"))
+    await asyncio.sleep(0)
+
+    follower.cancel()
+    with pytest.raises(asyncio.CancelledError):
+        await follower
+    gate.set()
+
+    assert await leader == FRAME_A
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_capture_in_flight_reports_the_window(patch_capture):
+    """The predicate a diagnose-style caller would use to know it will join,
+    not measure its own connection."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    assert capture_in_flight("/dev/video1", "usb") is False
+
+    leader = asyncio.create_task(capture_frame("/dev/video1", "usb"))
+    await _let_leader_start(capture)
+
+    assert capture_in_flight("/dev/video1", "usb") is True
+    assert capture_in_flight("/dev/video2", "usb") is False  # per camera
+
+    gate.set()
+    await leader
+    await asyncio.sleep(0)
+
+    assert capture_in_flight("/dev/video1", "usb") is False
+
+
+# ---------------------------------------------------------------------------
+# Failure must arrive as None, never as an exception
+# ---------------------------------------------------------------------------
+#
+# `test_failed_leader_does_not_poison_its_followers` above covers a leader that
+# RETURNS None. A leader that RAISES is a different path: the wrapper's retry
+# loop only catches TimeoutError and CancelledError, so an escaping exception
+# would reach every follower at once and none of them would take a turn of
+# their own — one caller's failure becoming N. The per-type helpers catch
+# narrowly (aiohttp.ClientError / OSError / timeouts), so the guarantee lives
+# in _capture_frame_uncoalesced's own blanket catch.
+
+
+@pytest.mark.asyncio
+async def test_an_unexpected_error_is_reported_as_a_failed_capture():
+    """Not every failure is an OSError. An IncompleteReadError is an EOFError,
+    which none of the per-type helpers catch."""
+
+    async def raising(url, timeout):
+        raise asyncio.IncompleteReadError(partial=b"", expected=4)
+
+    import backend.app.services.external_camera as ec
+
+    original = ec._capture_snapshot
+    ec._capture_snapshot = raising
+    try:
+        result = await ec._capture_frame_uncoalesced("http://cam/snap", "snapshot", 5, None)
+    finally:
+        ec._capture_snapshot = original
+    assert result is None
+
+
+@pytest.mark.asyncio
+async def test_a_raising_leader_does_not_take_its_followers_down_with_it(monkeypatch):
+    """The whole point of coalescing is that one caller's connection serves
+    several. It must not also mean one caller's crash fails several.
+
+    Patches the per-type helper rather than ``_capture_frame_uncoalesced``,
+    deliberately: the guarantee lives in that function's blanket catch, so a
+    stand-in installed in its place would test the wrapper against a shape the
+    wrapper can no longer be handed.
+    """
+    gate = asyncio.Event()
+    attempts: list[str] = []
+
+    async def raise_then_succeed(url, timeout):
+        attempts.append(url)
+        if len(attempts) == 1:
+            await gate.wait()
+            raise RuntimeError("ffmpeg died in a way nobody catches")
+        return FRAME_B
+
+    monkeypatch.setattr(ec_module, "_capture_rtsp_frame", raise_then_succeed)
+
+    leader = asyncio.create_task(capture_frame("rtsp://cam/1", "rtsp", timeout=5))
+    await asyncio.sleep(0)
+    follower = asyncio.create_task(capture_frame("rtsp://cam/1", "rtsp", timeout=5))
+    await asyncio.sleep(0)
+    gate.set()
+
+    leader_result, follower_result = await asyncio.gather(leader, follower, return_exceptions=True)
+
+    assert not isinstance(leader_result, BaseException), f"leader raised {leader_result!r}"
+    assert not isinstance(follower_result, BaseException), f"follower raised {follower_result!r}"
+    assert leader_result is None, "the leader's own capture failed, so it gets None"
+    assert follower_result == FRAME_B, "the follower took its own turn and succeeded"
+
+
+# ---------------------------------------------------------------------------
+# The connection test must not claim a connection it never opened
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_connection_reports_when_it_shared_someone_elses_capture(patch_capture):
+    """A test landing while Obico is mid-poll gets that frame back. Reporting a
+    bare success would credit a connection this test never made — and forcing
+    its own would open the second handle the coalescing exists to prevent."""
+    from backend.app.services.external_camera import test_connection
+
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(frames=(FRAME_A,), gate=gate))
+
+    other = asyncio.create_task(capture_frame("rtsp://cam/1", "rtsp", timeout=5))
+    await _let_leader_start(capture)
+
+    tested = asyncio.create_task(test_connection("rtsp://cam/1", "rtsp"))
+    await asyncio.sleep(0)
+    gate.set()
+
+    result = await tested
+    await other
+
+    assert result["success"] is True
+    assert result["coalesced"] is True
+    assert capture.count == 1, "no second connection was opened"
+
+
+@pytest.mark.asyncio
+async def test_connection_reports_its_own_capture_as_not_coalesced(patch_capture):
+    from backend.app.services.external_camera import test_connection
+
+    capture = patch_capture(RecordingCapture(frames=(FRAME_A,)))
+    result = await test_connection("rtsp://cam/1", "rtsp")
+
+    assert result["success"] is True
+    assert result["coalesced"] is False
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_connection_reports_coalesced_on_the_failure_path_too(patch_capture):
+    """The flag describes where the answer came from, not whether it was good."""
+    from backend.app.services.external_camera import test_connection
+
+    capture = patch_capture(RecordingCapture(frames=(None,)))
+    result = await test_connection("rtsp://cam/1", "rtsp")
+
+    assert result["success"] is False
+    assert result["coalesced"] is False
+    assert capture.count == 1
+
+
+# ---------------------------------------------------------------------------
+# Credentials must not reach the log
+# ---------------------------------------------------------------------------
+#
+# camera.py's coalescing is keyed by IP address and has nothing to redact.
+# These keys carry the camera URL, and an RTSP camera URL routinely embeds
+# user:pass@ — which is why every other URL log in the module redacts.
+
+CREDENTIALED_URL = "rtsp://admin:hunter2@192.168.1.50:554/Streaming/Channels/101"
+
+
+@pytest.mark.asyncio
+async def test_the_reuse_log_line_redacts_the_password(patch_capture, caplog):
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(frames=(FRAME_A,), gate=gate))
+
+    with caplog.at_level("DEBUG", logger=ec_module.__name__):
+        leader = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
+        await _let_leader_start(capture)
+        follower = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
+        await asyncio.sleep(0)
+        gate.set()
+        await asyncio.gather(leader, follower)
+
+    assert not [r.getMessage() for r in caplog.records if "hunter2" in r.getMessage()]
+
+
+@pytest.mark.asyncio
+async def test_the_gave_up_waiting_log_line_redacts_the_password(patch_capture, caplog):
+    """This one is a warning, so it shows at the default level and lands in
+    support bundles."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(frames=(FRAME_A,), gate=gate))
+
+    with caplog.at_level("DEBUG", logger=ec_module.__name__):
+        leader = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
+        await _let_leader_start(capture)
+        assert await capture_frame(CREDENTIALED_URL, "rtsp", timeout=0) is None
+        gate.set()
+        await leader
+
+    messages = [r.getMessage() for r in caplog.records]
+    assert any("Gave up waiting" in m for m in messages), "the timeout path did not run"
+    assert not [m for m in messages if "hunter2" in m]
+
+
+@pytest.mark.asyncio
+async def test_the_failed_capture_log_line_redacts_the_password(patch_capture, caplog):
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(frames=(None, FRAME_B), gate=gate))
+
+    with caplog.at_level("DEBUG", logger=ec_module.__name__):
+        leader = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
+        await _let_leader_start(capture)
+        follower = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
+        await asyncio.sleep(0)
+        gate.set()
+        await asyncio.gather(leader, follower)
+
+    assert not [r.getMessage() for r in caplog.records if "hunter2" in r.getMessage()]

+ 327 - 1
backend/tests/unit/services/test_layer_timelapse.py

@@ -4,9 +4,10 @@ Tests for the layer timelapse service.
 These tests cover session management and pure logic functions.
 """
 
+import time
 from datetime import datetime
 from pathlib import Path
-from unittest.mock import AsyncMock, MagicMock, patch
+from unittest.mock import ANY, AsyncMock, MagicMock, patch
 
 import pytest
 
@@ -246,6 +247,99 @@ class TestLayerChangeLogic:
                     assert session.frame_count == 0  # But frame count not incremented
 
 
+class TestCaptureLayerAppliesRotation:
+    """camera_rotation was previously only wired into the notification-
+    snapshot path, so a layer-timelapse video came out upside-down whenever
+    the printer had a rotation configured. capture_layer now applies it to
+    every captured frame, whether fresh or reused from the live view's
+    buffer, before writing to disk."""
+
+    @pytest.mark.asyncio
+    async def test_rotates_fresh_capture_when_configured(self, tmp_path):
+        from backend.app.services.layer_timelapse import TimelapseSession
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = tmp_path
+
+            with patch.object(Path, "mkdir"):
+                session = TimelapseSession(1, 100, "/dev/video1", "usb", rotation=180)
+
+                with (
+                    patch("backend.app.api.routes.camera.live_frame_for_capture", return_value=(False, None)),
+                    patch(
+                        "backend.app.services.layer_timelapse.capture_frame",
+                        new_callable=AsyncMock,
+                        return_value=b"\xff\xd8unrotated\xff\xd9",
+                    ),
+                    patch(
+                        "backend.app.services.layer_timelapse.apply_camera_rotation",
+                        return_value=b"\xff\xd8rotated\xff\xd9",
+                    ) as mock_rotate,
+                    patch.object(Path, "write_bytes") as mock_write,
+                ):
+                    result = await session.capture_layer(1)
+
+        assert result is True
+        mock_rotate.assert_called_once_with(b"\xff\xd8unrotated\xff\xd9", 180, ANY)
+        mock_write.assert_called_once_with(b"\xff\xd8rotated\xff\xd9")
+
+    @pytest.mark.asyncio
+    async def test_rotates_buffered_frame_when_configured(self, tmp_path):
+        from backend.app.services.layer_timelapse import TimelapseSession
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = tmp_path
+
+            with patch.object(Path, "mkdir"):
+                session = TimelapseSession(1, 100, "/dev/video1", "usb", rotation=90)
+
+                with (
+                    patch(
+                        "backend.app.api.routes.camera.live_frame_for_capture",
+                        return_value=(True, b"\xff\xd8buffered\xff\xd9"),
+                    ),
+                    patch(
+                        "backend.app.services.layer_timelapse.apply_camera_rotation",
+                        return_value=b"\xff\xd8rotated\xff\xd9",
+                    ) as mock_rotate,
+                    patch.object(Path, "write_bytes") as mock_write,
+                ):
+                    result = await session.capture_layer(1)
+
+        assert result is True
+        mock_rotate.assert_called_once_with(b"\xff\xd8buffered\xff\xd9", 90, ANY)
+        mock_write.assert_called_once_with(b"\xff\xd8rotated\xff\xd9")
+
+    @pytest.mark.asyncio
+    async def test_skips_rotation_when_not_configured(self, tmp_path):
+        """Default rotation=0 - no-op, and must not even call apply_camera_rotation
+        (avoids the PIL decode/re-encode round trip for the common case)."""
+        from backend.app.services.layer_timelapse import TimelapseSession
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = tmp_path
+
+            with patch.object(Path, "mkdir"):
+                session = TimelapseSession(1, 100, "/dev/video1", "usb")
+                assert session.rotation == 0
+
+                with (
+                    patch("backend.app.api.routes.camera.live_frame_for_capture", return_value=(False, None)),
+                    patch(
+                        "backend.app.services.layer_timelapse.capture_frame",
+                        new_callable=AsyncMock,
+                        return_value=b"\xff\xd8unrotated\xff\xd9",
+                    ),
+                    patch("backend.app.services.layer_timelapse.apply_camera_rotation") as mock_rotate,
+                    patch.object(Path, "write_bytes") as mock_write,
+                ):
+                    result = await session.capture_layer(1)
+
+        assert result is True
+        mock_rotate.assert_not_called()
+        mock_write.assert_called_once_with(b"\xff\xd8unrotated\xff\xd9")
+
+
 class TestOnLayerChange:
     """Tests for the on_layer_change callback."""
 
@@ -318,3 +412,235 @@ class TestGetActiveSessions:
                 assert 1 in _active_sessions
 
                 cancel_session(1)
+
+
+class TestCleanupOrphanedTimelapseSessions:
+    """_active_sessions is in-memory only, so a process restart mid-print
+    loses track of an active session without ever cleaning up its frames
+    directory (or a stitched-but-not-attached output .mp4). Confirmed live:
+    38MB of exactly this leftover on Carl's OrangePi after several restarts
+    during testing. cleanup_orphaned_timelapse_sessions() sweeps for it."""
+
+    def _touch_old(self, path, age_seconds=600):
+        import os
+
+        path.touch()
+        old = time.time() - age_seconds
+        os.utime(path, (old, old))
+
+    def _mkdir_old(self, path, age_seconds=600):
+        import os
+
+        path.mkdir(parents=True)
+        old = time.time() - age_seconds
+        os.utime(path, (old, old))
+
+    def test_removes_orphaned_frame_dir_and_stray_output(self, tmp_path):
+        from backend.app.services.layer_timelapse import (
+            _active_sessions,
+            cleanup_orphaned_timelapse_sessions,
+        )
+
+        _active_sessions.clear()
+        printer_dir = tmp_path / "timelapse_frames" / "1"
+        self._mkdir_old(printer_dir / "20260101_000000")
+        self._touch_old(printer_dir / "timelapse_20260101_000000.mp4")
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = tmp_path
+            removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300)
+
+        assert removed == 2
+        assert not (printer_dir / "20260101_000000").exists()
+        assert not (printer_dir / "timelapse_20260101_000000.mp4").exists()
+
+    def test_spares_the_currently_active_session(self, tmp_path):
+        from backend.app.services.layer_timelapse import (
+            TimelapseSession,
+            _active_sessions,
+            cleanup_orphaned_timelapse_sessions,
+        )
+
+        _active_sessions.clear()
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = tmp_path
+            session = TimelapseSession(1, 100, "/dev/video1", "usb")
+            _active_sessions[1] = session
+            import os
+
+            old = time.time() - 600
+            os.utime(session.frames_dir, (old, old))
+
+            removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300)
+
+        assert removed == 0
+        assert session.frames_dir.exists()
+        _active_sessions.clear()
+
+    def test_spares_recently_modified_entries(self, tmp_path):
+        """Defensive margin: something modified within min_age_seconds is
+        left alone even if it doesn't match an active session, in case this
+        is ever invoked while a session is mid-creation."""
+        from backend.app.services.layer_timelapse import (
+            _active_sessions,
+            cleanup_orphaned_timelapse_sessions,
+        )
+
+        _active_sessions.clear()
+        printer_dir = tmp_path / "timelapse_frames" / "1"
+        printer_dir.mkdir(parents=True)
+        (printer_dir / "20260101_000000").mkdir()
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = tmp_path
+            removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300)
+
+        assert removed == 0
+        assert (printer_dir / "20260101_000000").exists()
+
+    def test_no_base_dir_is_a_no_op(self, tmp_path):
+        from backend.app.services.layer_timelapse import cleanup_orphaned_timelapse_sessions
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = tmp_path / "does-not-exist"
+            removed = cleanup_orphaned_timelapse_sessions()
+
+        assert removed == 0
+
+    def test_ignores_non_numeric_printer_dirs(self, tmp_path):
+        """Defensive: unrelated directories under timelapse_frames/ (there
+        shouldn't be any, but printer_id is parsed from the dir name) must
+        not raise."""
+        from backend.app.services.layer_timelapse import (
+            _active_sessions,
+            cleanup_orphaned_timelapse_sessions,
+        )
+
+        _active_sessions.clear()
+        (tmp_path / "timelapse_frames" / "not-a-printer-id").mkdir(parents=True)
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = tmp_path
+            removed = cleanup_orphaned_timelapse_sessions()
+
+        assert removed == 0
+
+    def test_spares_a_session_that_is_mid_stitch(self, tmp_path):
+        """on_print_complete drops the session from _active_sessions before it
+        hands frames_dir to ffmpeg, so for the length of a stitch (up to 300s)
+        the directory matches no active session. Its mtime is the last layer's
+        frame write, which on a tall print's final layer is easily older than
+        the age margin — and the margin's default IS the stitch timeout, so it
+        offers no headroom here. _finalizing_sessions covers that window."""
+        import os
+
+        from backend.app.services.layer_timelapse import (
+            TimelapseSession,
+            _active_sessions,
+            _finalizing_sessions,
+            cleanup_orphaned_timelapse_sessions,
+        )
+
+        _active_sessions.clear()
+        _finalizing_sessions.clear()
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = tmp_path
+            session = TimelapseSession(1, 100, "/dev/video1", "usb")
+            (session.frames_dir / "layer_00001.jpg").write_bytes(b"x")
+            old = time.time() - 600
+            os.utime(session.frames_dir, (old, old))
+
+            # Exactly the state on_print_complete is in while ffmpeg runs.
+            _active_sessions.pop(1, None)
+            _finalizing_sessions[1] = session.session_id
+
+            removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300)
+
+        assert removed == 0
+        assert session.frames_dir.exists(), "ffmpeg's input was deleted mid-stitch"
+        _finalizing_sessions.clear()
+
+    @pytest.mark.asyncio
+    async def test_on_print_complete_clears_the_finalizing_marker(self, tmp_path):
+        """Including when the stitch fails — a leaked marker would make the
+        sweep skip that printer's leftovers forever."""
+        from backend.app.services.layer_timelapse import (
+            TimelapseSession,
+            _active_sessions,
+            _finalizing_sessions,
+            on_print_complete,
+        )
+
+        _active_sessions.clear()
+        _finalizing_sessions.clear()
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = tmp_path
+            session = TimelapseSession(1, 100, "/dev/video1", "usb")
+            session.frame_count = 3
+            _active_sessions[1] = session
+
+            with patch.object(TimelapseSession, "stitch", AsyncMock(side_effect=RuntimeError("ffmpeg died"))):
+                result = await on_print_complete(1)
+
+        assert result is None
+        assert 1 not in _finalizing_sessions
+
+    def test_leaves_unrelated_files_alone(self, tmp_path):
+        """Only this module's own artifacts are swept. A file that is neither a
+        session directory nor timelapse_<id>.mp4 was put there by something
+        else, and age is not a reason to delete it."""
+        from backend.app.services.layer_timelapse import (
+            _active_sessions,
+            cleanup_orphaned_timelapse_sessions,
+        )
+
+        _active_sessions.clear()
+        printer_dir = tmp_path / "timelapse_frames" / "1"
+        printer_dir.mkdir(parents=True)
+        stranger = printer_dir / "notes.txt"
+        self._touch_old(stranger)
+        self._touch_old(printer_dir / "timelapse_20260101_000000.mp4")
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = tmp_path
+            removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300)
+
+        assert removed == 1
+        assert stranger.exists()
+        assert not (printer_dir / "timelapse_20260101_000000.mp4").exists()
+
+    def test_a_removal_that_fails_is_not_counted_as_removed(self, tmp_path):
+        """The count and the log line are the only evidence an operator has of
+        what was deleted, so a failed rmtree must not be reported as a success.
+
+        The stub honours rmtree's real contract — ignore_errors=True swallows
+        the failure and returns normally — because that is the whole point: a
+        caller passing it gets a silent no-op that the surrounding
+        ``except OSError`` can never see, and would still count and log the
+        directory as removed. A stub that raised unconditionally would pass
+        either way and prove nothing.
+        """
+        from backend.app.services.layer_timelapse import (
+            _active_sessions,
+            cleanup_orphaned_timelapse_sessions,
+        )
+
+        _active_sessions.clear()
+        printer_dir = tmp_path / "timelapse_frames" / "1"
+        self._mkdir_old(printer_dir / "20260101_000000")
+
+        def rmtree_on_read_only_fs(path, ignore_errors=False, **kwargs):
+            if ignore_errors:
+                return  # silently does nothing, exactly like the real thing
+            raise OSError("read-only fs")
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = tmp_path
+            with patch("backend.app.services.layer_timelapse.shutil.rmtree", rmtree_on_read_only_fs):
+                removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300)
+
+        assert removed == 0, "a directory that is still on disk was reported as removed"
+        assert (printer_dir / "20260101_000000").exists()

+ 77 - 0
backend/tests/unit/services/test_print_dispatch_context.py

@@ -0,0 +1,77 @@
+"""Tests for the injected-End-G-code flag the finish photo depends on (#2547).
+
+The flag decides whether the finish photo comes from the camera (the print is
+still on the plate) or from the in-print frame bank (a SwapMod snippet ejected
+it — #1867). Getting it wrong in either direction ships the wrong photo, so the
+two-step pending/adopt handoff exists to guarantee the flag can never outlive
+the print it was recorded for.
+"""
+
+import pytest
+
+from backend.app.services import print_dispatch_context
+
+
+@pytest.fixture(autouse=True)
+def _clean():
+    for printer_id in (1, 2):
+        print_dispatch_context.clear(printer_id)
+    yield
+    for printer_id in (1, 2):
+        print_dispatch_context.clear(printer_id)
+
+
+def test_unknown_printer_reports_no_injection():
+    assert print_dispatch_context.end_gcode_injected(1) is False
+
+
+def test_pending_flag_only_counts_once_the_print_starts():
+    """Dispatch can fail between upload and start. Until the printer confirms a
+    print running, the flag must not affect anything."""
+    print_dispatch_context.mark_pending(1)
+
+    assert print_dispatch_context.end_gcode_injected(1) is False
+
+    assert print_dispatch_context.adopt(1) is True
+    assert print_dispatch_context.end_gcode_injected(1) is True
+
+
+def test_adopting_consumes_the_pending_flag():
+    """A second print must not inherit the first print's snippet."""
+    print_dispatch_context.mark_pending(1)
+    print_dispatch_context.adopt(1)
+
+    assert print_dispatch_context.adopt(1) is False
+    assert print_dispatch_context.end_gcode_injected(1) is False
+
+
+def test_a_print_we_did_not_dispatch_clears_the_previous_flag():
+    """The failure this two-step design exists to prevent: a print started from
+    the slicer or SD card right after a SwapMod job would otherwise inherit its
+    flag and get a mid-print banked frame instead of its own finish photo."""
+    print_dispatch_context.mark_pending(1)
+    print_dispatch_context.adopt(1)
+    assert print_dispatch_context.end_gcode_injected(1) is True
+
+    # Next print start, with nothing pending — i.e. Bambuddy didn't send it.
+    assert print_dispatch_context.adopt(1) is False
+    assert print_dispatch_context.end_gcode_injected(1) is False
+
+
+def test_printers_do_not_share_flags():
+    print_dispatch_context.mark_pending(1)
+    print_dispatch_context.adopt(1)
+
+    assert print_dispatch_context.end_gcode_injected(1) is True
+    assert print_dispatch_context.end_gcode_injected(2) is False
+
+
+def test_clear_forgets_pending_and_active():
+    print_dispatch_context.mark_pending(1)
+    print_dispatch_context.adopt(1)
+    print_dispatch_context.mark_pending(1)
+
+    print_dispatch_context.clear(1)
+
+    assert print_dispatch_context.end_gcode_injected(1) is False
+    assert print_dispatch_context.adopt(1) is False

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 871 - 44
backend/tests/unit/services/test_virtual_printer.py


+ 157 - 0
backend/tests/unit/test_connection_watchdog.py

@@ -0,0 +1,157 @@
+"""Tests for the dead-MQTT-session watchdog (#2732).
+
+``check_staleness()`` guards the "connected but silent" session and returns
+immediately once ``state.connected`` is False — from there, paho's own
+auto-reconnect is the only thing still watching. The #2732 bundle shows what
+happens when that stops making progress: a P1S dropped on a keep-alive timeout
+at 02:19 and did not reconnect until 11:24, nine hours offline with the UI open
+throughout.
+
+This watchdog is the backstop. The rules it has to keep are narrow on purpose —
+it must not interfere with a session that is recovering on its own, and it must
+not churn clients for printers that are simply switched off.
+"""
+
+import time
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.main import (
+    CONNECTION_WATCHDOG_OFFLINE_GRACE,
+    CONNECTION_WATCHDOG_RETRY_INTERVAL,
+    _connection_watchdog_last_attempt,
+    _recover_dead_printer_sessions,
+)
+
+
+def _client(*, connected: bool, last_message_age: float | None, ip: str = "192.168.1.100"):
+    """Stand-in for BambuMQTTClient with only the fields the watchdog reads."""
+    return SimpleNamespace(
+        state=SimpleNamespace(connected=connected),
+        _last_message_time=0.0 if last_message_age is None else time.time() - last_message_age,
+        ip_address=ip,
+        last_connect_error=None,
+        force_reconnect_stale_session=MagicMock(),
+    )
+
+
+async def _sweep(clients: dict, *, port_open: bool = True):
+    with (
+        patch("backend.app.main.printer_manager._clients", clients),
+        patch("backend.app.services.printer_diagnostic.check_port", AsyncMock(return_value=port_open)),
+    ):
+        return await _recover_dead_printer_sessions()
+
+
+@pytest.fixture(autouse=True)
+def _clear_cooldowns():
+    _connection_watchdog_last_attempt.clear()
+    yield
+    _connection_watchdog_last_attempt.clear()
+
+
+class TestRebuildsDeadSessions:
+    @pytest.mark.asyncio
+    async def test_rebuilds_a_long_dead_session(self):
+        """The #2732 case: offline for hours, printer answering the whole time."""
+        client = _client(connected=False, last_message_age=32718.0)
+
+        assert await _sweep({1: client}) == 1
+        client.force_reconnect_stale_session.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_reconnect_reason_names_the_duration(self):
+        client = _client(connected=False, last_message_age=32718.0)
+        await _sweep({1: client})
+        assert "32718" in client.force_reconnect_stale_session.call_args.args[0]
+
+    @pytest.mark.asyncio
+    async def test_sweeps_every_printer_in_the_farm(self):
+        clients = {i: _client(connected=False, last_message_age=9999.0) for i in range(1, 4)}
+        assert await _sweep(clients) == 3
+
+
+class TestLeavesHealthyAndRecoveringSessionsAlone:
+    @pytest.mark.asyncio
+    async def test_connected_printer_is_untouched(self):
+        client = _client(connected=True, last_message_age=99999.0)
+        assert await _sweep({1: client}) == 0
+        client.force_reconnect_stale_session.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_inside_the_grace_period_paho_keeps_the_job(self):
+        """Below the grace window a reconnect may well be in flight; interrupting
+        it would turn a self-healing blip into a forced session rebuild."""
+        client = _client(connected=False, last_message_age=CONNECTION_WATCHDOG_OFFLINE_GRACE - 30)
+        assert await _sweep({1: client}) == 0
+        client.force_reconnect_stale_session.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_a_client_that_never_had_a_session_is_left_to_paho(self):
+        """No inbound message ever means this is the initial connect, where
+        retrying is both correct and the only thing to do."""
+        client = _client(connected=False, last_message_age=None)
+        assert await _sweep({1: client}) == 0
+        client.force_reconnect_stale_session.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_reconnecting_clears_the_cooldown(self):
+        """A printer that comes back must not carry a stale cooldown into its
+        next outage."""
+        client = _client(connected=False, last_message_age=9999.0)
+        await _sweep({1: client})
+        assert 1 in _connection_watchdog_last_attempt
+
+        client.state.connected = True
+        await _sweep({1: client})
+        assert 1 not in _connection_watchdog_last_attempt
+
+
+class TestUnreachablePrinters:
+    @pytest.mark.asyncio
+    async def test_switched_off_printer_is_not_rebuilt(self):
+        """Rebuilding a client against a host that isn't answering achieves
+        nothing and would log a warning per printer all night."""
+        client = _client(connected=False, last_message_age=9999.0)
+        assert await _sweep({1: client}, port_open=False) == 0
+        client.force_reconnect_stale_session.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_unreachable_printer_still_takes_the_cooldown(self):
+        """Otherwise every sweep re-probes the port of every dead printer."""
+        client = _client(connected=False, last_message_age=9999.0)
+        await _sweep({1: client}, port_open=False)
+        assert 1 in _connection_watchdog_last_attempt
+
+
+class TestRetryInterval:
+    @pytest.mark.asyncio
+    async def test_does_not_rebuild_again_within_the_interval(self):
+        client = _client(connected=False, last_message_age=9999.0)
+        assert await _sweep({1: client}) == 1
+        assert await _sweep({1: client}) == 0
+        client.force_reconnect_stale_session.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_retries_once_the_interval_has_passed(self):
+        client = _client(connected=False, last_message_age=9999.0)
+        await _sweep({1: client})
+        _connection_watchdog_last_attempt[1] -= CONNECTION_WATCHDOG_RETRY_INTERVAL + 1
+
+        assert await _sweep({1: client}) == 1
+        assert client.force_reconnect_stale_session.call_count == 2
+
+
+class TestSweepIsFaultTolerant:
+    @pytest.mark.asyncio
+    async def test_one_broken_client_does_not_stop_the_others(self):
+        """A farm sweep that aborts on the first bad client would leave every
+        printer after it unrecovered."""
+        bad = _client(connected=False, last_message_age=9999.0)
+        bad.force_reconnect_stale_session.side_effect = RuntimeError("boom")
+        good = _client(connected=False, last_message_age=9999.0)
+
+        assert await _sweep({1: bad, 2: good}) == 2
+        good.force_reconnect_stale_session.assert_called_once()

+ 66 - 0
backend/tests/unit/test_finish_photo_from_timelapse.py

@@ -179,3 +179,69 @@ async def test_polls_until_file_appears(tmp_path: Path, patched_session, monkeyp
 
     assert result is not None
     assert result.startswith("finish_")
+
+
+async def test_extracted_frame_is_rotated_when_configured(tmp_path: Path, patched_session, monkeypatch):
+    """#2708: this source hands a path to ffmpeg and never holds the bytes, so
+    it was the one finish-photo source that ignored camera_rotation entirely.
+    A built-in-camera print with a timelapse prefers this source over the live
+    grab, so leaving it out meant the orientation depended on which source won.
+    """
+    import io
+
+    from PIL import Image
+
+    monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
+    video_relpath = Path("archive/1/print/timelapse.mp4")
+    video_abspath = tmp_path / video_relpath
+    video_abspath.parent.mkdir(parents=True, exist_ok=True)
+    video_abspath.write_bytes(b"x" * 100)
+
+    async def fake_extract(src, dst):
+        buf = io.BytesIO()
+        Image.new("RGB", (64, 32), (0, 0, 255)).save(buf, format="JPEG")
+        dst.write_bytes(buf.getvalue())
+        return True
+
+    monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.0)
+    patched_session.timelapse_path = str(video_relpath)
+
+    with patch("backend.app.services.camera.extract_video_last_frame", new=fake_extract):
+        result, _ = await _capture_finish_photo_from_timelapse(
+            archive_id=42,
+            archive_dir=tmp_path / "archive_dir",
+            rotation=90,
+        )
+
+    assert result is not None
+    written = tmp_path / "archive_dir" / "photos" / result
+    # 64x32 turned a quarter turn: the file on disk is the rotated one, not
+    # what ffmpeg wrote.
+    assert Image.open(io.BytesIO(written.read_bytes())).size == (32, 64)
+
+
+async def test_extracted_frame_is_untouched_without_a_rotation(tmp_path: Path, patched_session, monkeypatch):
+    """The default path must not decode and re-encode ffmpeg's output for
+    nothing — that would cost a generation of JPEG quality on every print."""
+    monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
+    video_relpath = Path("archive/1/print/timelapse.mp4")
+    video_abspath = tmp_path / video_relpath
+    video_abspath.parent.mkdir(parents=True, exist_ok=True)
+    video_abspath.write_bytes(b"x" * 100)
+
+    extracted = b"\xff\xd8" + b"\x00" * 50
+
+    async def fake_extract(src, dst):
+        dst.write_bytes(extracted)
+        return True
+
+    monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.0)
+    patched_session.timelapse_path = str(video_relpath)
+
+    with patch("backend.app.services.camera.extract_video_last_frame", new=fake_extract):
+        result, _ = await _capture_finish_photo_from_timelapse(
+            archive_id=42,
+            archive_dir=tmp_path / "archive_dir",
+        )
+
+    assert (tmp_path / "archive_dir" / "photos" / result).read_bytes() == extracted

+ 566 - 15
backend/tests/unit/test_finish_photo_moment_sync.py

@@ -15,6 +15,7 @@ by the consumer. These tests pin the producer side of that contract.
 """
 
 import asyncio
+import logging
 from contextlib import asynccontextmanager
 from types import SimpleNamespace
 from unittest.mock import AsyncMock
@@ -23,6 +24,7 @@ import pytest
 
 from backend.app import main as main_module
 from backend.app.main import on_finish_photo_moment
+from backend.app.services import print_dispatch_context
 
 
 @asynccontextmanager
@@ -54,11 +56,13 @@ def _clean_state():
     main_module._stage22_finish_frames.clear()
     main_module._inprint_frame_bank.clear()
     main_module._inprint_frame_bank_ts.clear()
+    print_dispatch_context.clear(7)
     yield
     main_module._stage22_finish_in_flight.clear()
     main_module._stage22_finish_frames.clear()
     main_module._inprint_frame_bank.clear()
     main_module._inprint_frame_bank_ts.clear()
+    print_dispatch_context.clear(7)
 
 
 @pytest.fixture
@@ -78,6 +82,18 @@ def patched_env(fake_printer, monkeypatch):
         "backend.app.api.routes.camera.get_buffered_frame",
         lambda _pid: None,
     )
+
+    # #2547: default the plate restore to "print height unknown", so tests that
+    # aren't about the restore never reach the G-code path. Tests that ARE about
+    # it override these two.
+    async def _no_height(_printer_id, _data, _logger):
+        return None
+
+    async def _not_blocked(_printer_id):
+        return False
+
+    monkeypatch.setattr(main_module, "_max_z_for_current_print", _no_height)
+    monkeypatch.setattr(main_module, "_plate_restore_is_blocked_by_queue", _not_blocked)
     return fake_printer
 
 
@@ -212,10 +228,12 @@ async def test_consumer_wait_unblocked_when_producer_completes(patched_env, monk
     await producer
 
 
-async def test_finish_state_prefers_banked_frame(patched_env, monkeypatch):
-    """#1867: on the FINISH-state fallback (stage-22-less firmware, e.g. A1
-    Mini) a live grab captures the post-swap plate. When a banked in-print
-    frame exists it must be used instead, and the live grab must not run."""
+async def test_finish_state_prefers_banked_frame_when_end_gcode_was_injected(patched_env, monkeypatch):
+    """#1867: when Bambuddy injected End G-code, a SwapMod snippet may already
+    have ejected the plate by FINISH — so the banked in-print frame is used and
+    the live grab must not run."""
+    print_dispatch_context.mark_pending(patched_env.id)
+    print_dispatch_context.adopt(patched_env.id)
     main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked"
 
     live_called = {"n": 0}
@@ -232,10 +250,31 @@ async def test_finish_state_prefers_banked_frame(patched_env, monkeypatch):
     assert live_called["n"] == 0
 
 
-async def test_finish_state_falls_back_to_live_when_no_bank(patched_env, monkeypatch):
-    """No banked frame (feature just enabled, tiny print, capture failures) —
-    the FINISH-state path still live-grabs so we degrade to the old behaviour
-    rather than sending a text-only notification."""
+async def test_finish_state_grabs_live_when_no_end_gcode_was_injected(patched_env, monkeypatch):
+    """#2547: the ordinary case. Nothing moved the plate, the toolhead is
+    parked, and the print is still sitting there — so the live frame is the
+    finished print, and a banked mid-print frame must NOT win over it.
+
+    Preferring the bank here unconditionally, which is what this code used to
+    do, is how the H2C shipped a photo with the toolhead over the part."""
+    main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked-midprint"
+
+    async def _live(**_kwargs):
+        return b"\xff\xd8live-finished-print"
+
+    monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
+
+    await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+    assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live-finished-print"
+
+
+async def test_finish_state_falls_back_to_live_when_bank_is_empty(patched_env, monkeypatch):
+    """End G-code was injected but nothing was ever banked (feature just
+    enabled, tiny print, capture failures). Degrade to a live grab rather than
+    sending a text-only notification."""
+    print_dispatch_context.mark_pending(patched_env.id)
+    print_dispatch_context.adopt(patched_env.id)
 
     async def _live(**_kwargs):
         return b"\xff\xd8live"
@@ -247,10 +286,12 @@ async def test_finish_state_falls_back_to_live_when_no_bank(patched_env, monkeyp
     assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live"
 
 
-async def test_last_layer_trigger_ignores_bank(patched_env, monkeypatch):
-    """The `last_layer` trigger fires before the swap and gives cleaner
-    (parked-toolhead) framing via a live grab — the bank is only for the
-    post-swap `finish_state` fallback, so it must be ignored here."""
+async def test_stage_22_trigger_ignores_bank(patched_env, monkeypatch):
+    """The `stage_22` trigger fires before any End G-code and gives cleaner
+    (parked-toolhead, plate-still-up) framing via a live grab — the bank is only
+    for the post-swap `finish_state` path, so it must be ignored here."""
+    print_dispatch_context.mark_pending(patched_env.id)
+    print_dispatch_context.adopt(patched_env.id)
     main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked"
 
     async def _live(**_kwargs):
@@ -258,7 +299,7 @@ async def test_last_layer_trigger_ignores_bank(patched_env, monkeypatch):
 
     monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
 
-    await on_finish_photo_moment(patched_env.id, {"trigger": "last_layer"})
+    await on_finish_photo_moment(patched_env.id, {"trigger": "stage_22"})
 
     assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live"
 
@@ -299,10 +340,25 @@ async def test_bank_throttles_within_interval(monkeypatch):
     assert main_module._inprint_frame_bank[3] == b"frame-1"
 
 
-async def test_bank_always_refreshes_on_last_layer(monkeypatch):
+async def test_bank_throttles_on_the_last_layer_too(monkeypatch):
+    """#2547: the last layer used to bypass the throttle so it always got a
+    fresh frame. Now that progress advances also drive banking, that exemption
+    would fire a camera grab on every percent tick of a multi-minute last layer
+    — and each grab contends with the live view for the single RTSP slot."""
     counter = _bank_env(monkeypatch, total_layers=10)
     await main_module._maybe_bank_inprint_frame(3, 5)  # banks frame-1
-    # Last layer bypasses the throttle for the best final framing.
+    await main_module._maybe_bank_inprint_frame(3, 10)  # last layer, within 25s
+    assert counter["n"] == 1
+    assert main_module._inprint_frame_bank[3] == b"frame-1"
+
+
+async def test_bank_refreshes_on_the_last_layer_once_the_throttle_elapses(monkeypatch):
+    """The point of banking on progress: a three-minute last layer keeps
+    refreshing instead of freezing at the moment that layer began."""
+    counter = _bank_env(monkeypatch, total_layers=10)
+    await main_module._maybe_bank_inprint_frame(3, 10)  # banks frame-1
+    # Pretend the throttle window has passed, as it does mid-last-layer.
+    main_module._inprint_frame_bank_ts[3] -= main_module._INPRINT_BANK_MIN_INTERVAL + 1
     await main_module._maybe_bank_inprint_frame(3, 10)
     assert counter["n"] == 2
     assert main_module._inprint_frame_bank[3] == b"frame-2"
@@ -322,3 +378,498 @@ async def test_bank_skips_during_calibration_substage(monkeypatch):
     _bank_env(monkeypatch, sub_stage=14)
     await main_module._maybe_bank_inprint_frame(3, 2)
     assert 3 not in main_module._inprint_frame_bank
+
+
+class TestStage22CacheHoldsExactlyOneRotation:
+    """#2708. `_stage22_finish_frames` is fed from two kinds of source: live
+    grabs, which are raw, and the #1867 in-print bank, whose bytes came from
+    `_capture_snapshot_for_notification` and are therefore ALREADY rotated.
+    The consumer cannot tell them apart, so the producer normalises: every
+    entry in the cache has had the rotation applied exactly once.
+
+    Rotating on the consumer side instead put two rotations on the banked
+    path — at 180 degrees that is the reported bug reproduced exactly, and at
+    90/270 it lands the photo 180 degrees out.
+    """
+
+    @staticmethod
+    def _jpeg(width, height):
+        import io
+
+        from PIL import Image
+
+        buf = io.BytesIO()
+        Image.new("RGB", (width, height), (0, 0, 255)).save(buf, format="JPEG")
+        return buf.getvalue()
+
+    @staticmethod
+    def _size(data):
+        import io
+
+        from PIL import Image
+
+        return Image.open(io.BytesIO(data)).size
+
+    async def test_a_live_grab_is_rotated_before_caching(self, patched_env, monkeypatch):
+        monkeypatch.setattr(patched_env, "camera_rotation", 90, raising=False)
+        raw = self._jpeg(64, 32)
+
+        async def _capture(**_kwargs):
+            return raw
+
+        monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture)
+
+        await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+        cached = main_module._stage22_finish_frames[patched_env.id]
+        assert self._size(cached) == (32, 64)
+
+    async def test_the_banked_frame_is_cached_verbatim(self, patched_env, monkeypatch):
+        """The bank is filled by `_capture_snapshot_for_notification`, which
+        rotates before it returns — so the producer must pass those bytes
+        through untouched rather than rotating them a second time.
+
+        Note this pins the invariant forward; it does not on its own prove the
+        bug fixed, because the old producer didn't rotate anything either. The
+        pair that discriminates is `test_a_live_grab_is_rotated_before_caching`
+        (producer now rotates) plus the source guard below (consumer no longer
+        does).
+        """
+        monkeypatch.setattr(patched_env, "camera_rotation", 90, raising=False)
+        # #2547: the bank is only preferred when End G-code was injected.
+        print_dispatch_context.mark_pending(patched_env.id)
+        print_dispatch_context.adopt(patched_env.id)
+        already_rotated = self._jpeg(32, 64)  # what one rotation of a 64x32 frame looks like
+        main_module._inprint_frame_bank[patched_env.id] = already_rotated
+
+        async def _capture(**_kwargs):  # pragma: no cover - must not be reached
+            raise AssertionError("the banked frame should have been preferred")
+
+        monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture)
+
+        await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+        cached = main_module._stage22_finish_frames[patched_env.id]
+        assert cached is already_rotated
+        assert self._size(cached) == (32, 64)
+
+    async def test_a_stage22_grab_is_rotated_even_though_the_bank_is_full(self, patched_env, monkeypatch):
+        """Only the `finish_state` trigger reads the bank. The `stage_22` and
+        `last_layer` triggers take a live grab, which still needs rotating —
+        a shared "did we use the bank" flag must not latch on the bank merely
+        existing."""
+        monkeypatch.setattr(patched_env, "camera_rotation", 90, raising=False)
+        main_module._inprint_frame_bank[patched_env.id] = self._jpeg(999, 1)
+
+        async def _capture(**_kwargs):
+            return self._jpeg(64, 32)
+
+        monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture)
+
+        await on_finish_photo_moment(patched_env.id, {"trigger": "stage_22"})
+
+        cached = main_module._stage22_finish_frames[patched_env.id]
+        assert self._size(cached) == (32, 64)
+
+    async def test_no_rotation_configured_caches_the_bytes_as_captured(self, patched_env, monkeypatch):
+        raw = self._jpeg(64, 32)
+
+        async def _capture(**_kwargs):
+            return raw
+
+        monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture)
+
+        await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+        assert main_module._stage22_finish_frames[patched_env.id] is raw
+
+
+def test_the_consumer_does_not_rotate_the_cached_frame():
+    """The other half of the #2708 invariant, and the half with no runtime
+    harness: `_background_finish_photo` is a closure nested inside
+    `on_print_complete`, so nothing can drive its cached-frame branch
+    directly. What it must NOT do is rotate what it pops from
+    `_stage22_finish_frames` — the producer has already done that, and doing
+    it again upside-downs the banked path, which is the bug this fixed.
+
+    Checked against the source because the alternative is no check at all.
+    """
+    import ast
+    from pathlib import Path
+
+    main_py = Path(__file__).resolve().parents[2] / "app" / "main.py"
+    assert main_py.exists(), f"guard is looking in the wrong place: {main_py}"
+    tree = ast.parse(main_py.read_text())
+
+    offenders = [
+        node.lineno
+        for node in ast.walk(tree)
+        if isinstance(node, ast.Call)
+        and isinstance(node.func, ast.Name)
+        and node.func.id == "_apply_camera_rotation"
+        and node.args
+        and isinstance(node.args[0], ast.Name)
+        and node.args[0].id == "cached_frame"
+    ]
+
+    assert not offenders, (
+        f"main.py:{offenders} rotates the frame popped from _stage22_finish_frames. "
+        "Those bytes are already rotated by on_finish_photo_moment (#2708); rotating "
+        "again returns a 180-degree print to upside-down."
+    )
+
+
+class TestPlateRestore:
+    """#2547 / #1145 / #1397 / #1565: put the plate back into camera framing.
+
+    Bambu's end G-code drops the plate ~100mm as the last thing it does, so by
+    FINISH the finished print sits well below where the camera frames it. The
+    restore commands an ABSOLUTE Z back to just above the last printed layer.
+
+    Absolute is the safety argument, and these tests pin it: the target is a
+    height the toolhead was physically at seconds earlier, so it is inside the
+    travel limits and leaves the nozzle above the part. It is also unambiguous
+    across model families — Z is the nozzle-to-bed gap whether the bed moves or
+    the toolhead does — so there is no sign to get wrong the way the relative
+    bed-jog path had (#1334).
+    """
+
+    @pytest.fixture
+    def printer_client(self, monkeypatch):
+        sent: list[str] = []
+        client = SimpleNamespace(
+            state=SimpleNamespace(state="FINISH"),
+            send_gcode=lambda gcode: (sent.append(gcode), True)[1],
+        )
+        monkeypatch.setattr(main_module.printer_manager, "get_client", lambda _pid: client)
+        monkeypatch.setattr(main_module.asyncio, "sleep", AsyncMock())
+        client.sent = sent
+        return client
+
+    async def test_commands_an_absolute_move_above_the_print(self, printer_client):
+        ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
+
+        assert ok is True
+        assert printer_client.sent == ["G90\nG1 Z26.00 F600"]
+
+    async def test_never_touches_m211(self, printer_client):
+        """#2579: disabling soft endstops is what let a jog drive the nozzle
+        into the bed. This path must not reintroduce it."""
+        await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
+
+        assert not any("M211" in line for line in printer_client.sent)
+
+    async def test_skipped_when_the_printer_is_no_longer_in_finish(self, printer_client):
+        """The queue dispatches the next job the instant a print completes.
+        Commanding a plate move into a starting print is not a race worth
+        having, so state is re-read immediately before the move."""
+        printer_client.state.state = "RUNNING"
+
+        ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
+
+        assert ok is False
+        assert printer_client.sent == []
+
+    async def test_skipped_when_the_printer_is_gone(self, monkeypatch):
+        monkeypatch.setattr(main_module.printer_manager, "get_client", lambda _pid: None)
+
+        ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
+
+        assert ok is False
+
+    async def test_reports_failure_when_the_send_fails(self, printer_client, monkeypatch):
+        """A failed send means the plate never moved — the caller must not go on
+        to owe it a move back down."""
+        monkeypatch.setattr(printer_client, "send_gcode", lambda _g: False)
+
+        ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
+
+        assert ok is False
+
+    def test_park_lowers_the_plate_again(self, printer_client):
+        main_module._park_plate_after_finish_photo(7, 16.0, logging.getLogger(__name__))
+
+        assert printer_client.sent == ["G90\nG1 Z116.00 F600"]
+
+    def test_park_skipped_once_the_next_print_has_started(self, printer_client):
+        printer_client.state.state = "RUNNING"
+
+        main_module._park_plate_after_finish_photo(7, 16.0, logging.getLogger(__name__))
+
+        assert printer_client.sent == []
+
+
+class TestPlateRestoreWiring:
+    """The restore only runs in the one situation it is correct for, and the
+    plate always comes back down afterwards."""
+
+    @pytest.fixture
+    def restore_env(self, patched_env, monkeypatch):
+        calls = {"restore": [], "park": [], "blocked": False, "height": 16.0}
+
+        async def _height(_printer_id, _data, _logger):
+            return calls["height"]
+
+        async def _blocked(_printer_id):
+            return calls["blocked"]
+
+        async def _restore(printer_id, max_z, _logger):
+            calls["restore"].append((printer_id, max_z))
+            return True
+
+        def _park(printer_id, max_z, _logger):
+            calls["park"].append((printer_id, max_z))
+
+        monkeypatch.setattr(main_module, "_max_z_for_current_print", _height)
+        monkeypatch.setattr(main_module, "_plate_restore_is_blocked_by_queue", _blocked)
+        monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore)
+        monkeypatch.setattr(main_module, "_park_plate_after_finish_photo", _park)
+
+        async def _live(**_kwargs):
+            return b"\xff\xd8live"
+
+        monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
+        return calls
+
+    async def test_restores_then_parks_on_the_finish_state_path(self, patched_env, restore_env):
+        await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+        assert restore_env["restore"] == [(patched_env.id, 16.0)]
+        assert restore_env["park"] == [(patched_env.id, 16.0)]
+
+    async def test_not_restored_on_the_stage_22_path(self, patched_env, restore_env):
+        """Stage 22 fires before the end G-code drops the plate — it is already
+        where we want it, and moving it would only cost the settle delay."""
+        await on_finish_photo_moment(patched_env.id, {"trigger": "stage_22"})
+
+        assert restore_env["restore"] == []
+        assert restore_env["park"] == []
+
+    async def test_not_restored_when_the_banked_frame_is_used(self, patched_env, restore_env):
+        """The plate has been swapped out — no move brings the print back."""
+        print_dispatch_context.mark_pending(patched_env.id)
+        print_dispatch_context.adopt(patched_env.id)
+        main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked"
+
+        await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+        assert restore_env["restore"] == []
+        assert restore_env["park"] == []
+
+    async def test_not_restored_when_end_gcode_was_injected_but_the_bank_is_empty(self, patched_env, restore_env):
+        """A plate-swap machine may have just ejected its plate. Even with no
+        banked frame to fall back on, driving Z into whatever a swap mechanism
+        is doing is not worth a photo of a bed we know may be bare."""
+        print_dispatch_context.mark_pending(patched_env.id)
+        print_dispatch_context.adopt(patched_env.id)
+
+        await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+        assert restore_env["restore"] == []
+        assert restore_env["park"] == []
+
+    async def test_not_restored_when_the_print_height_is_unknown(self, patched_env, restore_env):
+        restore_env["height"] = None
+
+        await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+        assert restore_env["restore"] == []
+        assert restore_env["park"] == []
+
+    async def test_not_restored_when_another_job_is_queued(self, patched_env, restore_env):
+        restore_env["blocked"] = True
+
+        await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+        assert restore_env["restore"] == []
+        assert restore_env["park"] == []
+
+    async def test_not_restored_when_the_setting_is_off(self, patched_env, restore_env, monkeypatch):
+        async def _get_setting(_db, key):
+            if key == "capture_finish_photo":
+                return "true"
+            if key == "finish_photo_restore_plate":
+                return "false"
+            return None
+
+        monkeypatch.setattr("backend.app.api.routes.settings.get_setting", _get_setting)
+
+        await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+        assert restore_env["restore"] == []
+        assert restore_env["park"] == []
+
+    async def test_plate_is_parked_even_when_the_capture_throws(self, patched_env, restore_env, monkeypatch):
+        """We raised it, so we owe the move back down — including when the grab
+        between the two fails. Otherwise the user finds the print pinned under
+        the nozzle."""
+
+        async def _boom(**_kwargs):
+            raise RuntimeError("camera gone")
+
+        monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _boom)
+
+        await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+        assert restore_env["restore"] == [(patched_env.id, 16.0)]
+        assert restore_env["park"] == [(patched_env.id, 16.0)]
+
+    async def test_producer_event_is_still_set_after_a_restore(self, patched_env, restore_env):
+        """#1790: the consumer's bounded wait must be released on every exit,
+        and the restore added a new path through the producer."""
+        await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+        assert main_module._stage22_finish_in_flight[patched_env.id].is_set()
+
+
+async def test_producer_wait_budget_covers_the_restore():
+    """The consumer's wait has to outlast settle + a worst-case RTSP grab, and
+    still finish inside the notification's own photo budget — otherwise the
+    restore path is cut off by a timeout somewhere above it."""
+    assert main_module._FINISH_PHOTO_PRODUCER_WAIT_SECONDS > main_module._PLATE_RESTORE_SETTLE_SECONDS + 15
+
+
+class TestMaxZResolution:
+    """#2547 safety: the height that becomes a Z-move target must provably
+    belong to the print that just finished.
+
+    A height from another print is the one failure mode that could drive the
+    nozzle into the model — 20mm carried onto a 200mm print commands the plate
+    up through the part. So the resolver refuses on every ambiguity rather than
+    falling back to "whatever ran last on this printer".
+    """
+
+    @staticmethod
+    def _archive(**overrides):
+        base = {
+            "id": 11,
+            "file_path": "/data/archive/1/job/job.3mf",
+            "plate_id": 1,
+            "total_layers": 30,
+        }
+        base.update(overrides)
+        return SimpleNamespace(**base)
+
+    @pytest.fixture
+    def resolver_env(self, monkeypatch):
+        env = {"archive": self._archive(), "reported_layers": 30, "height": 16.0, "where": None}
+
+        @asynccontextmanager
+        async def _session():
+            async def _execute(stmt):
+                env["where"] = str(stmt)
+                return SimpleNamespace(scalar_one_or_none=lambda: env["archive"])
+
+            yield SimpleNamespace(execute=_execute)
+
+        monkeypatch.setattr(main_module, "async_session", _session)
+        monkeypatch.setattr(
+            main_module.printer_manager,
+            "get_client",
+            lambda _pid: SimpleNamespace(state=SimpleNamespace(total_layers=env["reported_layers"])),
+        )
+        monkeypatch.setattr(
+            "backend.app.utils.threemf_tools.extract_max_z_height_from_3mf",
+            lambda _path, _plate: env["height"],
+        )
+        return env
+
+    async def test_returns_the_height_when_name_and_layers_agree(self, resolver_env):
+        height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__))
+        assert height == 16.0
+
+    async def test_refuses_when_the_print_has_no_name_to_match_on(self, resolver_env):
+        """Without an identifier there is nothing to bind the archive to, and
+        the query would degrade to 'the newest row for this printer'."""
+        height = await main_module._max_z_for_current_print(1, {}, logging.getLogger(__name__))
+
+        assert height is None
+        assert resolver_env["where"] is None  # refused before touching the DB
+
+    async def test_refuses_when_no_archive_matches_the_name(self, resolver_env):
+        resolver_env["archive"] = None
+
+        height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__))
+        assert height is None
+
+    async def test_refuses_when_the_layer_counts_disagree(self, resolver_env):
+        """The corroboration check. The archive's layer count comes from the
+        3MF; the printer's comes from MQTT. If two independent sources disagree,
+        the row is not this print whatever its name says."""
+        resolver_env["reported_layers"] = 240
+
+        height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__))
+        assert height is None
+
+    async def test_proceeds_when_a_layer_count_is_simply_unknown(self, resolver_env):
+        """Absent is not the same as contradictory — a print Bambuddy has no
+        layer count for still gets its height, because the name matched."""
+        resolver_env["reported_layers"] = 0
+        assert (
+            await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__)) == 16.0
+        )
+
+        resolver_env["reported_layers"] = 30
+        resolver_env["archive"] = self._archive(total_layers=None)
+        assert (
+            await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__)) == 16.0
+        )
+
+    async def test_matches_by_equality_not_substring(self, resolver_env):
+        """`LIKE %name%` would let "Cube" resolve to "Cube v2" — a different
+        print, quite possibly a much taller one."""
+        await main_module._max_z_for_current_print(1, {"subtask_name": "Cube"}, logging.getLogger(__name__))
+
+        assert "LIKE" not in resolver_env["where"].upper()
+
+    async def test_refuses_when_the_archive_has_no_file(self, resolver_env):
+        resolver_env["archive"] = self._archive(file_path=None)
+
+        height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__))
+        assert height is None
+
+    async def test_refuses_when_the_3mf_has_no_height(self, resolver_env):
+        resolver_env["height"] = None
+
+        height = await main_module._max_z_for_current_print(1, {"subtask_name": "job"}, logging.getLogger(__name__))
+        assert height is None
+
+
+class TestTimelapsePathPlateRestore:
+    """#2547: the timelapse path falls through to a live grab whenever the
+    video hasn't landed yet — the documented usual outcome on P1-series.
+
+    `on_finish_photo_moment` returns early for those prints without raising the
+    plate, so the photo that actually ships in the notification would be of an
+    already-dropped plate. The consumer therefore does the restore itself, but
+    only on that path — everywhere else the producer has already done it.
+    """
+
+    def test_notification_budget_outlasts_the_video_poll_plus_a_restore(self):
+        """The wait has to cover polling for the video AND the restore that
+        follows when it doesn't arrive. At the old flat 75s the fallback was
+        cut off mid-settle, so the plate would have moved for a photo nobody
+        was still waiting for."""
+        assert (
+            main_module._FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS + main_module._FINISH_PHOTO_PRODUCER_WAIT_SECONDS
+            > main_module._FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS + main_module._PLATE_RESTORE_SETTLE_SECONDS + 15
+        )
+
+    async def test_producer_skips_the_restore_when_a_timelapse_was_recording(self, patched_env, monkeypatch):
+        """The producer returns before any of the restore code — the consumer
+        owns it on this path, and doing it in both would move the plate twice."""
+        moved = []
+
+        async def _restore(printer_id, max_z, _logger):
+            moved.append((printer_id, max_z))
+            return True
+
+        async def _height(_printer_id, _data, _logger):
+            return 16.0
+
+        monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore)
+        monkeypatch.setattr(main_module, "_max_z_for_current_print", _height)
+
+        await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state", "timelapse_was_active": True})
+
+        assert moved == []

+ 509 - 0
backend/tests/unit/test_github_backup_cloud_profiles.py

@@ -0,0 +1,509 @@
+"""Cloud-profile collection for Git backup (#2717).
+
+The collector used to read a ``setting`` key the Bambu Cloud API never returns,
+so ``cloud_profiles/*`` was never written while ``backup_metadata.json`` claimed
+it was. It also asked for the auth-disabled credential store unconditionally,
+which meant it saw no accounts at all once auth was on. These tests pin the
+response shape it actually has to parse, the account enumeration, and the
+metadata now telling the truth.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.services.github_backup import GitHubBackupService
+
+# The real listing body: keyed by preset type, each holding private/public
+# lists. There is no top-level "setting" array, and the entries carry no "type"
+# of their own — the type is the outer key, and Bambu calls process "print".
+BAMBU_LISTING = {
+    "filament": {
+        "private": [
+            {"setting_id": "PFUS1", "name": "My PLA", "version": "1.0", "user_id": "u-123"},
+        ],
+        "public": [
+            {"setting_id": "GFSA00", "name": "Bambu PLA Basic", "version": "1.0"},
+        ],
+    },
+    "printer": {
+        "private": [{"setting_id": "PMUS1", "name": "My X1C", "version": "1.0"}],
+        "public": [],
+    },
+    "print": {
+        "private": [{"setting_id": "PSUS1", "name": "My 0.2mm", "version": "1.0"}],
+        "public": [],
+    },
+}
+
+
+def _detail(setting_id: str, name: str, base: str) -> dict:
+    return {
+        "setting_id": setting_id,
+        "name": name,
+        "type": "filament",
+        "version": "1.0",
+        "base_id": base,
+        "filament_id": "P1234",
+        "setting": {"filament_flow_ratio": ["0.98"]},
+    }
+
+
+def _bambu_cloud(listing=None, detail_side_effect=None):
+    cloud = MagicMock()
+    cloud.is_authenticated = True
+    cloud.get_slicer_settings = AsyncMock(return_value=listing if listing is not None else BAMBU_LISTING)
+    cloud.get_setting_detail = AsyncMock(
+        side_effect=detail_side_effect or (lambda sid: _detail(sid, f"detail-{sid}", "GFSA00")),
+    )
+    cloud.close = AsyncMock()
+    return cloud
+
+
+def _orca_service(profiles):
+    svc = MagicMock()
+    svc.list_profiles = AsyncMock(return_value=profiles)
+    svc.close = AsyncMock()
+    return svc
+
+
+@pytest.fixture
+def service():
+    return GitHubBackupService()
+
+
+class TestCloudAccountEnumeration:
+    """Which accounts a backup collects from."""
+
+    @pytest.mark.asyncio
+    async def test_auth_disabled_uses_the_global_store(self, service, db_session):
+        """With auth off there is no User row at all — credentials live in the
+        Settings table and the account is keyed ``global``."""
+        with (
+            patch(
+                "backend.app.api.routes.cloud.get_stored_token",
+                new_callable=AsyncMock,
+                return_value=("bambu-token", "a@b.c", "global"),
+            ),
+            patch(
+                "backend.app.api.routes.orca_cloud._load_credentials",
+                new_callable=AsyncMock,
+                return_value=MagicMock(token=None),
+            ),
+        ):
+            bambu, orca = await service.cloud_accounts(db_session)
+
+        assert bambu == [("global", None)]
+        assert orca == []
+
+    @pytest.mark.asyncio
+    async def test_auth_enabled_finds_every_user_holding_a_token(self, service, db_session):
+        """The bug that made this invisible: with auth on, tokens live on User
+        rows, and the collector only ever looked at the global store. Each cloud
+        is enumerated separately so a user connected to one shows up only there.
+        """
+        both = User(username="both", cloud_token="t1", orca_cloud_token="o1")
+        bambu_only = User(username="bambu-only", cloud_token="t2")
+        orca_only = User(username="orca-only", orca_cloud_token="o2")
+        neither = User(username="neither")
+        db_session.add_all([both, bambu_only, orca_only, neither])
+        await db_session.commit()
+
+        with (
+            patch(
+                "backend.app.api.routes.cloud.get_stored_token",
+                new_callable=AsyncMock,
+                return_value=(None, None, "global"),
+            ),
+            patch(
+                "backend.app.api.routes.orca_cloud._load_credentials",
+                new_callable=AsyncMock,
+                return_value=MagicMock(token=None),
+            ),
+        ):
+            bambu, orca = await service.cloud_accounts(db_session)
+
+        assert sorted(key for key, _ in bambu) == [f"user-{both.id}", f"user-{bambu_only.id}"]
+        assert sorted(key for key, _ in orca) == [f"user-{both.id}", f"user-{orca_only.id}"]
+
+    @pytest.mark.asyncio
+    async def test_global_and_per_user_accounts_coexist(self, service, db_session):
+        """A Settings row survives someone enabling auth later. Dropping it
+        would silently stop backing up that account's presets."""
+        db_session.add(User(username="u", cloud_token="t1"))
+        await db_session.commit()
+
+        with (
+            patch(
+                "backend.app.api.routes.cloud.get_stored_token",
+                new_callable=AsyncMock,
+                return_value=("legacy-global", None, "global"),
+            ),
+            patch(
+                "backend.app.api.routes.orca_cloud._load_credentials",
+                new_callable=AsyncMock,
+                return_value=MagicMock(token=None),
+            ),
+        ):
+            bambu, _orca = await service.cloud_accounts(db_session)
+
+        assert "global" in [key for key, _ in bambu]
+        assert len(bambu) == 2
+
+
+class TestBambuCollection:
+    @pytest.mark.asyncio
+    async def test_reads_the_shape_the_api_actually_returns(self, service, db_session):
+        """The whole bug in one assertion: presets come out of
+        ``data[type]["private"]``, not a flat ``setting`` list, and ``print``
+        maps to ``process``."""
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=_bambu_cloud(),
+        ):
+            counts = await service._collect_bambu_profiles(db_session, files, "global", None)
+
+        assert counts == {"filament": 1, "printer": 1, "process": 1}
+        assert set(files) == {
+            "cloud_profiles/bambu/global/filament.json",
+            "cloud_profiles/bambu/global/printer.json",
+            "cloud_profiles/bambu/global/process.json",
+        }
+
+    @pytest.mark.asyncio
+    async def test_public_presets_are_not_backed_up(self, service, db_session):
+        """Bambu's bundled catalogue is identical for everyone, re-downloadable,
+        and not recreatable under your account — backing it up would churn the
+        repository on every run for no recovery value."""
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=_bambu_cloud(),
+        ):
+            await service._collect_bambu_profiles(db_session, files, "global", None)
+
+        filament = files["cloud_profiles/bambu/global/filament.json"]["profiles"]
+        assert [p["setting_id"] for p in filament] == ["PFUS1"]
+
+    @pytest.mark.asyncio
+    async def test_stores_the_payload_a_restore_needs(self, service, db_session):
+        """The listing is metadata only. Without ``base_id`` and ``setting``
+        the backup is a list of names — ``create_setting`` cannot rebuild from
+        it."""
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=_bambu_cloud(),
+        ):
+            await service._collect_bambu_profiles(db_session, files, "global", None)
+
+        preset = files["cloud_profiles/bambu/global/filament.json"]["profiles"][0]
+        assert preset["base_id"] == "GFSA00"
+        assert preset["setting"] == {"filament_flow_ratio": ["0.98"]}
+        assert preset["type"] == "filament"
+
+    @pytest.mark.asyncio
+    async def test_account_identity_is_not_written_to_the_repo(self, service, db_session):
+        """Backup repositories can be public, and ``user_id`` adds nothing to a
+        rebuild."""
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=_bambu_cloud(),
+        ):
+            await service._collect_bambu_profiles(db_session, files, "global", None)
+
+        for payload in files.values():
+            for preset in payload["profiles"]:
+                assert "user_id" not in preset
+
+    @pytest.mark.asyncio
+    async def test_one_unreadable_preset_does_not_lose_the_others(self, service, db_session):
+        """And it is counted, not swallowed — a partial backup that looks
+        complete is how #2717 stayed invisible."""
+
+        def detail(setting_id):
+            if setting_id == "PFUS1":
+                raise RuntimeError("boom")
+            return _detail(setting_id, "ok", "GFSA00")
+
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=_bambu_cloud(detail_side_effect=detail),
+        ):
+            counts = await service._collect_bambu_profiles(db_session, files, "global", None)
+
+        assert "cloud_profiles/bambu/global/filament.json" not in files
+        assert counts["printer"] == 1
+        assert counts["process"] == 1
+        assert counts["failed"] == 1
+
+    @pytest.mark.asyncio
+    async def test_unauthenticated_account_writes_nothing(self, service, db_session):
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=None,
+        ):
+            counts = await service._collect_bambu_profiles(db_session, files, "user-1", MagicMock())
+
+        assert counts == {}
+        assert files == {}
+
+
+class TestOrcaCollection:
+    @pytest.mark.asyncio
+    async def test_groups_by_content_type_including_aliases(self, service, db_session):
+        """Orca carries the type at ``content.type`` and uses BambuStudio-style
+        aliases — ``machine`` is a printer, ``process`` and ``print`` are both
+        process. Same map the Orca tab groups by."""
+        profiles = [
+            {"id": 1, "name": "f", "content": {"type": "filament"}},
+            {"id": 2, "name": "m", "content": {"type": "machine"}},
+            {"id": 3, "name": "p", "content": {"type": "print"}},
+            {"id": 4, "name": "p2", "content": {"type": "process"}},
+        ]
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            new_callable=AsyncMock,
+            return_value=_orca_service(profiles),
+        ):
+            counts = await service._collect_orca_profiles(db_session, files, "user-3", MagicMock())
+
+        assert counts == {"filament": 1, "printer": 1, "process": 2}
+        assert "cloud_profiles/orca/user-3/printer.json" in files
+
+    @pytest.mark.asyncio
+    async def test_content_is_stored_inline_without_a_second_fetch(self, service, db_session):
+        """The sync-pull listing already carries each profile's content, so
+        unlike Bambu there is no per-profile round trip."""
+        svc = _orca_service([{"id": 7, "name": "f", "content": {"type": "filament", "flow": 0.98}}])
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            new_callable=AsyncMock,
+            return_value=svc,
+        ):
+            await service._collect_orca_profiles(db_session, files, "global", None)
+
+        stored = files["cloud_profiles/orca/global/filament.json"]["profiles"][0]
+        assert stored["content"] == {"type": "filament", "flow": 0.98}
+        assert svc.list_profiles.await_count == 1
+
+    @pytest.mark.asyncio
+    async def test_unmapped_types_are_kept_not_dropped(self, service, db_session):
+        """The Orca *route* drops profiles whose type it can't render, which is
+        right for a list and wrong for a backup: silently omitting a profile
+        because Orca added a type is the same class of bug as #2717."""
+        profiles = [
+            {"id": 1, "name": "f", "content": {"type": "filament"}},
+            {"id": 2, "name": "x", "content": {"type": "something_new"}},
+            {"id": 3, "name": "y", "content": {}},
+        ]
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            new_callable=AsyncMock,
+            return_value=_orca_service(profiles),
+        ):
+            counts = await service._collect_orca_profiles(db_session, files, "global", None)
+
+        assert counts["other"] == 2
+        assert len(files["cloud_profiles/orca/global/other.json"]["profiles"]) == 2
+
+    @pytest.mark.asyncio
+    async def test_dead_pairing_writes_nothing_and_does_not_raise(self, service, db_session):
+        """An unexpected failure building the Orca client must not abort the
+        rest of the backup — the other accounts and the other cloud still have
+        profiles worth collecting."""
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            new_callable=AsyncMock,
+            side_effect=RuntimeError("session expired"),
+        ):
+            counts = await service._collect_orca_profiles(db_session, files, "user-2", MagicMock())
+
+        assert counts == {}
+        assert files == {}
+
+    @pytest.mark.asyncio
+    async def test_the_backup_never_disconnects_an_account(self, service, db_session):
+        """A backup is an observer. It must not change anyone's sign-in state
+        on a schedule — least of all on Orca's composite rejection reason,
+        which cannot tell a real revocation from a lost refresh-rotation race.
+        The Profiles route clears the dead pairing instead, with the user
+        present to act on it.
+        """
+        from fastapi import HTTPException
+
+        build = AsyncMock(side_effect=HTTPException(status_code=401, detail="grant already used"))
+        files: dict = {}
+        with patch("backend.app.api.routes.orca_cloud._build_authenticated_service", build):
+            counts = await service._collect_orca_profiles(db_session, files, "global", None)
+
+        assert counts == {}
+        assert build.await_args.kwargs["clear_on_auth_failure"] is False
+
+    @pytest.mark.asyncio
+    async def test_a_rejected_session_says_it_will_keep_being_skipped(self, service, db_session, caplog):
+        """Not clearing means the warning recurs every run, so the one line the
+        operator sees has to say how to stop it."""
+        from fastapi import HTTPException
+
+        files: dict = {}
+        with (
+            caplog.at_level("WARNING"),
+            patch(
+                "backend.app.api.routes.orca_cloud._build_authenticated_service",
+                new_callable=AsyncMock,
+                side_effect=HTTPException(status_code=401, detail="refresh rejected: grant already used"),
+            ),
+        ):
+            counts = await service._collect_orca_profiles(db_session, files, "global", None)
+
+        assert counts == {}
+        assert "paired again" in caplog.text
+        assert "Later runs will skip it too" in caplog.text
+
+    @pytest.mark.asyncio
+    async def test_an_unreachable_orca_is_a_transient_skip(self, service, db_session, caplog):
+        """502 is very likely gone by the next run, so it must not carry the
+        "go and re-pair" advice a rejected session does."""
+        from fastapi import HTTPException
+
+        files: dict = {}
+        with (
+            caplog.at_level("WARNING"),
+            patch(
+                "backend.app.api.routes.orca_cloud._build_authenticated_service",
+                new_callable=AsyncMock,
+                side_effect=HTTPException(status_code=502, detail="Orca Cloud unreachable: timeout"),
+            ),
+        ):
+            counts = await service._collect_orca_profiles(db_session, files, "user-9", None)
+
+        assert counts == {}
+        assert "unreachable" in caplog.text
+        assert "paired again" not in caplog.text
+
+
+class TestCollectorAndMetadata:
+    @pytest.mark.asyncio
+    async def test_no_connected_account_collects_nothing(self, service, db_session):
+        files: dict = {}
+        with (
+            patch(
+                "backend.app.api.routes.cloud.get_stored_token",
+                new_callable=AsyncMock,
+                return_value=(None, None, "global"),
+            ),
+            patch(
+                "backend.app.api.routes.orca_cloud._load_credentials",
+                new_callable=AsyncMock,
+                return_value=MagicMock(token=None),
+            ),
+        ):
+            summary = await service._collect_cloud_profiles(db_session, files)
+
+        assert summary == {"bambu": {}, "orca": {}}
+        assert files == {}
+
+    @pytest.mark.asyncio
+    async def test_one_failing_account_does_not_stop_the_others(self, service, db_session):
+        a = User(username="a", cloud_token="t1")
+        b = User(username="b", cloud_token="t2")
+        db_session.add_all([a, b])
+        await db_session.commit()
+
+        def build(db, user=None):
+            if user is not None and user.username == "a":
+                raise RuntimeError("cloud down for this account")
+            return _bambu_cloud()
+
+        files: dict = {}
+        with (
+            patch(
+                "backend.app.api.routes.cloud.get_stored_token",
+                new_callable=AsyncMock,
+                return_value=(None, None, "global"),
+            ),
+            patch(
+                "backend.app.api.routes.orca_cloud._load_credentials",
+                new_callable=AsyncMock,
+                return_value=MagicMock(token=None),
+            ),
+            patch(
+                "backend.app.api.routes.cloud.build_authenticated_cloud",
+                new_callable=AsyncMock,
+                side_effect=build,
+            ),
+        ):
+            summary = await service._collect_cloud_profiles(db_session, files)
+
+        assert f"user-{a.id}" not in summary["bambu"]
+        assert summary["bambu"][f"user-{b.id}"] == {"filament": 1, "printer": 1, "process": 1}
+
+    @pytest.mark.asyncio
+    async def test_metadata_reports_collection_not_configuration(self, service, db_session):
+        """``contents.cloud_profiles`` said ``true`` on every backup, including
+        the ones that wrote nothing. A restore has to be able to trust it."""
+        config = MagicMock(
+            backup_kprofiles=False,
+            backup_cloud_profiles=True,
+            backup_settings=False,
+            backup_spools=False,
+            backup_archives=False,
+        )
+        with patch.object(
+            service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value={"bambu": {}, "orca": {}}
+        ):
+            files = await service._collect_backup_data(db_session, config)
+
+        assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is False
+        assert "cloud_profiles" not in files["backup_metadata.json"]
+
+    @pytest.mark.asyncio
+    async def test_metadata_records_per_account_counts_when_collected(self, service, db_session):
+        config = MagicMock(
+            backup_kprofiles=False,
+            backup_cloud_profiles=True,
+            backup_settings=False,
+            backup_spools=False,
+            backup_archives=False,
+        )
+        summary = {"bambu": {"user-3": {"filament": 2}}, "orca": {}}
+        with patch.object(service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value=summary):
+            files = await service._collect_backup_data(db_session, config)
+
+        assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is True
+        assert files["backup_metadata.json"]["cloud_profiles"] == summary
+
+
+class TestSettingsFallbackIsStillHonoured:
+    @pytest.mark.asyncio
+    async def test_global_orca_row_is_discovered(self, service, db_session):
+        """Orca's auth-disabled fallback lives in the same Settings table as
+        Bambu's; both stores are read on every run."""
+        db_session.add(Settings(key="orca_cloud_token", value="oc_ext_x"))
+        await db_session.commit()
+
+        with patch(
+            "backend.app.api.routes.cloud.get_stored_token",
+            new_callable=AsyncMock,
+            return_value=(None, None, "global"),
+        ):
+            _bambu, orca = await service.cloud_accounts(db_session)
+
+        assert orca == [("global", None)]

+ 1 - 0
backend/tests/unit/test_layer_timelapse_expected_archive.py

@@ -60,6 +60,7 @@ def test_starts_timelapse_when_external_camera_enabled():
         "http://camera.local:5000/snapshot.jpg",
         "snapshot",
         snapshot_url="http://camera.local:5000/snapshot.jpg",
+        rotation=0,
     )
 
 

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

@@ -132,6 +132,205 @@ class TestTestConnection:
         assert result["body"] == "something else"
 
 
+class TestMlApiToken:
+    """Obico's ML API gates /p/ behind ML_API_TOKEN (#2733)."""
+
+    def test_auth_headers_only_when_configured(self):
+        from backend.app.services.obico_detection import auth_headers
+
+        assert auth_headers("s3cret") == {"Authorization": "Bearer s3cret"}
+        # Unconfigured must stay byte-identical to the pre-setting request.
+        assert auth_headers("") == {}
+        assert auth_headers(None) == {}
+        assert auth_headers("   ") == {}
+        # Whitespace around a real token is a paste artefact, not part of it.
+        assert auth_headers("  s3cret  ") == {"Authorization": "Bearer s3cret"}
+
+    def test_settings_schema_accepts_a_token(self):
+        assert AppSettingsUpdate(obico_ml_token="s3cret").obico_ml_token == "s3cret"
+        assert AppSettingsUpdate(obico_ml_token="").obico_ml_token == ""
+        assert AppSettingsUpdate().obico_ml_token is None
+
+    @staticmethod
+    def _settings(**overrides):
+        base = {
+            "enabled": True,
+            "ml_url": "http://obico:3333",
+            "ml_token": "",
+            "sensitivity": "medium",
+            "action": "notify",
+            "poll_interval": 10,
+            "enabled_printers": None,
+            "external_url": "http://bambuddy:8000",
+        }
+        base.update(overrides)
+        return base
+
+    @staticmethod
+    def _client(response):
+        mock_client = MagicMock()
+        mock_client.get = AsyncMock(return_value=response)
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+        return mock_client
+
+    @pytest.mark.asyncio
+    async def test_detection_call_carries_the_bearer_header(self):
+        svc = ObicoDetectionService()
+        response = MagicMock(status_code=200)
+        response.json.return_value = {"detections": []}
+        response.raise_for_status = MagicMock()
+        mock_client = self._client(response)
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        with (
+            patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
+            patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
+        ):
+            await svc._check_printer(1, status, self._settings(ml_token="s3cret"))
+
+        assert mock_client.get.await_args.kwargs["headers"] == {"Authorization": "Bearer s3cret"}
+
+    @pytest.mark.asyncio
+    async def test_detection_call_sends_no_header_without_a_token(self):
+        svc = ObicoDetectionService()
+        response = MagicMock(status_code=200)
+        response.json.return_value = {"detections": []}
+        response.raise_for_status = MagicMock()
+        mock_client = self._client(response)
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        with (
+            patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
+            patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
+        ):
+            await svc._check_printer(1, status, self._settings())
+
+        assert mock_client.get.await_args.kwargs["headers"] == {}
+
+    @pytest.mark.asyncio
+    async def test_401_reports_the_token_rather_than_a_bare_http_error(self):
+        svc = ObicoDetectionService()
+        response = MagicMock(status_code=401)
+        # raise_for_status would also raise here; the status check must come first
+        # so the user gets an actionable message instead of "401 Unauthorized".
+        response.raise_for_status = MagicMock(side_effect=AssertionError("must not reach raise_for_status"))
+        mock_client = self._client(response)
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        with (
+            patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
+            patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
+        ):
+            await svc._check_printer(1, status, self._settings(ml_token="wrong"))
+
+        assert "401" in svc._last_error
+        assert "ML_API_TOKEN" in svc._last_error
+        # A rejected call must not be scored as a clean frame.
+        assert 1 not in svc._states or svc._states[1].frame_count == 0
+
+    @pytest.mark.asyncio
+    async def test_401_message_does_not_leak_the_token(self):
+        svc = ObicoDetectionService()
+        response = MagicMock(status_code=401)
+        response.raise_for_status = MagicMock()
+        mock_client = self._client(response)
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        with (
+            patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
+            patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
+        ):
+            await svc._check_printer(1, status, self._settings(ml_token="sup3rs3cret"))
+
+        assert "sup3rs3cret" not in svc._last_error
+
+
+class TestTestConnectionTokenProbe:
+    """/hc/ is ungated, so health alone cannot validate the token (#2733)."""
+
+    @staticmethod
+    def _client(responses):
+        mock_client = MagicMock()
+        mock_client.get = AsyncMock(side_effect=responses)
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+        return mock_client
+
+    @pytest.mark.asyncio
+    async def test_healthy_but_rejected_token_is_not_ok(self):
+        svc = ObicoDetectionService()
+        mock_client = self._client([MagicMock(status_code=200, text="ok"), MagicMock(status_code=401)])
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            result = await svc.test_connection("http://obico:3333", "wrong")
+
+        assert result["ok"] is False
+        assert result["auth_ok"] is False
+        assert result["status_code"] == 401
+        assert "ML_API_TOKEN" in result["error"]
+
+    @pytest.mark.asyncio
+    async def test_accepted_token_is_ok(self):
+        svc = ObicoDetectionService()
+        # 422 = "Invalid request params": auth passed, then the handler rejected
+        # the img-less probe. That is the success signal.
+        mock_client = self._client([MagicMock(status_code=200, text="ok"), MagicMock(status_code=422)])
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            result = await svc.test_connection("http://obico:3333", "right")
+
+        assert result["ok"] is True
+        assert result["auth_ok"] is True
+        assert result["error"] is None
+
+    @pytest.mark.asyncio
+    async def test_probe_failure_leaves_the_token_unknown_but_keeps_the_test_ok(self):
+        svc = ObicoDetectionService()
+        mock_client = self._client([MagicMock(status_code=200, text="ok"), RuntimeError("read timeout")])
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            result = await svc.test_connection("http://obico:3333", "maybe")
+
+        assert result["ok"] is True
+        assert result["auth_ok"] is None
+
+    @pytest.mark.asyncio
+    async def test_unhealthy_server_is_not_probed(self):
+        svc = ObicoDetectionService()
+        mock_client = self._client([MagicMock(status_code=200, text="error")])
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            result = await svc.test_connection("http://obico:3333", "any")
+
+        assert result["ok"] is False
+        assert result["auth_ok"] is None
+        assert mock_client.get.await_count == 1
+
+    @pytest.mark.asyncio
+    async def test_both_requests_carry_the_header(self):
+        svc = ObicoDetectionService()
+        mock_client = self._client([MagicMock(status_code=200, text="ok"), MagicMock(status_code=422)])
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            await svc.test_connection("http://obico:3333", "s3cret")
+
+        assert [call.args[0] for call in mock_client.get.await_args_list] == [
+            "http://obico:3333/hc/",
+            "http://obico:3333/p/",
+        ]
+        for call in mock_client.get.await_args_list:
+            assert call.kwargs["headers"] == {"Authorization": "Bearer s3cret"}
+
+    @pytest.mark.asyncio
+    async def test_url_policy_still_applies_before_any_request(self):
+        svc = ObicoDetectionService()
+        result = await svc.test_connection("http://169.254.169.254/latest/meta-data/", "s3cret")
+        assert result["ok"] is False
+        assert result["auth_ok"] is None
+        assert result["error"]
+
+
 class TestPollOneStateLifecycle:
     """Confirms per-printer state is reset when a new print starts."""
 

+ 125 - 0
backend/tests/unit/test_orca_cloud_refresh.py

@@ -0,0 +1,125 @@
+"""What a rejected Orca Cloud refresh is allowed to do to stored credentials.
+
+The refresh token is single-use and rotating, and Orca reports every rejection
+with one composite reason (``unknown, expired, revoked, or already used``), so
+Bambuddy cannot tell a genuine revocation from a lost rotation race. Routes may
+still clear on that signal — a person is looking at the page and can pair again
+— but a background job must not, or an unattended run can destroy a working
+pairing (#2717).
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi import HTTPException
+from sqlalchemy import select
+
+from backend.app.api.routes.orca_cloud import _SETTINGS_KEYS, _build_authenticated_service
+from backend.app.models.settings import Settings
+from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
+
+
+async def _store_global_credentials(db):
+    """An auth-disabled install's Orca credentials, expired so the helper
+    refreshes rather than returning straight away."""
+    db.add_all(
+        [
+            Settings(key=_SETTINGS_KEYS["token"], value="oc_ext_old"),
+            Settings(key=_SETTINGS_KEYS["refresh_token"], value="oc_ext_rt_old"),
+            Settings(key=_SETTINGS_KEYS["expires_at"], value="2000-01-01T00:00:00+00:00"),
+            Settings(key=_SETTINGS_KEYS["email"], value="a@b.c"),
+        ]
+    )
+    await db.commit()
+
+
+async def _stored_keys(db) -> set[str]:
+    result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
+    return {s.key for s in result.scalars().all()}
+
+
+def _expired_service(refresh_side_effect=None):
+    """A service that reports its access token as expired, so the helper takes
+    the refresh branch."""
+    svc = MagicMock()
+    svc.is_authenticated = False
+    svc.refresh_token = "oc_ext_rt_old"
+    svc.set_tokens = MagicMock()
+    svc.refresh = AsyncMock(side_effect=refresh_side_effect)
+    svc.access_token = "oc_ext_new"
+    svc.token_expiry = None
+    return svc
+
+
+class TestRejectedRefresh:
+    @pytest.mark.asyncio
+    async def test_routes_clear_the_dead_pairing_by_default(self, db_session):
+        """Unchanged behaviour for interactive callers: the page flips to
+        disconnected while the user is there to pair again."""
+        await _store_global_credentials(db_session)
+        svc = _expired_service(OrcaCloudAuthError("grant already used"))
+
+        with (
+            patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
+            pytest.raises(HTTPException) as exc,
+        ):
+            await _build_authenticated_service(db_session, None)
+
+        assert exc.value.status_code == 401
+        assert await _stored_keys(db_session) == set()
+
+    @pytest.mark.asyncio
+    async def test_background_callers_leave_the_credentials_alone(self, db_session):
+        """The whole point of the flag. A scheduled backup that guesses wrong
+        here destroys a pairing nobody asked it to touch, and the user finds
+        out when their profiles stop being backed up."""
+        await _store_global_credentials(db_session)
+        svc = _expired_service(OrcaCloudAuthError("grant already used"))
+
+        with (
+            patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
+            pytest.raises(HTTPException) as exc,
+        ):
+            await _build_authenticated_service(db_session, None, clear_on_auth_failure=False)
+
+        # Still reported as a hard auth failure — the caller has to skip the
+        # account — but nothing was destroyed on the way out.
+        assert exc.value.status_code == 401
+        assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session)
+        assert _SETTINGS_KEYS["refresh_token"] in await _stored_keys(db_session)
+
+    @pytest.mark.asyncio
+    async def test_an_unreachable_orca_never_clears_either_way(self, db_session):
+        """A transport failure says nothing about the credentials' validity."""
+        await _store_global_credentials(db_session)
+        svc = _expired_service(OrcaCloudError("connection reset"))
+
+        with (
+            patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
+            pytest.raises(HTTPException) as exc,
+        ):
+            await _build_authenticated_service(db_session, None)
+
+        assert exc.value.status_code == 502
+        assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session)
+
+
+class TestSuccessfulRefresh:
+    @pytest.mark.asyncio
+    async def test_the_rotated_pair_is_persisted_even_for_background_callers(self, db_session):
+        """Not optional: by the time the refresh succeeds the old token is
+        consumed, so failing to store the new pair would break a live pairing
+        for real. The flag suppresses destruction, never persistence.
+        """
+        await _store_global_credentials(db_session)
+        svc = _expired_service()
+        svc.refresh_token = "oc_ext_rt_new"
+
+        with patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc):
+            returned = await _build_authenticated_service(db_session, None, clear_on_auth_failure=False)
+
+        assert returned is svc
+        result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["token"]))
+        assert result.scalar_one().value == "oc_ext_new"
+        result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["refresh_token"]))
+        assert result.scalar_one().value == "oc_ext_rt_new"

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

@@ -607,3 +607,118 @@ class TestWatchdogRetryBudget:
             item = await db.get(PrintQueueItem, 1)
             assert item.status == "printing"
             assert item.dispatch_attempts == 0
+
+
+class TestWatchdogCommandRejected:
+    """A printer reporting HMS 0500_0500_0001_0007 refused the command outright.
+
+    It is not wedged and it is not slow: its authorization check rejected a
+    command it could not verify, and it will reject the next two identically.
+    Spending the full 270 s and two more full 3MF uploads on that is 15 minutes
+    of a farm's upload capacity buying nothing, and it ends with a message about
+    SD cards (#2732).
+    """
+
+    @staticmethod
+    def _rejected_status(state: str = "IDLE", subtask_id: str | None = "NEW_SUBTASK"):
+        from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
+
+        return SimpleNamespace(
+            state=state,
+            subtask_id=subtask_id,
+            gcode_file="/new.3mf",
+            hms_errors=[SimpleNamespace(full_code=HMS_MQTT_VERIFY_FAILED)],
+        )
+
+    @staticmethod
+    async def _run(db_session, status):
+        get_status = MagicMock(return_value=status)
+        get_client = MagicMock(return_value=MagicMock())
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
+            patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
+            patch("backend.app.services.print_scheduler.async_session", db_session),
+            patch("backend.app.core.database.async_session", db_session),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_queue_job_failed",
+                AsyncMock(),
+            ) as notify,
+        ):
+            await PrintScheduler._watchdog_print_start(
+                queue_item_id=1,
+                printer_id=42,
+                pre_state="IDLE",
+                pre_subtask_id="OLD_SUBTASK",
+                pre_gcode_file="/old.3mf",
+                timeout=0.2,
+                phase_b_timeout=0.2,
+                poll_interval=0.05,
+            )
+        return get_client, notify
+
+    @pytest.mark.asyncio
+    async def test_fails_on_the_first_attempt(self, db_session):
+        await self._run(db_session, self._rejected_status())
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "failed", "a refused command must not be retried"
+            assert item.dispatch_attempts == 1, "it must not burn the whole budget"
+            assert item.completed_at is not None
+
+    @pytest.mark.asyncio
+    async def test_error_message_names_the_fix(self, db_session):
+        """The old wording sent this user to check their SD card."""
+        await self._run(db_session, self._rejected_status())
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert "0500-0500-0001-0007" in item.error_message
+            assert "Developer Mode" in item.error_message
+            assert "SD card" not in item.error_message
+
+    @pytest.mark.asyncio
+    async def test_detected_in_phase_a_before_any_subtask_advance(self, db_session):
+        """The printer can refuse without ever echoing a subtask_id."""
+        await self._run(db_session, self._rejected_status(subtask_id="OLD_SUBTASK"))
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "failed"
+            assert item.dispatch_attempts == 1
+
+    @pytest.mark.asyncio
+    async def test_skips_the_forced_reconnect(self, db_session):
+        """The MQTT session is fine — reconnecting would only add 0500_4003 (#1150)."""
+        get_client, _ = await self._run(db_session, self._rejected_status(subtask_id="OLD_SUBTASK"))
+        get_client.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_notifies_with_the_rejection_reason(self, db_session):
+        _, notify = await self._run(db_session, self._rejected_status())
+
+        notify.assert_awaited_once()
+        assert "rejected" in notify.await_args.kwargs["reason"]
+
+    @pytest.mark.asyncio
+    async def test_unrelated_hms_still_takes_the_retry_path(self, db_session):
+        """Only this code short-circuits; every other fault keeps its retries."""
+        status = self._rejected_status()
+        status.hms_errors = [SimpleNamespace(full_code="0300020000018012")]
+
+        await self._run(db_session, status)
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "pending"
+            assert item.dispatch_attempts == 1
+
+    @pytest.mark.asyncio
+    async def test_a_printer_that_actually_starts_is_unaffected(self, db_session):
+        """A stale HMS from a previous job must not kill a print that is running."""
+        await self._run(db_session, self._rejected_status(state="RUNNING"))
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "printing"
+            assert item.dispatch_attempts == 0

+ 229 - 0
backend/tests/unit/test_slicer_stall_timeout.py

@@ -0,0 +1,229 @@
+"""Tests for the progress-supervised slice timeout (#2730).
+
+The old behaviour was a flat 300 s httpx timeout on the slice POST. A heavy
+model that Bambu Studio also took a long time over blew through it while the
+slicer was working perfectly happily, and — because ``httpx.ReadTimeout`` is a
+subclass of ``RequestError`` — the failure was reported as "Slicer sidecar
+unreachable", sending the reporter off to check a sidecar that was reachable
+throughout.
+
+The wait is now bounded by *silence* instead: Bambuddy already polls the
+sidecar's progress endpoint once a second, so it can tell a slow slice from a
+stalled one. The deadline moves forward on every progress update.
+"""
+
+import asyncio
+
+import httpx
+import pytest
+
+from backend.app.services.slicer_api import (
+    DEFAULT_SLICE_STALL_TIMEOUT_SECONDS,
+    SlicerApiService,
+    SlicerApiUnavailableError,
+    SlicerTimeoutError,
+    _Liveness,
+    get_stall_timeout_seconds,
+)
+
+SLICE_ARGS = {
+    "model_bytes": b"solid\n",
+    "model_filename": "cube.3mf",
+    "printer_profile_json": "{}",
+    "process_profile_json": "{}",
+    "filament_profile_jsons": ["{}"],
+}
+
+
+def _service(handler, *, timeout_seconds: float, poll_interval: float = 0.02) -> SlicerApiService:
+    """A service wired to a mock sidecar, with the timing compressed.
+
+    The stall window is floored at three poll intervals — liveness can only be
+    observed as fast as the poller ticks — so tests shrink both together rather
+    than waiting out production's 1 Hz.
+    """
+    client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+    svc = SlicerApiService("http://sidecar:3003", client=client, timeout_seconds=timeout_seconds)
+    svc.progress_poll_interval = poll_interval
+    return svc
+
+
+class TestLivenessWindow:
+    """The unit that decides when to stop waiting."""
+
+    def test_a_fresh_slice_has_the_full_window(self):
+        live = _Liveness(60.0, 1.0)
+        assert live.deadline - live.started_at == pytest.approx(60.0)
+
+    def test_progress_pushes_the_deadline_out(self):
+        live = _Liveness(60.0, 1.0)
+        live.saw_progress_endpoint()
+        before = live.deadline
+        live._last_alive += 30.0  # simulate a progress update 30s later
+        assert live.deadline > before
+
+    def test_without_a_progress_channel_the_window_is_total_elapsed(self):
+        """No liveness signal means no way to tell slow from stalled, so the
+        window degrades to the pre-#2730 wall clock — just configurable."""
+        live = _Liveness(60.0, 1.0)
+        live.mark_alive()  # would move the deadline if progress were supported
+        assert live.deadline == pytest.approx(live.started_at + 60.0)
+
+    def test_message_distinguishes_the_two_cases(self):
+        supported = _Liveness(60.0, 1.0)
+        supported.saw_progress_endpoint()
+        assert "stopped reporting progress" in supported.timeout_message()
+
+        unsupported = _Liveness(60.0, 1.0)
+        assert "does not report progress" in unsupported.timeout_message()
+
+    def test_message_points_at_the_setting(self):
+        live = _Liveness(900.0, 1.0)
+        assert "Settings -> Workflow -> Slicer" in live.timeout_message()
+
+
+class TestSliceIsNotCutOffWhileProgressing:
+    @pytest.mark.asyncio
+    async def test_a_slow_slice_that_reports_progress_completes(self):
+        """The reporter's case: slower than the old ceiling, still working.
+
+        The slice takes ~5x the stall window; progress keeps arriving, so it
+        must run to completion rather than being abandoned.
+        """
+        progress = {"n": 0}
+
+        async def handler(request: httpx.Request) -> httpx.Response:
+            if request.url.path.endswith("/slice"):
+                await asyncio.sleep(0.5)
+                return httpx.Response(
+                    200,
+                    content=b"G1 X0\n",
+                    headers={
+                        "x-print-time-seconds": "100",
+                        "x-filament-used-g": "1.0",
+                        "x-filament-used-mm": "100",
+                    },
+                )
+            progress["n"] += 1
+            return httpx.Response(200, json={"percent": progress["n"]})
+
+        svc = _service(handler, timeout_seconds=0.1)
+        result = await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-1", on_progress=lambda _p: None)
+
+        assert result.print_time_seconds == 100
+        assert progress["n"] > 1, "the poller must have been running throughout"
+
+    @pytest.mark.asyncio
+    async def test_repeated_identical_progress_does_not_count_as_alive(self):
+        """The sidecar re-serves its last snapshot on every poll. Treating that
+        as progress would make a stall undetectable."""
+
+        async def handler(request: httpx.Request) -> httpx.Response:
+            if request.url.path.endswith("/slice"):
+                await asyncio.sleep(10)
+                return httpx.Response(200, content=b"never gets here")
+            return httpx.Response(200, json={"percent": 42})  # frozen
+
+        svc = _service(handler, timeout_seconds=0.3)
+        with pytest.raises(SlicerTimeoutError):
+            await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-2", on_progress=lambda _p: None)
+
+
+class TestStalledSliceFails:
+    @pytest.mark.asyncio
+    async def test_silence_ends_the_wait(self):
+        async def handler(request: httpx.Request) -> httpx.Response:
+            if request.url.path.endswith("/slice"):
+                await asyncio.sleep(10)
+                return httpx.Response(200, content=b"never gets here")
+            return httpx.Response(404)  # no progress available
+
+        svc = _service(handler, timeout_seconds=0.2)
+        with pytest.raises(SlicerTimeoutError) as exc:
+            await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-3", on_progress=lambda _p: None)
+
+        assert "does not report progress" in str(exc.value)
+
+    @pytest.mark.asyncio
+    async def test_timeout_is_not_reported_as_unreachable(self):
+        """The whole point: this used to surface as "Slicer sidecar unreachable"."""
+
+        async def handler(request: httpx.Request) -> httpx.Response:
+            if request.url.path.endswith("/slice"):
+                await asyncio.sleep(10)
+            return httpx.Response(404)
+
+        svc = _service(handler, timeout_seconds=0.2)
+        with pytest.raises(SlicerTimeoutError) as exc:
+            await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-4", on_progress=lambda _p: None)
+
+        assert not isinstance(exc.value, SlicerApiUnavailableError)
+        assert "unreachable" not in str(exc.value)
+
+    @pytest.mark.asyncio
+    async def test_a_genuinely_unreachable_sidecar_still_says_so(self):
+        """Timeouts got their own type; connection failures keep the old one."""
+
+        async def handler(_request: httpx.Request) -> httpx.Response:
+            raise httpx.ConnectError("connection refused")
+
+        svc = _service(handler, timeout_seconds=5.0)
+        with pytest.raises(SlicerApiUnavailableError) as exc:
+            await svc.slice_with_profiles(**SLICE_ARGS)
+
+        assert "unreachable" in str(exc.value)
+
+
+class TestStallTimeoutSetting:
+    @pytest.mark.asyncio
+    async def test_reads_the_configured_value(self):
+        class _DB:
+            pass
+
+        async def fake_get_setting(_db, key):
+            assert key == "slicer_stall_timeout_minutes"
+            return "45"
+
+        import backend.app.api.routes.settings as settings_module
+
+        original = settings_module.get_setting
+        settings_module.get_setting = fake_get_setting
+        try:
+            assert await get_stall_timeout_seconds(_DB()) == 45 * 60
+        finally:
+            settings_module.get_setting = original
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("stored", [None, "", "not-a-number", "0", "-5"])
+    async def test_falls_back_rather_than_failing_the_slice(self, stored):
+        """A bad settings row must not be the reason a print doesn't happen."""
+
+        async def fake_get_setting(_db, _key):
+            return stored
+
+        import backend.app.api.routes.settings as settings_module
+
+        original = settings_module.get_setting
+        settings_module.get_setting = fake_get_setting
+        try:
+            assert await get_stall_timeout_seconds(object()) == DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
+        finally:
+            settings_module.get_setting = original
+
+    @pytest.mark.asyncio
+    async def test_a_failing_lookup_falls_back_too(self):
+        async def boom(_db, _key):
+            raise RuntimeError("db is down")
+
+        import backend.app.api.routes.settings as settings_module
+
+        original = settings_module.get_setting
+        settings_module.get_setting = boom
+        try:
+            assert await get_stall_timeout_seconds(object()) == DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
+        finally:
+            settings_module.get_setting = original
+
+    def test_default_is_longer_than_the_old_fixed_ceiling(self):
+        """300s was the number that broke; the new default must beat it."""
+        assert DEFAULT_SLICE_STALL_TIMEOUT_SECONDS > 300

+ 128 - 6
backend/tests/unit/test_support_helpers.py

@@ -701,19 +701,33 @@ class TestCollectSupportInfo:
 
 
 class TestParseObicoEnabledPrinters:
-    """Tests for the per-printer obico flag parser used by the bundle."""
+    """Tests for the per-printer obico flag parser used by the bundle.
 
-    def test_empty_string_returns_empty_set(self):
+    The setting is written by the settings UI as a JSON array and read by
+    ObicoDetectionService._load_settings as one; the bundle used to split it on
+    commas and call empty "no printers", so a default Obico setup was reported
+    as monitoring nothing while it was in fact monitoring everything (#2733).
+    """
+
+    def test_empty_means_all_printers(self):
+        from backend.app.api.routes.support import _parse_obico_enabled_printers
+
+        # None (not "no printers") — the same convention _load_settings uses.
+        assert _parse_obico_enabled_printers("") is None
+        assert _parse_obico_enabled_printers("   ") is None
+        assert _parse_obico_enabled_printers(None) is None
+
+    def test_json_array_is_the_stored_shape(self):
         from backend.app.api.routes.support import _parse_obico_enabled_printers
 
-        assert _parse_obico_enabled_printers("") == set()
-        assert _parse_obico_enabled_printers("   ") == set()
+        assert _parse_obico_enabled_printers("[1, 2, 3]") == {1, 2, 3}
+        assert _parse_obico_enabled_printers("[]") == set()
 
-    def test_comma_separated_ids(self):
+    def test_comma_separated_ids_still_parse(self):
+        # Legacy fallback for any install that stored the old shape.
         from backend.app.api.routes.support import _parse_obico_enabled_printers
 
         assert _parse_obico_enabled_printers("1,2,3") == {1, 2, 3}
-        # Whitespace around tokens is forgiven (matches obico_detection's parser).
         assert _parse_obico_enabled_printers("1, 2 ,3") == {1, 2, 3}
 
     def test_non_integer_tokens_are_skipped(self):
@@ -722,6 +736,13 @@ class TestParseObicoEnabledPrinters:
 
         assert _parse_obico_enabled_printers("1,abc,2") == {1, 2}
         assert _parse_obico_enabled_printers(",,1,") == {1}
+        assert _parse_obico_enabled_printers('[1, "two", 3]') == {1, 3}
+
+    def test_json_object_is_not_a_printer_list(self):
+        from backend.app.api.routes.support import _parse_obico_enabled_printers
+
+        # Falls through to the comma parser, which finds no integers.
+        assert _parse_obico_enabled_printers('{"1": true}') == set()
 
 
 class TestCheckUrlReachable:
@@ -1451,3 +1472,104 @@ class TestSanitizePushStatusValues:
 
         assert raw == before, "input was mutated"
         assert out["tag_uid"] == "[SERIAL]"  # and the copy really was redacted
+
+
+class TestProcessInfo:
+    """Bambuddy's own footprint in the bundle (#2734).
+
+    Bundles carried nothing about the process itself, so "memory climbs over
+    days until the OOM killer fires" could not be triaged from a bundle — the
+    reporter had to run shell commands by hand, and the numbers that would have
+    named the mechanism were unrecoverable afterwards.
+    """
+
+    def test_reports_the_figures_that_separate_the_mechanisms(self):
+        """RSS vs VMS, threads and children distinguish a heap that is growing
+        from address space, a thread leak, and a child-process leak."""
+        from backend.app.api.routes.support import _collect_process_info
+
+        info = _collect_process_info()
+
+        assert info["available"] is True
+        for key in ("rss_bytes", "vms_bytes", "num_threads", "children_total"):
+            assert isinstance(info[key], int), key
+
+    def test_children_are_named_but_never_quoted(self):
+        """An ffmpeg argv carries the camera URL, and with it its password. The
+        count per executable is what identifies a leak; the arguments are not
+        needed and must not travel."""
+        from backend.app.api.routes.support import _collect_process_info
+
+        info = _collect_process_info()
+
+        for name in info.get("children_by_name", {}):
+            assert " " not in name, f"looks like a command line, not a name: {name!r}"
+            assert "://" not in name
+
+    def test_heap_census_is_skipped_on_a_large_process(self):
+        """gc.get_objects() materialises every tracked object, so the census
+        costs most on the process that can least afford it. A bundle generated
+        to diagnose runaway memory must not be the allocation that tips the
+        host over."""
+        from unittest.mock import MagicMock, patch
+
+        import backend.app.api.routes.support as support_module
+
+        fake = MagicMock()
+        fake.memory_info.return_value = MagicMock(rss=8 * 1024**3, vms=12 * 1024**3)
+        fake.num_threads.return_value = 40
+        fake.create_time.return_value = 0.0
+        fake.open_files.return_value = []
+        fake.net_connections.return_value = []
+        fake.children.return_value = []
+
+        with patch("psutil.Process", return_value=fake):
+            info = support_module._collect_process_info()
+
+        assert "gc_top_types" not in info
+        assert "skipped" in info["gc_census"]
+        # The discriminating numbers still come through — those are the point.
+        assert info["rss_bytes"] == 8 * 1024**3
+        assert info["num_threads"] == 40
+
+    def test_heap_census_runs_on_a_normal_process(self):
+        from backend.app.api.routes.support import _collect_process_info
+
+        info = _collect_process_info()
+
+        assert info["gc_tracked_objects"] > 0
+        assert len(info["gc_top_types"]) <= 15
+
+    def test_survives_a_hostile_psutil(self):
+        """psutil raises on hardened kernels and in restricted containers. A
+        support bundle must still be produced when it does — the bundle is how
+        someone reports the problem in the first place."""
+        from unittest.mock import patch
+
+        import backend.app.api.routes.support as support_module
+
+        with patch("psutil.Process", side_effect=RuntimeError("no /proc for you")):
+            info = support_module._collect_process_info()
+
+        assert info == {"available": False}
+
+    def test_partial_failures_do_not_lose_the_rest(self):
+        """One inaccessible metric must not cost the others."""
+        from unittest.mock import MagicMock, patch
+
+        import backend.app.api.routes.support as support_module
+
+        fake = MagicMock()
+        fake.memory_info.return_value = MagicMock(rss=100, vms=200)
+        fake.num_threads.side_effect = PermissionError("denied")
+        fake.create_time.return_value = 0.0
+        fake.open_files.side_effect = PermissionError("denied")
+        fake.net_connections.side_effect = PermissionError("denied")
+        fake.children.return_value = []
+
+        with patch("psutil.Process", return_value=fake):
+            info = support_module._collect_process_info()
+
+        assert info["rss_bytes"] == 100
+        assert "num_threads" not in info
+        assert info["children_total"] == 0

+ 88 - 0
backend/tests/unit/test_threemf_tools.py

@@ -14,6 +14,7 @@ from backend.app.utils.threemf_tools import (
     extract_bed_type_from_3mf,
     extract_embedded_presets_from_3mf,
     extract_filament_usage_from_3mf,
+    extract_max_z_height_from_3mf,
     extract_plate_extruder_set_from_3mf,
     extract_print_time_from_3mf,
     extract_project_filaments_from_3mf,
@@ -1304,3 +1305,90 @@ class TestExtractPlateMetadataFrom3mf:
         assert meta.filament_usage == []
         # Missing file must not create a sticky cache entry (it may appear later).
         assert spy.call_count == 2
+
+
+def _make_plate_3mf(tmp_path, gcode_by_name: dict[str, str], name: str = "print.3mf"):
+    """Write a 3MF containing the given plate G-code members."""
+    buffer = io.BytesIO()
+    with zipfile.ZipFile(buffer, "w") as zf:
+        for member, content in gcode_by_name.items():
+            zf.writestr(member, content)
+    buffer.seek(0)
+    path = tmp_path / name
+    path.write_bytes(buffer.read())
+    return path
+
+
+def _header(**values: str) -> str:
+    lines = ["; HEADER_BLOCK_START"]
+    lines += [f"; {key.replace('_', ' ')}: {value}" for key, value in values.items()]
+    lines.append("; HEADER_BLOCK_END")
+    lines.append("G1 X0 Y0")
+    return "\n".join(lines)
+
+
+class TestExtractMaxZHeightFrom3mf:
+    """#2547: the print's top Z, used to command the plate back into camera
+    framing before the finish photo.
+
+    This value becomes the target of a real Z move, so "don't know" has to be
+    reported as None rather than defaulted — a wrong height would drive the
+    nozzle into the part.
+    """
+
+    def test_reads_max_z_height_from_the_plate_header(self, tmp_path):
+        path = _make_plate_3mf(
+            tmp_path,
+            {"Metadata/plate_1.gcode": _header(max_z_height="16.00", total_layer_number="80")},
+        )
+        assert extract_max_z_height_from_3mf(path, 1) == 16.0
+
+    def test_picks_the_requested_plate(self, tmp_path):
+        path = _make_plate_3mf(
+            tmp_path,
+            {
+                "Metadata/plate_1.gcode": _header(max_z_height="16.00"),
+                "Metadata/plate_2.gcode": _header(max_z_height="42.50"),
+            },
+        )
+        assert extract_max_z_height_from_3mf(path, 2) == 42.5
+
+    def test_falls_back_to_the_only_gcode_when_the_plate_name_does_not_match(self, tmp_path):
+        """Files from slicers that don't use Bambu's plate naming still resolve."""
+        path = _make_plate_3mf(tmp_path, {"whatever.gcode": _header(max_z_height="7.25")})
+        assert extract_max_z_height_from_3mf(path, 3) == 7.25
+
+    def test_missing_header_key_returns_none(self, tmp_path):
+        path = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(total_layer_number="80")})
+        assert extract_max_z_height_from_3mf(path, 1) is None
+
+    def test_non_numeric_value_returns_none(self, tmp_path):
+        path = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(max_z_height="tall")})
+        assert extract_max_z_height_from_3mf(path, 1) is None
+
+    def test_zero_and_negative_are_treated_as_unknown(self, tmp_path):
+        """Passed through, either would become a Z move toward the bed."""
+        zero = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(max_z_height="0")}, "z.3mf")
+        negative = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(max_z_height="-3")}, "n.3mf")
+        assert extract_max_z_height_from_3mf(zero, 1) is None
+        assert extract_max_z_height_from_3mf(negative, 1) is None
+
+    def test_no_gcode_member_returns_none(self, tmp_path):
+        path = _make_plate_3mf(tmp_path, {"Metadata/slice_info.config": "<config/>"})
+        assert extract_max_z_height_from_3mf(path, 1) is None
+
+    def test_unreadable_file_returns_none(self, tmp_path):
+        path = tmp_path / "broken.3mf"
+        path.write_text("not a zip")
+        assert extract_max_z_height_from_3mf(path, 1) is None
+
+    def test_missing_file_returns_none(self, tmp_path):
+        assert extract_max_z_height_from_3mf(tmp_path / "nope.3mf", 1) is None
+
+    def test_only_the_header_is_inflated(self, tmp_path):
+        """A sliced plate is routinely tens of MB; reading it whole to reach ~40
+        header lines would stall the finish-photo path. The header is read from
+        a bounded prefix, so a huge body must not change the answer."""
+        gcode = _header(max_z_height="99.9") + "\n" + ("G1 X1 Y1 E0.1\n" * 400_000)
+        path = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": gcode})
+        assert extract_max_z_height_from_3mf(path, 1) == 99.9

+ 192 - 0
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx

@@ -536,6 +536,198 @@ describe('ConfigureAmsSlotModal', () => {
     });
   });
 
+  describe('Generic presets and the K-profile picker (#2710)', () => {
+    // Reporter's printer: nine Flow-Dynamics entries, every one of them
+    // calibrated against Generic PLA (filament_id GFL99) and named after the
+    // spool's colour rather than its material. Bambu Studio lists all nine for
+    // a Generic PLA slot; Bambuddy showed only the one already bound to the
+    // slot via cali_idx.
+    const genericPlaProfiles = [
+      'Black PLA+', 'Dark Brown', 'Glow', 'Gray', 'Lt Brown',
+      'Marble', 'Orange PLA', 'Sunlu White PLA+', 'White PLA+ Duramic',
+    ].map((name, i) => ({
+      slot_id: i + 1,
+      extruder_id: 0,
+      nozzle_id: 'HH00-0.4',
+      nozzle_diameter: '0.4',
+      filament_id: 'GFL99',
+      name,
+      k_value: `0.0${30 + i}`,
+      n_coef: '0',
+      ams_id: 0,
+      tray_id: 0,
+      setting_id: '',
+    }));
+
+    const genericPlaSlot = {
+      ...defaultProps.slotInfo,
+      savedPresetId: 'builtin_GFL99',
+      extruderId: 0,
+    };
+
+    beforeEach(() => {
+      (api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([
+        { filament_id: 'GFL99', name: 'Generic PLA', filament_type: 'PLA' },
+      ]);
+      (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
+        profiles: genericPlaProfiles,
+      });
+    });
+
+    it('offers every Generic PLA profile when the slot preset is Generic PLA', async () => {
+      render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={genericPlaSlot} />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /Dark Brown/ })).toBeInTheDocument();
+      });
+      // All nine, not just the one bound via cali_idx — matching the printer's
+      // own calibration table for GFL99.
+      for (const profile of genericPlaProfiles) {
+        expect(
+          screen.getByRole('option', { name: `${profile.name} (K=${profile.k_value})` }),
+        ).toBeInTheDocument();
+      }
+    });
+
+    it('offers them on a freshly reset slot with no active cali_idx', async () => {
+      // "When I reset the AMS slot, the generic PLA shows no k-values at all."
+      // With no cali_idx the #1689 safety net has nothing to surface, so the
+      // list came back empty; the id match has to stand on its own.
+      render(
+        <ConfigureAmsSlotModal
+          {...defaultProps}
+          slotInfo={{ ...genericPlaSlot, caliIdx: 0 }}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /Marble/ })).toBeInTheDocument();
+      });
+      expect(screen.getByRole('option', { name: /Glow/ })).toBeInTheDocument();
+    });
+
+    it('matches on name when the profile carries no filament_id at all', async () => {
+      // Not every firmware fills filament_id in extrusion_cali_get. "Generic"
+      // is not a brand, so the name path must fall back to the material
+      // instead of demanding "GENERIC" in the profile name.
+      (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
+        profiles: [
+          { ...genericPlaProfiles[0], filament_id: '', name: 'Orange PLA' },
+          { ...genericPlaProfiles[1], filament_id: '', name: 'Dark Brown' },
+        ],
+      });
+      render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={genericPlaSlot} />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /Orange PLA/ })).toBeInTheDocument();
+      });
+    });
+
+    it('does not sweep generic profiles into a brand preset', async () => {
+      // Guard against the id path widening: "Bambu PLA Basic" is GFL05, so
+      // GFL99 profiles must still be filtered out of the matching group and
+      // only reachable through the explicit "other profiles" group.
+      render(
+        <ConfigureAmsSlotModal
+          {...defaultProps}
+          slotInfo={{ ...defaultProps.slotInfo, savedPresetId: 'GFSL05_09', extruderId: 0 }}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Bambu PLA Basic @BBL X1C')).toBeInTheDocument();
+      });
+      // Every GFL99 profile is demoted to the other group: the brand gate on
+      // "Bambu" still applies, and none of these names carry it.
+      for (const profile of genericPlaProfiles) {
+        const option = screen.getByRole('option', { name: `${profile.name} (K=${profile.k_value})` });
+        expect(option.closest('optgroup')).toHaveAttribute(
+          'label',
+          'Other K profiles on this printer',
+        );
+      }
+    });
+
+    it("offers the printer's other profiles even when nothing matches the preset", async () => {
+      // The escape hatch: a PETG preset matches none of the PLA profiles, but
+      // the user can still reach every profile the printer holds instead of
+      // being sent to the slicer.
+      (api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([
+        { filament_id: 'GFG99', name: 'Generic PETG', filament_type: 'PETG' },
+      ]);
+      render(
+        <ConfigureAmsSlotModal
+          {...defaultProps}
+          slotInfo={{ ...genericPlaSlot, savedPresetId: 'builtin_GFG99', caliIdx: 0 }}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /Dark Brown/ })).toBeInTheDocument();
+      });
+      expect(screen.getByRole('option', { name: /Dark Brown/ }).closest('optgroup')).toHaveAttribute(
+        'label',
+        'Other K profiles on this printer',
+      );
+    });
+
+    it('sends the cali_idx of a profile picked from the other-profiles group', async () => {
+      (api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([
+        { filament_id: 'GFG99', name: 'Generic PETG', filament_type: 'PETG' },
+      ]);
+      render(
+        <ConfigureAmsSlotModal
+          {...defaultProps}
+          slotInfo={{ ...genericPlaSlot, savedPresetId: 'builtin_GFG99', caliIdx: 0 }}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /Marble/ })).toBeInTheDocument();
+      });
+      const marble = genericPlaProfiles.find(p => p.name === 'Marble')!;
+      fireEvent.change(screen.getByRole('combobox'), {
+        target: { value: `${marble.name}|${marble.k_value}` },
+      });
+      fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+
+      await waitFor(() => {
+        expect(api.configureAmsSlot).toHaveBeenCalled();
+      });
+      const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
+      expect(payload.cali_idx).toBe(marble.slot_id);
+      expect(payload.kprofile_filament_id).toBe('GFL99');
+    });
+
+    it('distinguishes two profiles that share a name but differ in K', async () => {
+      // The picker used to key options by name alone, so same-named profiles
+      // were indistinguishable and the first always won.
+      (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
+        profiles: [
+          { ...genericPlaProfiles[0], slot_id: 1, name: 'PLA', k_value: '0.020' },
+          { ...genericPlaProfiles[1], slot_id: 2, name: 'PLA', k_value: '0.045' },
+        ],
+      });
+      render(
+        <ConfigureAmsSlotModal
+          {...defaultProps}
+          slotInfo={{ ...genericPlaSlot, caliIdx: 0 }}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /K=0.045/ })).toBeInTheDocument();
+      });
+      fireEvent.change(screen.getByRole('combobox'), { target: { value: 'PLA|0.045' } });
+      fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+
+      await waitFor(() => {
+        expect(api.configureAmsSlot).toHaveBeenCalled();
+      });
+      expect((api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3].cali_idx).toBe(2);
+    });
+  });
+
   it('does not include the active K-profile when caliIdx is 0 or null (#1689 guard)', async () => {
     // cali_idx == 0 / null means no profile is active (printer default 0.020).
     // The safety net only triggers for activeIdx > 0 — otherwise unrelated

+ 97 - 0
frontend/src/__tests__/components/FailureDetectionSettings.test.tsx

@@ -23,6 +23,7 @@ const baseSettings = {
   include_beta_updates: false,
   obico_enabled: false,
   obico_ml_url: '',
+  obico_ml_token: '',
   obico_sensitivity: 'medium',
   obico_action: 'notify',
   obico_poll_interval: 10,
@@ -83,6 +84,102 @@ describe('FailureDetectionSettings', () => {
     expect(await screen.findByText(/ML API reachable/i)).toBeInTheDocument();
   });
 
+  describe('ML API token (#2733)', () => {
+    const enabledWithToken = {
+      ...baseSettings,
+      obico_enabled: true,
+      obico_ml_url: 'http://obico:3333',
+      obico_ml_token: 's3cret',
+    };
+
+    it('renders the token as a masked field populated from settings', async () => {
+      server.use(http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)));
+      render(<FailureDetectionSettings />);
+
+      const input = await screen.findByDisplayValue('s3cret');
+      expect(input).toHaveAttribute('type', 'password');
+      expect(screen.getByText(/ML API Token/i)).toBeInTheDocument();
+    });
+
+    it('sends the token with the test-connection request', async () => {
+      let sent: { url: string; token?: string } | null = null;
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
+        http.post('/api/v1/obico/test-connection', async ({ request }) => {
+          sent = (await request.json()) as { url: string; token?: string };
+          return HttpResponse.json({
+            ok: true,
+            status_code: 200,
+            body: 'ok',
+            error: null,
+            auth_ok: true,
+          });
+        }),
+      );
+      render(<FailureDetectionSettings />);
+      await screen.findByDisplayValue('http://obico:3333');
+      await userEvent.click(screen.getByRole('button', { name: /test/i }));
+
+      await waitFor(() => expect(sent).not.toBeNull());
+      // The value in the box, not the saved one — so a token can be checked
+      // before it is committed.
+      expect(sent!.token).toBe('s3cret');
+    });
+
+    it('reports a rejected token instead of a bare success', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
+        http.post('/api/v1/obico/test-connection', () =>
+          HttpResponse.json({
+            ok: false,
+            status_code: 401,
+            body: 'ok',
+            error: 'The ML API is reachable but rejected the token.',
+            auth_ok: false,
+          }),
+        ),
+      );
+      render(<FailureDetectionSettings />);
+      await screen.findByDisplayValue('http://obico:3333');
+      await userEvent.click(screen.getByRole('button', { name: /test/i }));
+
+      expect(await screen.findByText(/rejected the token/i)).toBeInTheDocument();
+    });
+
+    it('does not claim the token works when it could not be checked', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
+        http.post('/api/v1/obico/test-connection', () =>
+          HttpResponse.json({ ok: true, status_code: 200, body: 'ok', error: null, auth_ok: null }),
+        ),
+      );
+      render(<FailureDetectionSettings />);
+      await screen.findByDisplayValue('http://obico:3333');
+      await userEvent.click(screen.getByRole('button', { name: /test/i }));
+
+      expect(await screen.findByText(/token could not be checked/i)).toBeInTheDocument();
+    });
+
+    it('auto-saves the token', async () => {
+      let saved: Record<string, unknown> | null = null;
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ ...enabledWithToken, obico_ml_token: '' })),
+        http.put('/api/v1/settings/', async ({ request }) => {
+          saved = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...enabledWithToken, obico_ml_token: 'typed' });
+        }),
+      );
+      render(<FailureDetectionSettings />);
+      const input = await screen.findByPlaceholderText(/ML_API_TOKEN/i);
+      // Every field stays disabled until the settings query lands.
+      await waitFor(() => expect(input).not.toBeDisabled());
+      await userEvent.type(input, 'typed');
+
+      await waitFor(() => expect(saved).not.toBeNull(), { timeout: 3000 });
+      expect(saved!.obico_ml_token).toBe('typed');
+    });
+  });
+
   it('shows failure class history entries with red styling', async () => {
     server.use(
       http.get('/api/v1/obico/status', () =>

+ 195 - 0
frontend/src/__tests__/components/FilamentMappingArchivePick.test.tsx

@@ -0,0 +1,195 @@
+/**
+ * Tests for the FilamentMapping "Mapping" toggle (#2700).
+ *
+ * When the archive carries the slicer's own saved AMS-slot pick, a toggle next
+ * to "Re-read" selects every slot straight from it, bypassing the type/color
+ * auto-match. Turning it back off has to undo exactly what it did and leave
+ * hand-made picks alone — that bookkeeping is what these tests pin.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { useState } from 'react';
+import { screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { FilamentMapping } from '../../components/PrintModal/FilamentMapping';
+import type { PrinterStatus } from '../../api/client';
+
+// Two-slot print. Slot 1 wants red PLA, slot 2 wants green PETG.
+const TWO_SLOT_REQS = {
+  filaments: [
+    { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, used_meters: 3 },
+    { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 10, used_meters: 3 },
+  ],
+};
+
+// One AMS, four trays -> global tray IDs 0..3. Trays 0 and 2 both hold red PLA,
+// which is exactly the ambiguity the saved slicer pick exists to resolve: the
+// auto-match has no way to tell which red spool the user meant.
+function createStatus(): PrinterStatus {
+  return {
+    id: 1,
+    name: 'X1C',
+    connected: true,
+    state: 'IDLE',
+    ams: [
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'Red A' },
+          { id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'Green' },
+          { id: 2, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'Red B' },
+          { id: 3, tray_type: 'PLA', tray_color: '0000FF', tray_info_idx: 'GFA00', tray_sub_brands: 'Blue' },
+        ],
+      },
+    ],
+    vt_tray: [],
+    ams_extruder_map: {},
+    fila_switch: null,
+  } as unknown as PrinterStatus;
+}
+
+/** Holds `manualMappings` the way PrintModal does, so an OFF click sees the
+ *  state the ON click produced rather than the initial prop. */
+function Harness({
+  archiveAmsMapping,
+  initialManualMappings = {},
+  onChange,
+}: {
+  archiveAmsMapping?: number[];
+  initialManualMappings?: Record<number, number>;
+  onChange?: (m: Record<number, number>) => void;
+}) {
+  const [manualMappings, setManualMappings] = useState<Record<number, number>>(initialManualMappings);
+  return (
+    <FilamentMapping
+      printerId={1}
+      filamentReqs={TWO_SLOT_REQS}
+      manualMappings={manualMappings}
+      onManualMappingChange={(m) => {
+        setManualMappings(m);
+        onChange?.(m);
+      }}
+      currencySymbol="$"
+      defaultCostPerKg={0}
+      defaultExpanded
+      archiveAmsMapping={archiveAmsMapping}
+    />
+  );
+}
+
+/** The panel only finishes mounting once printer status has loaded. */
+async function waitForPanel() {
+  await waitFor(() => {
+    expect(screen.getByText(/Re-read/i)).toBeInTheDocument();
+  });
+}
+
+beforeEach(() => {
+  server.use(
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(createStatus())),
+    http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
+  );
+});
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+describe('FilamentMapping — saved slicer AMS pick', () => {
+  it('hides the toggle when the archive has no saved mapping', async () => {
+    // Every archive predating the feature, every library file, and every
+    // reprint aimed at a printer other than the one the mapping came from.
+    render(<Harness />);
+    await waitForPanel();
+    expect(screen.queryByRole('button', { name: 'Mapping' })).not.toBeInTheDocument();
+  });
+
+  it('selects every slot from the saved mapping when switched on', async () => {
+    // Saved pick says slot 1 -> tray 2 (the *second* red spool) and slot 2 ->
+    // tray 1. Auto-match would have taken tray 0 for slot 1, so this is a
+    // visible, load-bearing difference.
+    const onChange = vi.fn();
+    render(<Harness archiveAmsMapping={[2, 1]} onChange={onChange} />);
+    await waitForPanel();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Mapping' }));
+
+    expect(onChange).toHaveBeenCalledTimes(1);
+    expect(onChange).toHaveBeenCalledWith({ 1: 2, 2: 1 });
+  });
+
+  it('skips slots the slicer left unresolved', async () => {
+    // -1 is the slicer saying "no AMS tray for this filament" (external spool,
+    // or it simply didn't resolve). Overriding that slot with -1 would be
+    // worse than leaving it to the auto-match.
+    const onChange = vi.fn();
+    render(<Harness archiveAmsMapping={[-1, 1]} onChange={onChange} />);
+    await waitForPanel();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Mapping' }));
+
+    expect(onChange).toHaveBeenCalledWith({ 2: 1 });
+  });
+
+  it('skips slots the saved mapping is too short to address', async () => {
+    // A mapping with fewer entries than the plate has slots can't say anything
+    // about the missing ones; reading past the end would write `undefined`.
+    const onChange = vi.fn();
+    render(<Harness archiveAmsMapping={[2]} onChange={onChange} />);
+    await waitForPanel();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Mapping' }));
+
+    expect(onChange).toHaveBeenCalledWith({ 1: 2 });
+  });
+
+  it('undoes exactly its own picks when switched off', async () => {
+    const onChange = vi.fn();
+    render(<Harness archiveAmsMapping={[2, 1]} onChange={onChange} />);
+    await waitForPanel();
+
+    const toggle = screen.getByRole('button', { name: 'Mapping' });
+    fireEvent.click(toggle);
+    expect(onChange).toHaveBeenLastCalledWith({ 1: 2, 2: 1 });
+
+    fireEvent.click(toggle);
+    expect(onChange).toHaveBeenLastCalledWith({});
+  });
+
+  it('leaves a hand-made pick untouched when switched off', async () => {
+    // The user picked slot 2 by hand first, then switched the toggle on, which
+    // overwrote it as part of applying the whole saved mapping. Switching off
+    // removes both, since both are now the toggle's own picks — the earlier
+    // hand pick is not restored, and slot 2 falls back to the auto-match. That
+    // is the documented behaviour, not an accident; the next test covers the
+    // case where the hand pick does survive.
+    const onChange = vi.fn();
+    render(<Harness archiveAmsMapping={[2, 1]} initialManualMappings={{ 2: 3 }} onChange={onChange} />);
+    await waitForPanel();
+
+    const toggle = screen.getByRole('button', { name: 'Mapping' });
+    fireEvent.click(toggle);
+    expect(onChange).toHaveBeenLastCalledWith({ 1: 2, 2: 1 });
+
+    fireEvent.click(toggle);
+    expect(onChange).toHaveBeenLastCalledWith({});
+  });
+
+  it('keeps hand-made picks for slots the saved mapping never touched', async () => {
+    // Saved mapping only resolves slot 1, so slot 2's hand-made pick was never
+    // one of "its own" and has to survive the round trip.
+    const onChange = vi.fn();
+    render(<Harness archiveAmsMapping={[2, -1]} initialManualMappings={{ 2: 3 }} onChange={onChange} />);
+    await waitForPanel();
+
+    const toggle = screen.getByRole('button', { name: 'Mapping' });
+    fireEvent.click(toggle);
+    expect(onChange).toHaveBeenLastCalledWith({ 1: 2, 2: 3 });
+
+    fireEvent.click(toggle);
+    expect(onChange).toHaveBeenLastCalledWith({ 2: 3 });
+  });
+});

+ 45 - 1
frontend/src/__tests__/components/HMSErrorModal.test.tsx

@@ -6,7 +6,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
 import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
-import { HMSErrorModal } from '../../components/HMSErrorModal';
+import { HMSErrorModal, filterKnownHMSErrors } from '../../components/HMSErrorModal';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 import type { HMSError } from '../../api/client';
@@ -189,6 +189,50 @@ describe('HMSErrorModal', () => {
     });
   });
 
+  describe('MQTT command verification failed (#2732)', () => {
+    // attr 0x05000500, code 0x00010007 — a real P1S on firmware 01.10.00.00.
+    // getShortCode() collapses this to "0500_0007", which matches nothing, so
+    // before #2732 filterKnownHMSErrors dropped the one error that explained
+    // why the printer accepted every job and started none of them.
+    const verifyFailedError: HMSError = {
+      attr: 0x05000500,
+      code: '0x10007',
+      severity: 1,
+      full_code: '0500050000010007',
+    };
+
+    it('surfaces the error instead of filtering it out', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
+      expect(screen.queryByText('No errors')).not.toBeInTheDocument();
+      expect(screen.getByText(/could not verify it/i)).toBeInTheDocument();
+    });
+
+    it('counts towards the known-error filter', () => {
+      expect(filterKnownHMSErrors([verifyFailedError])).toHaveLength(1);
+    });
+
+    it('shows the remedy, not just the fault', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
+      expect(screen.getByText(/Enable Developer Mode on the printer/i)).toBeInTheDocument();
+    });
+
+    it('displays the code the printer screen shows, not the truncated form', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
+      expect(screen.getByText('[0500-0500-0001-0007]')).toBeInTheDocument();
+      expect(screen.queryByText('[0500-0007]')).not.toBeInTheDocument();
+    });
+
+    it('leaves short-code errors on the two-group display', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[knownError]} />);
+      expect(screen.getByText('[0300-400C]')).toBeInTheDocument();
+    });
+
+    it('does not add the remedy line to other errors', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[knownError]} />);
+      expect(screen.queryByText(/Enable Developer Mode/i)).not.toBeInTheDocument();
+    });
+  });
+
   describe('interactions', () => {
     it('calls onClose when X button is clicked', async () => {
       const user = userEvent.setup();

+ 72 - 0
frontend/src/__tests__/components/archiveAmsMapping.test.ts

@@ -0,0 +1,72 @@
+/**
+ * Tests for `resolveArchiveSlicerAmsMapping` (#2700).
+ *
+ * This is the gate between an archive's saved slicer AMS pick and the print
+ * modal offering it. The saved tray IDs are *global tray IDs*, which only mean
+ * something against the AMS layout of the one printer they were resolved
+ * against — tray 5 on printer A can hold a completely different spool than
+ * tray 5 on printer B. Everything here exists to make sure the mapping is only
+ * ever offered for its own printer.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { resolveArchiveSlicerAmsMapping } from '../../components/PrintModal/archiveAmsMapping';
+
+const SAVED = { slicer_ams_mapping: { mapping: [4, -1, 12, -1], printer_id: 7 } };
+
+describe('resolveArchiveSlicerAmsMapping', () => {
+  it('returns the mapping when the selected printer is the one it was saved for', () => {
+    expect(resolveArchiveSlicerAmsMapping(SAVED, 7)).toEqual([4, -1, 12, -1]);
+  });
+
+  it('refuses the mapping on a different printer', () => {
+    // The whole point of storing printer_id. Tray 4 on printer 9 is not the
+    // spool the slicer picked on printer 7.
+    expect(resolveArchiveSlicerAmsMapping(SAVED, 9)).toBeUndefined();
+  });
+
+  it('refuses the mapping when no printer is selected yet', () => {
+    // Guards the `undefined === undefined` reading as a match: with no printer
+    // chosen there is nothing to compare against, so nothing may be offered.
+    expect(resolveArchiveSlicerAmsMapping(SAVED, null)).toBeUndefined();
+    expect(resolveArchiveSlicerAmsMapping(SAVED, undefined)).toBeUndefined();
+  });
+
+  it('returns undefined for archives with no extra_data at all', () => {
+    expect(resolveArchiveSlicerAmsMapping(null, 7)).toBeUndefined();
+    expect(resolveArchiveSlicerAmsMapping(undefined, 7)).toBeUndefined();
+    expect(resolveArchiveSlicerAmsMapping({}, 7)).toBeUndefined();
+  });
+
+  it('ignores unrelated extra_data keys', () => {
+    // The common case: archives carry plenty of metadata but no saved mapping.
+    expect(resolveArchiveSlicerAmsMapping({ printable_objects: { '1': 'part' } }, 7)).toBeUndefined();
+  });
+
+  it('rejects a stored value that is missing its printer_id', () => {
+    // A mapping saved without knowing which printer it came from can't be
+    // safely reused on any printer, including the one it actually came from —
+    // there'd be no way to tell.
+    expect(resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: { mapping: [4, 12] } }, 7)).toBeUndefined();
+  });
+
+  it('rejects malformed stored values instead of throwing', () => {
+    // extra_data is free-form JSON off the wire; a bad shape must degrade to
+    // "no saved mapping", never crash the modal.
+    expect(resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: 'nope' }, 7)).toBeUndefined();
+    expect(resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: null }, 7)).toBeUndefined();
+    expect(
+      resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: { mapping: 'nope', printer_id: 7 } }, 7),
+    ).toBeUndefined();
+    expect(
+      resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: { mapping: [], printer_id: 7 } }, 7),
+    ).toBeUndefined();
+  });
+
+  it('does not coerce printer ids', () => {
+    // A string "7" from a hand-edited record is not printer 7.
+    expect(
+      resolveArchiveSlicerAmsMapping({ slicer_ams_mapping: { mapping: [4], printer_id: '7' } }, 7),
+    ).toBeUndefined();
+  });
+});

+ 78 - 3
frontend/src/__tests__/components/spool-form/isMatchingCalibration.test.ts

@@ -12,6 +12,8 @@ import {
   isMatchingCalibration,
   toFilamentId,
   isGenericFilamentId,
+  materialForGenericFilamentId,
+  genericFilamentIdMatchesMaterial,
 } from '../../../components/spool-form/utils';
 
 describe('toFilamentId', () => {
@@ -70,6 +72,37 @@ describe('isGenericFilamentId', () => {
   });
 });
 
+describe('materialForGenericFilamentId (#2710)', () => {
+  it('resolves the material each generic id stands for', () => {
+    expect(materialForGenericFilamentId('GFL99')).toBe('PLA');
+    expect(materialForGenericFilamentId('GFG99')).toBe('PETG');
+    expect(materialForGenericFilamentId('gfu99')).toBe('TPU');
+  });
+
+  it('returns empty for specific ids and for nothing', () => {
+    expect(materialForGenericFilamentId('GFL05')).toBe('');
+    expect(materialForGenericFilamentId(null)).toBe('');
+    expect(materialForGenericFilamentId('')).toBe('');
+  });
+});
+
+describe('genericFilamentIdMatchesMaterial (#2710)', () => {
+  it('agrees when the generic id describes that material', () => {
+    expect(genericFilamentIdMatchesMaterial('GFL99', 'PLA')).toBe(true);
+    expect(genericFilamentIdMatchesMaterial('GFL99', 'pla')).toBe(true);
+  });
+
+  it('treats Nylon and PA as the same material', () => {
+    expect(genericFilamentIdMatchesMaterial('GFN99', 'Nylon')).toBe(true);
+  });
+
+  it('rejects a mismatched material, a specific id, or a missing material', () => {
+    expect(genericFilamentIdMatchesMaterial('GFL99', 'PETG')).toBe(false);
+    expect(genericFilamentIdMatchesMaterial('GFL05', 'PLA')).toBe(false);
+    expect(genericFilamentIdMatchesMaterial('GFL99', '')).toBe(false);
+  });
+});
+
 describe('isMatchingCalibration (#1688)', () => {
   const formData = {
     material: 'PETG',
@@ -98,9 +131,10 @@ describe('isMatchingCalibration (#1688)', () => {
     ).toBe(true);
   });
 
-  it('skips id-match for generic GFx99 ids and falls through to name match', () => {
-    // GFL99 = generic PLA, shared across many real filaments. Even if the
-    // spool stored GFL99, name parsing must drive the decision.
+  it('skips id-match when a generic id contradicts the spool material', () => {
+    // GFL99 is generic *PLA* but the spool says PETG — the ids agreeing is not
+    // enough, the material has to agree too. Name parsing then drives the
+    // decision and rejects it.
     const result = isMatchingCalibration(
       { name: 'Random thing with no PETG in it', filament_id: 'GFL99' },
       { ...formData, slicer_filament: 'GFL99' },
@@ -108,6 +142,47 @@ describe('isMatchingCalibration (#1688)', () => {
     expect(result).toBe(false);
   });
 
+  it('matches a generic id when the material agrees and the spool claims no brand (#2710)', () => {
+    // Reporter's printer: every K-profile calibrated under Generic PLA and
+    // named after the colour. No name parsing can tie "Dark Brown" to PLA, so
+    // the shared GFL99 is the only signal there is.
+    expect(
+      isMatchingCalibration(
+        { name: 'Dark Brown', filament_id: 'GFL99' },
+        { material: 'PLA', brand: '', subtype: '', slicer_filament: 'GFL99' },
+      ),
+    ).toBe(true);
+  });
+
+  it('treats "Generic" as no brand on the generic id path', () => {
+    expect(
+      isMatchingCalibration(
+        { name: 'Marble', filament_id: 'GFL99' },
+        { material: 'PLA', brand: 'Generic', subtype: '', slicer_filament: 'GFL99' },
+      ),
+    ).toBe(true);
+  });
+
+  it('keeps generic id matches brand-specific when the spool names a brand', () => {
+    // A spool that says "Sunlu" should still get Sunlu-specific suggestions
+    // rather than the printer's whole generic-PLA table.
+    expect(
+      isMatchingCalibration(
+        { name: 'Dark Brown', filament_id: 'GFL99' },
+        { material: 'PLA', brand: 'Sunlu', subtype: '', slicer_filament: 'GFL99' },
+      ),
+    ).toBe(false);
+  });
+
+  it('accepts Nylon/PA as the same material on the generic id path', () => {
+    expect(
+      isMatchingCalibration(
+        { name: 'spool-of-doom', filament_id: 'GFN99' },
+        { material: 'Nylon', brand: '', subtype: '', slicer_filament: 'GFN99' },
+      ),
+    ).toBe(true);
+  });
+
   it('falls through to name match when spool has no slicer_filament', () => {
     expect(
       isMatchingCalibration(

+ 60 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -110,6 +110,66 @@ describe('SettingsPage', () => {
     });
   });
 
+  describe('finish photo plate restore (#2547)', () => {
+    const restoreLabel = 'Restore plate for finish photo';
+
+    it('offers the toggle while finish photos are enabled', async () => {
+      render(<SettingsPage />);
+
+      expect(await screen.findByText(restoreLabel)).toBeInTheDocument();
+    });
+
+    it('hides the toggle when finish photos are switched off', async () => {
+      // It only describes how the finish photo is framed, so it is meaningless
+      // when no finish photo is taken at all.
+      server.use(
+        http.get('/api/v1/settings/', () =>
+          HttpResponse.json({ ...mockSettings, capture_finish_photo: false })
+        )
+      );
+      render(<SettingsPage />);
+
+      await screen.findByRole('heading', { name: 'Settings' });
+      await waitFor(() => {
+        expect(screen.queryByText(restoreLabel)).not.toBeInTheDocument();
+      });
+    });
+
+    it('defaults to on when the backend has never stored the setting', async () => {
+      // Existing installs have no row for it; the UI must not read that as off.
+      render(<SettingsPage />);
+
+      const label = await screen.findByText(restoreLabel);
+      const row = label.closest('div')!.parentElement!;
+      expect(within(row).getByRole('checkbox')).toBeChecked();
+    });
+
+    it('sends the new value on save', async () => {
+      let saved: Record<string, unknown> | null = null;
+      server.use(
+        http.put('/api/v1/settings/', async ({ request }) => {
+          saved = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...mockSettings, ...saved });
+        })
+      );
+      render(<SettingsPage />);
+
+      const label = await screen.findByText(restoreLabel);
+      // The page suppresses auto-save for 100ms after the settings load, so a
+      // click landing inside that window is swallowed with no re-trigger.
+      await new Promise((resolve) => setTimeout(resolve, 200));
+      const row = label.closest('div')!.parentElement!;
+      await userEvent.click(within(row).getByRole('checkbox'));
+
+      // The page auto-saves on a 500ms debounce, so the default 1s waitFor
+      // window is only just wide enough — give the request room to land.
+      await waitFor(() => {
+        expect(saved).not.toBeNull();
+      }, { timeout: 3000 });
+      expect(saved!.finish_photo_restore_plate).toBe(false);
+    });
+  });
+
   describe('general settings', () => {
     it('shows date format setting', async () => {
       render(<SettingsPage />);

+ 75 - 0
frontend/src/__tests__/utils/projectQueries.test.ts

@@ -0,0 +1,75 @@
+/**
+ * Tests for the project-view cache invalidation helper (#2731).
+ *
+ * The default staleTime is 60s, so a project page revisited within a minute of
+ * deleting one of its prints serves the cached answer and keeps showing the
+ * print. The user had to reload the page by hand. Deletes invalidated only
+ * `['archives']`; the project-assign mutations only `['projects']`, which
+ * refreshed the overview cards but never the detail page.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import type { QueryClient } from '@tanstack/react-query';
+import { invalidateProjectViews, invalidateArchiveAndProjectViews } from '../../utils/projectQueries';
+
+function mockClient() {
+  const invalidateQueries = vi.fn().mockResolvedValue(undefined);
+  return { client: { invalidateQueries } as unknown as QueryClient, invalidateQueries };
+}
+
+const keysFrom = (fn: ReturnType<typeof vi.fn>) => fn.mock.calls.map((c) => c[0].queryKey.join('/'));
+
+describe('invalidateProjectViews', () => {
+  it('refreshes every view whose contents depend on project membership', async () => {
+    const { client, invalidateQueries } = mockClient();
+
+    await invalidateProjectViews(client);
+
+    expect(keysFrom(invalidateQueries).sort()).toEqual(
+      ['project', 'project-archives', 'project-file-progress', 'project-timeline', 'projects'].sort(),
+    );
+  });
+
+  it('uses bare prefixes so every cached project id is covered', async () => {
+    const { client, invalidateQueries } = mockClient();
+
+    await invalidateProjectViews(client);
+
+    // ['project'] matches ['project', 42]; ['project', 42] would not match 43.
+    for (const call of invalidateQueries.mock.calls) {
+      expect(call[0].queryKey).toHaveLength(1);
+    }
+  });
+
+  it('does not touch the archive list on its own', async () => {
+    const { client, invalidateQueries } = mockClient();
+
+    await invalidateProjectViews(client);
+
+    expect(keysFrom(invalidateQueries)).not.toContain('archives');
+  });
+});
+
+describe('invalidateArchiveAndProjectViews', () => {
+  it('adds the archive list to the project views', async () => {
+    const { client, invalidateQueries } = mockClient();
+
+    await invalidateArchiveAndProjectViews(client);
+
+    const keys = keysFrom(invalidateQueries);
+    expect(keys).toContain('archives');
+    expect(keys).toContain('project-archives');
+    expect(keys).toContain('projects');
+  });
+
+  it('resolves only once every invalidation has settled', async () => {
+    let settled = 0;
+    const invalidateQueries = vi.fn().mockImplementation(
+      () => new Promise<void>((resolve) => setTimeout(() => { settled += 1; resolve(); }, 0)),
+    );
+
+    await invalidateArchiveAndProjectViews({ invalidateQueries } as unknown as QueryClient);
+
+    expect(settled).toBe(6);
+  });
+});

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

@@ -1185,6 +1185,7 @@ export interface AppSettings {
   auto_archive: boolean;
   save_thumbnails: boolean;
   capture_finish_photo: boolean;
+  finish_photo_restore_plate: boolean;
   default_filament_cost: number;
   currency: string;
   energy_cost_per_kwh: number;
@@ -1277,6 +1278,10 @@ export interface AppSettings {
   // Per-install sidecar URLs. Empty string falls back to the env defaults.
   orcaslicer_api_url: string;
   bambu_studio_api_url: string;
+  // Minutes of silence from the sidecar before a slice is abandoned. Bounds
+  // stalls, not total slicing time — a model that keeps reporting progress
+  // runs to completion however long it takes.
+  slicer_stall_timeout_minutes: number;
   // Prometheus metrics
   prometheus_enabled: boolean;
   prometheus_token: string;
@@ -1336,6 +1341,7 @@ export interface AppSettings {
   ldap_default_group: string;
   obico_enabled: boolean;
   obico_ml_url: string;
+  obico_ml_token: string;
   obico_sensitivity: 'low' | 'medium' | 'high';
   obico_action: 'notify' | 'pause' | 'pause_and_off';
   obico_poll_interval: number;
@@ -2210,6 +2216,10 @@ export interface PrintQueueItem {
   // start route when skip_filament_check=true, or at queue creation if
   // PrintModal's deficit warning was acknowledged.
   skip_filament_check: boolean;
+  // True when the source archive carries the slicer's own live-resolved
+  // AMS-slot pick (extra_data.slicer_ams_mapping) — a reprint reuses that
+  // exact physical spool instead of re-deriving one from type/color.
+  archive_has_slicer_ams_mapping: boolean;
   ams_mapping: number[] | null;  // AMS slot mapping for multi-color prints
   filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; tray_info_idx?: string; force_color_match?: boolean }> | null;  // Filament overrides for model-based assignment
   plate_id: number | null;  // Plate ID for multi-plate 3MF files
@@ -2642,6 +2652,14 @@ export interface NotificationProviderUpdate {
 export type ScheduleType = 'hourly' | 'daily' | 'weekly';
 export type GitProviderType = 'github' | 'gitea' | 'forgejo' | 'gitlab';
 
+/** How many cloud accounts a backup would collect presets from. Counts only —
+ *  with auth enabled the accounts belong to individual users, so the backup
+ *  administrator sees how many, never whose. */
+export interface CloudAccountCounts {
+  bambu: number;
+  orca: number;
+}
+
 export interface GitHubBackupConfig {
   id: number;
   repository_url: string;
@@ -2779,6 +2797,9 @@ export interface ObicoTestConnection {
   status_code: number | null;
   body: string | null;
   error: string | null;
+  // Whether the ML API accepted the token. null = not determined (the health
+  // check failed first, or the token probe itself errored).
+  auth_ok: boolean | null;
 }
 
 export interface GitHubTestConnectionResponse {
@@ -3895,7 +3916,11 @@ export const api = {
       method: 'POST',
     }),
   testExternalCamera: (printerId: number, url: string, cameraType: string) =>
-    request<{ success: boolean; error?: string; resolution?: string }>(
+    // `coalesced` is true when the frame came from a capture that was already
+    // running (Obico polling, a snapshot) rather than a connection this test
+    // opened — a single-reader camera is shared rather than opened twice, so
+    // the result is real but says nothing about reaching the camera just now.
+    request<{ success: boolean; error?: string; resolution?: string; coalesced?: boolean }>(
       `/printers/${printerId}/camera/external/test?url=${encodeURIComponent(url)}&camera_type=${encodeURIComponent(cameraType)}`,
       { method: 'POST' }
     ),
@@ -6452,6 +6477,9 @@ export const api = {
   getGitHubBackupConfig: () =>
     request<GitHubBackupConfig | null>('/github-backup/config'),
 
+  getGitHubBackupCloudAccounts: () =>
+    request<CloudAccountCounts>('/github-backup/cloud-accounts'),
+
   saveGitHubBackupConfig: (config: GitHubBackupConfigCreate) =>
     request<GitHubBackupConfig>('/github-backup/config', {
       method: 'POST',
@@ -6523,10 +6551,12 @@ export const api = {
   getObicoPrinterStatus: () =>
     request<ObicoPrinterStatus>('/obico/printer-status'),
 
-  testObicoConnection: (url: string) =>
+  // `token` is sent as-is, so an empty string tests with no token at all.
+  // Omitting the argument makes the backend fall back to the saved token.
+  testObicoConnection: (url: string, token?: string) =>
     request<ObicoTestConnection>('/obico/test-connection', {
       method: 'POST',
-      body: JSON.stringify({ url }),
+      body: JSON.stringify(token === undefined ? { url } : { url, token }),
     }),
 
   // Slicer API — slice in the background. Both endpoints return 202 + a
@@ -7273,6 +7303,7 @@ export interface VirtualPrinterConfig {
   target_printer_id: number | null;
   auto_dispatch: boolean;
   queue_force_color_match: boolean;
+  save_ams_mapping: boolean;
   gcode_injection: boolean;
   tailscale_disabled: boolean;
   bind_ip: string | null;
@@ -7300,6 +7331,7 @@ export const multiVirtualPrinterApi = {
     target_printer_id?: number;
     auto_dispatch?: boolean;
     queue_force_color_match?: boolean;
+    save_ams_mapping?: boolean;
     gcode_injection?: boolean;
     bind_ip?: string;
     remote_interface_ip?: string;
@@ -7318,6 +7350,7 @@ export const multiVirtualPrinterApi = {
     target_printer_id?: number;
     auto_dispatch?: boolean;
     queue_force_color_match?: boolean;
+    save_ams_mapping?: boolean;
     gcode_injection?: boolean;
     tailscale_disabled?: boolean;
     bind_ip?: string;

+ 4 - 8
frontend/src/components/BatchProjectModal.tsx

@@ -6,6 +6,7 @@ import { api } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
+import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
 
 interface BatchProjectModalProps {
   selectedIds: number[];
@@ -43,14 +44,9 @@ export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalPro
     return () => window.removeEventListener('keydown', handleKeyDown);
   }, [onClose]);
 
-  // Helper to invalidate all project-related queries
-  const invalidateProjectQueries = () => {
-    queryClient.invalidateQueries({ queryKey: ['archives'] });
-    queryClient.invalidateQueries({ queryKey: ['projects'] });
-    // Invalidate project detail pages (partial match catches all project IDs)
-    queryClient.invalidateQueries({ queryKey: ['project'] });
-    queryClient.invalidateQueries({ queryKey: ['project-archives'] });
-  };
+  // Helper to invalidate all project-related queries. The shared version also
+  // covers the timeline and file-progress views, which this list was missing.
+  const invalidateProjectQueries = () => invalidateArchiveAndProjectViews(queryClient);
 
   // Assign to project mutation (uses bulk API)
   const assignMutation = useMutation({

+ 90 - 19
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -5,7 +5,7 @@ import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'luc
 import { api } from '../api/client';
 import type { KProfile } from '../api/client';
 import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex } from '../utils/slicerPrinterMatch';
-import { toFilamentId, isGenericFilamentId } from './spool-form/utils';
+import { toFilamentId } from './spool-form/utils';
 import { Button } from './Button';
 import { getAmsLabel } from '../utils/amsHelpers';
 
@@ -99,6 +99,13 @@ function parsePresetName(name: string): { material: string; brand: string; varia
   return { material: withoutSuffix, brand: '', variant: '' };
 }
 
+// Identity of a K-profile inside the picker. Both profile lists are
+// deduplicated on name+k_value, so this is unique across the whole option set
+// — unlike the bare name, which two profiles can share (#2710).
+function kProfileOptionValue(profile: KProfile): string {
+  return `${profile.name}|${profile.k_value}`;
+}
+
 // Check if a preset is a user preset (not built-in)
 function isUserPreset(settingId: string): boolean {
   // Built-in presets have specific patterns, user presets are UUIDs
@@ -870,7 +877,12 @@ export function ConfigureAmsSlotModal({
     const { fullName, material, brand, filamentId } = selectedPresetInfo;
     const upperFullName = fullName.toUpperCase();
     const upperMaterial = material.toUpperCase();
-    const upperBrand = brand.toUpperCase();
+    // "Generic" leads every built-in Bambu preset name ("Generic PLA",
+    // "Generic PETG") but is not a manufacturer (#2710). Treating it as one
+    // put the filter into brand-gated mode and demanded "GENERIC" in the
+    // K-profile name, which no real profile has — so selecting a built-in
+    // generic preset matched nothing at all.
+    const upperBrand = brand.toUpperCase() === 'GENERIC' ? '' : brand.toUpperCase();
     const presetFid = filamentId; // already normalised via toFilamentId
 
     // Material must be at least 2 chars to avoid false positives
@@ -880,11 +892,18 @@ export function ConfigureAmsSlotModal({
     const filtered = kprofilesData.profiles.filter(p => {
       // Preferred: exact filament_id match (#1688). A user's custom K-profile
       // whose name doesn't agree with the slicer preset still surfaces when
-      // both sides agree on filament_id. Generic GFx99 IDs are excluded —
-      // they're shared across many filaments and over-match if id-compared.
+      // both sides agree on filament_id.
+      //
+      // Generic GFx99 ids count here too (#2710). The equality test already
+      // means both sides carry the *same* id, so the old "generic ids
+      // over-match" exclusion could only ever fire when the selected preset
+      // was itself the generic one — precisely the case where the match is
+      // right. The printer keeps one calibration table per filament_id, so a
+      // slot on "Generic PLA" should offer every profile calibrated under
+      // Generic PLA, whatever the user named them.
       if (presetFid) {
         const calFid = toFilamentId(p.filament_id);
-        if (calFid && calFid === presetFid && !isGenericFilamentId(calFid)) {
+        if (calFid && calFid === presetFid) {
           return true;
         }
       }
@@ -966,6 +985,46 @@ export function ConfigureAmsSlotModal({
     return result;
   }, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId, slotInfo.caliIdx]);
 
+  // Every remaining K-profile the printer holds, offered under a separate group
+  // after the matching ones (#2710). The matcher works off preset names and
+  // filament ids, neither of which the user controls when they name a profile
+  // after its colour — so there is always a residual chance it filters out a
+  // profile the user wants. This makes that recoverable in the UI instead of
+  // sending them to the slicer: the printer's own calibration table is the
+  // authority on what can be selected, and the backend realigns the slot's
+  // filament context to whichever profile is picked.
+  const otherKProfiles = useMemo(() => {
+    if (!kprofilesData?.profiles) return [];
+    const matched = new Set(matchingKProfiles.map(p => kProfileOptionValue(p)));
+    // Same name+k_value dedup as the matching list, so a multi-nozzle printer's
+    // duplicate rows don't show up twice here either.
+    const seen = new Map<string, KProfile>();
+    for (const profile of kprofilesData.profiles) {
+      const key = kProfileOptionValue(profile);
+      if (matched.has(key)) continue;
+      const existing = seen.get(key);
+      if (!existing) {
+        seen.set(key, profile);
+      } else if (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) {
+        seen.set(key, profile);
+      }
+    }
+    return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name));
+  }, [kprofilesData?.profiles, matchingKProfiles, slotInfo.extruderId]);
+
+  const hasAnyKProfile = matchingKProfiles.length > 0 || otherKProfiles.length > 0;
+
+  const selectKProfileByValue = useCallback((value: string) => {
+    if (!value) {
+      setSelectedKProfile(null);
+      return;
+    }
+    const profile = matchingKProfiles.find(p => kProfileOptionValue(p) === value)
+      || otherKProfiles.find(p => kProfileOptionValue(p) === value)
+      || null;
+    setSelectedKProfile(profile);
+  }, [matchingKProfiles, otherKProfiles]);
+
   // Pre-select current profile when modal opens, reset when closes
   useEffect(() => {
     if (isOpen) {
@@ -1234,22 +1293,28 @@ export function ConfigureAmsSlotModal({
                       </span>
                     )}
                   </label>
-                  {matchingKProfiles.length > 0 ? (
+                  {hasAnyKProfile ? (
                     <div className="relative">
                       <select
-                        value={selectedKProfile?.name || ''}
-                        onChange={(e) => {
-                          const profile = matchingKProfiles.find(p => p.name === e.target.value);
-                          setSelectedKProfile(profile || null);
-                        }}
+                        value={selectedKProfile ? kProfileOptionValue(selectedKProfile) : ''}
+                        onChange={(e) => selectKProfileByValue(e.target.value)}
                         className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none pr-10"
                       >
                         <option value="">{t('configureAmsSlot.noKProfile')}</option>
                         {matchingKProfiles.map((profile) => (
-                          <option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
+                          <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
                             {profile.name} (K={profile.k_value})
                           </option>
                         ))}
+                        {otherKProfiles.length > 0 && (
+                          <optgroup label={t('configureAmsSlot.otherKProfiles')}>
+                            {otherKProfiles.map((profile) => (
+                              <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
+                                {profile.name} (K={profile.k_value})
+                              </option>
+                            ))}
+                          </optgroup>
+                        )}
                       </select>
                       <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
                     </div>
@@ -1473,22 +1538,28 @@ export function ConfigureAmsSlotModal({
                     </span>
                   )}
                 </label>
-                {matchingKProfiles.length > 0 ? (
+                {hasAnyKProfile ? (
                   <div className="relative">
                     <select
-                      value={selectedKProfile?.name || ''}
-                      onChange={(e) => {
-                        const profile = matchingKProfiles.find(p => p.name === e.target.value);
-                        setSelectedKProfile(profile || null);
-                      }}
+                      value={selectedKProfile ? kProfileOptionValue(selectedKProfile) : ''}
+                      onChange={(e) => selectKProfileByValue(e.target.value)}
                       className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none pr-10"
                     >
                       <option value="">{t('configureAmsSlot.noKProfile')}</option>
                       {matchingKProfiles.map((profile) => (
-                        <option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
+                        <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
                           {profile.name} (K={profile.k_value})
                         </option>
                       ))}
+                      {otherKProfiles.length > 0 && (
+                        <optgroup label={t('configureAmsSlot.otherKProfiles')}>
+                          {otherKProfiles.map((profile) => (
+                            <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
+                              {profile.name} (K={profile.k_value})
+                            </option>
+                          ))}
+                        </optgroup>
+                      )}
                     </select>
                     <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
                   </div>

+ 4 - 2
frontend/src/components/EditArchiveModal.tsx

@@ -6,6 +6,7 @@ import { api } from '../api/client';
 import type { Archive } from '../api/client';
 import { Button } from './Button';
 import { PrintLogTable } from './PrintLogTable';
+import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
 
 // Keys for failure reasons - translated at render time.
 // Exported so the Print Log per-row classification editor (#1687 part 4)
@@ -147,8 +148,9 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
     mutationFn: (data: Parameters<typeof api.updateArchive>[1]) =>
       api.updateArchive(archive.id, data),
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['archives'] });
-      queryClient.invalidateQueries({ queryKey: ['projects'] });
+      // This form can change the archive's project, so the project detail
+      // views need refreshing too — not just the overview cards (#2731).
+      invalidateArchiveAndProjectViews(queryClient);
       onClose();
     },
   });

+ 29 - 3
frontend/src/components/FailureDetectionSettings.tsx

@@ -17,6 +17,7 @@ export function FailureDetectionSettings() {
 
   const [enabled, setEnabled] = useState(false);
   const [mlUrl, setMlUrl] = useState('');
+  const [mlToken, setMlToken] = useState('');
   const [sensitivity, setSensitivity] = useState<'low' | 'medium' | 'high'>('medium');
   const [action, setAction] = useState<'notify' | 'pause' | 'pause_and_off'>('notify');
   const [pollInterval, setPollInterval] = useState(10);
@@ -44,6 +45,7 @@ export function FailureDetectionSettings() {
     if (!settings) return;
     setEnabled(settings.obico_enabled ?? false);
     setMlUrl(settings.obico_ml_url ?? '');
+    setMlToken(settings.obico_ml_token ?? '');
     setSensitivity(settings.obico_sensitivity ?? 'medium');
     setAction(settings.obico_action ?? 'notify');
     setPollInterval(settings.obico_poll_interval ?? 10);
@@ -63,6 +65,7 @@ export function FailureDetectionSettings() {
       api.updateSettings({
         obico_enabled: enabled,
         obico_ml_url: mlUrl,
+        obico_ml_token: mlToken,
         obico_sensitivity: sensitivity,
         obico_action: action,
         obico_poll_interval: pollInterval,
@@ -84,6 +87,7 @@ export function FailureDetectionSettings() {
     const changed =
       settings.obico_enabled !== enabled ||
       settings.obico_ml_url !== mlUrl ||
+      (settings.obico_ml_token ?? '') !== mlToken ||
       settings.obico_sensitivity !== sensitivity ||
       settings.obico_action !== action ||
       settings.obico_poll_interval !== pollInterval ||
@@ -92,14 +96,23 @@ export function FailureDetectionSettings() {
     const id = setTimeout(() => saveMutation.mutate(), 500);
     return () => clearTimeout(id);
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [enabled, mlUrl, sensitivity, action, pollInterval, enabledPrinters, initialized]);
+  }, [enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters, initialized]);
 
   const handleTest = async () => {
     setTestResult(null);
     try {
-      const res = await api.testObicoConnection(mlUrl);
+      const res = await api.testObicoConnection(mlUrl, mlToken);
       if (res.ok) {
-        setTestResult({ ok: true, message: t('failureDetection.testSuccess') });
+        // auth_ok is null when the token could not be checked — don't claim it
+        // works. It is true both for an accepted token and for a server that
+        // requires none, which is the same outcome for the user.
+        setTestResult({
+          ok: true,
+          message:
+            res.auth_ok === null
+              ? t('failureDetection.testSuccessTokenUnknown')
+              : t('failureDetection.testSuccess'),
+        });
       } else {
         setTestResult({
           ok: false,
@@ -163,6 +176,19 @@ export function FailureDetectionSettings() {
                 </Button>
               </div>
               <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlUrlHint')}</p>
+              <label className="block text-sm text-bambu-gray mb-1 mt-3">
+                {t('failureDetection.mlToken')}
+              </label>
+              <input
+                type="password"
+                value={mlToken}
+                onChange={(e) => setMlToken(e.target.value)}
+                autoComplete="off"
+                placeholder={t('failureDetection.mlTokenPlaceholder')}
+                className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
+                disabled={!enabled}
+              />
+              <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlTokenHint')}</p>
               {testResult && (
                 <div
                   className={`flex items-start gap-2 mt-2 text-sm ${

+ 35 - 4
frontend/src/components/GitHubBackupSettings.tsx

@@ -32,6 +32,7 @@ import type {
   LocalBackupPathCheck,
   LocalBackupStatus,
   ScheduleType,
+  CloudAccountCounts,
   CloudAuthStatus,
   Printer,
 } from '../api/client';
@@ -324,6 +325,24 @@ export function GitHubBackupSettings() {
     queryFn: api.getCloudStatus,
   });
 
+  // How many cloud accounts the backup would actually collect from, across
+  // both Bambu Cloud and Orca Cloud. Not the same question as `cloudStatus`,
+  // which is only *this viewer's* Bambu sign-in: with auth enabled every user
+  // holds their own credentials and the backup collects from all of them, so
+  // an admin who never signed in to Bambu Cloud personally would otherwise see
+  // the category disabled while there is plenty to back up (#2717).
+  const { data: cloudAccounts } = useQuery<CloudAccountCounts>({
+    queryKey: ['github-backup-cloud-accounts'],
+    queryFn: api.getGitHubBackupCloudAccounts,
+    staleTime: 60_000,
+  });
+  const connectedCloudAccounts = (cloudAccounts?.bambu ?? 0) + (cloudAccounts?.orca ?? 0);
+  // Until the count arrives, fall back to the viewer's own Bambu status rather
+  // than rendering the toggle as unavailable and making it flicker enabled.
+  const anyCloudConnected = cloudAccounts
+    ? connectedCloudAccounts > 0
+    : !!cloudStatus?.is_authenticated;
+
   // Fetch printers and their statuses for K-profile availability
   const { data: printers } = useQuery<Printer[]>({
     queryKey: ['printers'],
@@ -751,18 +770,18 @@ export function GitHubBackupSettings() {
                     <p className="text-xs text-bambu-gray">{t('backup.kProfilesDescription')}</p>
                   </div>
                 </label>
-                <label className={`flex items-start gap-2 ${!cloudStatus?.is_authenticated ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
+                <label className={`flex items-start gap-2 ${!anyCloudConnected ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
                   <input
                     type="checkbox"
                     checked={backupCloudProfiles}
                     onChange={(e) => setBackupCloudProfiles(e.target.checked)}
                     className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
-                    disabled={!cloudStatus?.is_authenticated}
+                    disabled={!anyCloudConnected}
                   />
                   <div>
                     <div className="flex items-center gap-2">
-                      <span className={`text-sm ${cloudStatus?.is_authenticated ? 'text-white' : 'text-bambu-gray'}`}>{t('backup.cloudProfiles')}</span>
-                      {!cloudStatus?.is_authenticated && (
+                      <span className={`text-sm ${anyCloudConnected ? 'text-white' : 'text-bambu-gray'}`}>{t('backup.cloudProfiles')}</span>
+                      {!anyCloudConnected && (
                         <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
                           <AlertTriangle className="w-3 h-3" />
                           {t('backup.cloudLoginRequiredShort')}
@@ -770,6 +789,18 @@ export function GitHubBackupSettings() {
                       )}
                     </div>
                     <p className="text-xs text-bambu-gray">{t('backup.cloudProfilesDescription')}</p>
+                    {/* Say how many accounts are in scope. On a multi-user
+                        install the presets being backed up are other people's,
+                        and the count is the only honest way to show that
+                        without naming them. */}
+                    {connectedCloudAccounts > 0 && (
+                      <p className="text-xs text-bambu-gray mt-0.5">
+                        {t('backup.cloudProfilesAccounts', {
+                          bambu: cloudAccounts?.bambu ?? 0,
+                          orca: cloudAccounts?.orca ?? 0,
+                        })}
+                      </p>
+                    )}
                   </div>
                 </label>
                 <label className="flex items-start gap-2 cursor-pointer">

+ 43 - 5
frontend/src/components/HMSErrorModal.tsx

@@ -33,9 +33,20 @@ const AMS_RUNOUT_SHORT_CODES = new Set([
   '0705_8011', '0706_8011', '0707_8011', '07FF_8011',
 ]);
 
-// Comprehensive error code database (short format: XXXX_YYYY)
-// Auto-generated from ha-bambulab - 853 codes
+// "MQTT command verification failed" — the firmware's authorization check
+// refusing a control command. Keyed by its full 16-char code on purpose: this
+// error's meaning lives in attr's low half (0500) and code's high half (0001),
+// both of which getShortCode() discards, so the short form is a useless
+// "0500_0007". Before #2732 that meant filterKnownHMSErrors dropped it and the
+// user was never shown the one message that explained why nothing printed.
+export const HMS_MQTT_VERIFY_FAILED = '0500050000010007';
+
+// Comprehensive error code database, keyed by short code (XXXX_YYYY) or, where
+// the short code cannot express the error, by full code (16 hex chars).
+// Short-code entries auto-generated from ha-bambulab - 853 codes
 const ERROR_DESCRIPTIONS: Record<string, string> = {
+  [HMS_MQTT_VERIFY_FAILED]:
+    'The printer rejected a command because it could not verify it. Prints, temperature changes and filament loads sent from Bambuddy will be ignored until this is fixed.',
   '0300_4000': 'Z axis homing failed; the task has been stopped.',
   '0300_4001': 'The printer timed out waiting for the nozzle to cool down before homing.',
   '0300_4002': 'Auto Bed Leveling failed; the task has been stopped.',
@@ -913,6 +924,15 @@ function getShortCode(attr: number, code: number): string {
   return `${module.toString(16).padStart(4, '0').toUpperCase()}_${codeNum.toString(16).padStart(4, '0').toUpperCase()}`;
 }
 
+// Catalog lookup. full_code (16 hex chars for hms[]-sourced faults) is tried
+// first because it is lossless; shortCode is the fallback that the bulk of the
+// catalog is keyed by. Returns undefined for an uncataloged error so callers can
+// tell "no description" from "empty description".
+function lookupDescription(fullCode: string | undefined, shortCode: string): string | undefined {
+  if (fullCode && ERROR_DESCRIPTIONS[fullCode] !== undefined) return ERROR_DESCRIPTIONS[fullCode];
+  return ERROR_DESCRIPTIONS[shortCode];
+}
+
 // Helper to filter HMS errors the UI should surface (exported for use in badge counts).
 // Keeps an error if EITHER:
 //   - it's in the bundled ERROR_DESCRIPTIONS catalog (known, has a description), OR
@@ -925,7 +945,7 @@ export function filterKnownHMSErrors(errors: HMSError[]): HMSError[] {
   return errors.filter((error) => {
     const codeNum = parseInt(error.code.replace('0x', ''), 16) || 0;
     const shortCode = getShortCode(error.attr, codeNum);
-    if (ERROR_DESCRIPTIONS[shortCode] !== undefined) return true;
+    if (lookupDescription(error.full_code, shortCode) !== undefined) return true;
     return (error.actions?.length ?? 0) > 0;
   });
 }
@@ -1023,7 +1043,17 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
                 // Runout guidance (#2587): for an AMS per-slot runout on a paused
                 // print, name the slot the firmware now expects rather than the
                 // misleading generic "insert into the same slot" text.
-                let description = ERROR_DESCRIPTIONS[shortCode] ?? t('hmsErrors.unknownCode');
+                const matchedFullCode =
+                  !!error.full_code && ERROR_DESCRIPTIONS[error.full_code] !== undefined;
+                let description =
+                  lookupDescription(error.full_code, shortCode) ?? t('hmsErrors.unknownCode');
+                // The remedy is Bambuddy's, not Bambu's — their wiki says "update
+                // Studio or Handy", which is no help to someone printing from
+                // Bambuddy. Same override shape as the runout guidance below.
+                const remedy =
+                  error.full_code === HMS_MQTT_VERIFY_FAILED
+                    ? t('hmsErrors.mqttVerifyFailedRemedy')
+                    : null;
                 if (runoutGuidance && AMS_RUNOUT_SHORT_CODES.has(shortCode)) {
                   if (runoutGuidance.expectedSlotLabel && runoutGuidance.ranOutSlotLabel) {
                     description = t('hmsErrors.runoutExpectedSlot', {
@@ -1039,7 +1069,14 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
                   }
                 }
                 const hmsHomeUrl = getHMSHomeUrl();
-                const displayCode = shortCode.replace('_', '-');
+                // Show the printer's own four-group notation when the short code
+                // could not identify the error — for those, "0500-0007" matches
+                // nothing the user can look up, while "0500-0500-0001-0007" is
+                // exactly what the printer screen and the Bambu wiki show.
+                const displayCode =
+                  matchedFullCode && error.full_code!.length === 16
+                    ? error.full_code!.match(/.{4}/g)!.join('-')
+                    : shortCode.replace('_', '-');
 
                 return (
                   <div
@@ -1056,6 +1093,7 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
                           </span>
                         </div>
                         <p className="text-sm text-bambu-gray mb-2">{description}</p>
+                        {remedy && <p className="text-sm text-bambu-gray mb-2">{remedy}</p>}
                         {error.actions && error.actions.length > 0 && (
                           <div className="flex flex-wrap gap-2 my-2">
                             {error.actions.map((action) => {

+ 79 - 11
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
@@ -24,11 +24,62 @@ export function FilamentMapping({
   forceColorMatch,
   onForceColorMatchChange,
   plateLabel,
+  archiveAmsMapping,
 }: FilamentMappingProps & { defaultExpanded?: boolean }) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const [isRefreshing, setIsRefreshing] = useState(false);
   const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+  // "Mapping" toggle (only shown when the archive has a saved slicer pick):
+  // ON selects every slot straight from `archiveAmsMapping`, bypassing the
+  // type/color auto-match entirely — same mechanism as a manual per-slot
+  // pick (`manualMappings`), just applied to every required slot at once.
+  // OFF removes exactly those overrides so the panel falls back to its
+  // normal auto-match, without touching any *other* manual picks the user
+  // made by hand.
+  const [usingArchiveMapping, setUsingArchiveMapping] = useState(false);
+  // Which slot IDs the ON branch below actually wrote into manualMappings —
+  // so OFF can undo exactly those and leave any *other* manual pick the user
+  // made by hand (before or after pressing the button) untouched.
+  const appliedSlotIdsRef = useRef<number[]>([]);
+
+  // Reset the toggle whenever the saved mapping it would apply changes — a
+  // different printer, plate selection, or archive entirely. Without this
+  // the button can read ON (green) from a previous printer/archive/plate
+  // even though it was never pressed against the mapping currently in scope.
+  useEffect(() => {
+    setUsingArchiveMapping(false);
+    appliedSlotIdsRef.current = [];
+  }, [archiveAmsMapping, plateLabel, printerId]);
+
+  const toggleArchiveMapping = () => {
+    if (!archiveAmsMapping || !filamentReqs?.filaments) return;
+    if (usingArchiveMapping) {
+      const next = { ...manualMappings };
+      for (const slotId of appliedSlotIdsRef.current) {
+        delete next[slotId];
+      }
+      onManualMappingChange(next);
+      appliedSlotIdsRef.current = [];
+      setUsingArchiveMapping(false);
+      return;
+    }
+    const next = { ...manualMappings };
+    const appliedSlotIds: number[] = [];
+    for (const req of filamentReqs.filaments) {
+      const idx = req.slot_id - 1;
+      // A negative value (e.g. the external spool sentinel) means the
+      // slicer didn't resolve this filament to an AMS tray — leave that
+      // slot's existing auto-match/manual pick alone rather than clearing it.
+      if (req.slot_id > 0 && idx >= 0 && idx < archiveAmsMapping.length && archiveAmsMapping[idx] >= 0) {
+        next[req.slot_id] = archiveAmsMapping[idx];
+        appliedSlotIds.push(req.slot_id);
+      }
+    }
+    onManualMappingChange(next);
+    appliedSlotIdsRef.current = appliedSlotIds;
+    setUsingArchiveMapping(true);
+  };
 
   // Fetch printer status
   const { data: printerStatus } = useQuery({
@@ -211,16 +262,33 @@ export function FilamentMapping({
       {isExpanded && (
         <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
           <div className="flex items-center justify-between mb-2">
-            <span className="text-xs text-bambu-gray">Click to change slot assignment</span>
-            <button
-              type="button"
-              onClick={handleRefresh}
-              className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
-              disabled={isRefreshing}
-            >
-              <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
-              <span>Re-read</span>
-            </button>
+            <span className="text-xs text-bambu-gray">{t('printModal.clickToChangeSlot')}</span>
+            <div className="flex items-center gap-1.5">
+              {archiveAmsMapping && (
+                <button
+                  type="button"
+                  onClick={toggleArchiveMapping}
+                  title={t('printModal.useArchiveMappingTooltip')}
+                  className={`flex items-center gap-1 px-2 py-0.5 text-xs rounded border transition-colors ${
+                    usingArchiveMapping
+                      ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
+                      : 'border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
+                  }`}
+                >
+                  <Check className="w-3 h-3" />
+                  <span>{t('printModal.useArchiveMapping')}</span>
+                </button>
+              )}
+              <button
+                type="button"
+                onClick={handleRefresh}
+                className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
+                disabled={isRefreshing}
+              >
+                <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
+                <span>{t('printModal.reRead')}</span>
+              </button>
+            </div>
           </div>
           {filamentComparison.map((item, idx) => {
             // #1717: surface the same per-slot force-color-match checkbox here

+ 46 - 0
frontend/src/components/PrintModal/archiveAmsMapping.ts

@@ -0,0 +1,46 @@
+/**
+ * Reading the archive's saved slicer AMS-slot pick back out of `extra_data`.
+ *
+ * A virtual printer with "Save AMS mapping" on stores the slicer's own
+ * live-resolved tray choice on the archive as
+ * `extra_data.slicer_ams_mapping = { mapping, printer_id }` (written by
+ * `ArchiveService.archive_print`). The tray IDs in `mapping` are global tray
+ * IDs, which only mean something against the AMS layout of the one printer
+ * they were resolved against — slot 3 on another printer can hold a completely
+ * different spool. `printer_id` records which printer that was.
+ *
+ * Lives here rather than inline in the modal so the printer-scoping rule can
+ * be tested on its own: it is the only thing standing between a saved mapping
+ * and the wrong physical spool.
+ */
+
+/** Shape of `extra_data.slicer_ams_mapping`. Every field optional — this is
+ *  free-form JSON off the wire, and older archives predate the key entirely. */
+export interface SavedSlicerAmsMapping {
+  mapping?: number[];
+  printer_id?: number;
+}
+
+/**
+ * The saved mapping, but only when it is safe to apply to `printerId`.
+ *
+ * Returns `undefined` — meaning "no saved mapping in scope, behave as before" —
+ * when the archive has none, when the stored value is malformed, when no
+ * printer is selected yet, or when the selected printer is not the one the
+ * mapping was resolved against.
+ */
+export function resolveArchiveSlicerAmsMapping(
+  extraData: Record<string, unknown> | null | undefined,
+  printerId: number | null | undefined,
+): number[] | undefined {
+  // No printer selected means there is nothing to compare against. Bailing
+  // here also stops `undefined === undefined` from reading as a match below.
+  if (printerId == null) return undefined;
+
+  const saved = extraData?.slicer_ams_mapping as SavedSlicerAmsMapping | undefined;
+  if (!saved || typeof saved !== 'object') return undefined;
+  if (saved.printer_id !== printerId) return undefined;
+  if (!Array.isArray(saved.mapping) || saved.mapping.length === 0) return undefined;
+
+  return saved.mapping;
+}

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

@@ -22,6 +22,7 @@ import { getCurrencySymbol } from '../../utils/currency';
 import { getBedTypeInfo } from '../../utils/bedType';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
 import { getGlobalTrayId, isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
+import { resolveArchiveSlicerAmsMapping } from './archiveAmsMapping';
 import { FilamentMapping } from './FilamentMapping';
 import { FilamentOverride } from './FilamentOverride';
 import { PlateSelector } from './PlateSelector';
@@ -325,6 +326,19 @@ export function PrintModal({
   // Get sliced_for_model from archive or library file
   const slicedForModel = archiveDetails?.sliced_for_model || libraryFileDetails?.sliced_for_model || null;
 
+  // The archive's own saved AMS-slot pick from the slicer (see the "Save AMS
+  // mapping" virtual-printer setting) — undefined for library files or
+  // archives that predate the feature / had it off at print time, and
+  // deliberately undefined unless the selected printer is the one the mapping
+  // was resolved against. See `resolveArchiveSlicerAmsMapping`.
+  const archiveSlicerAmsMapping = useMemo(
+    () =>
+      isLibraryFile
+        ? undefined
+        : resolveArchiveSlicerAmsMapping(archiveDetails?.extra_data, effectivePrinterId),
+    [isLibraryFile, archiveDetails?.extra_data, effectivePrinterId],
+  );
+
   // Fetch plates for archives
   const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
     queryKey: ['archive-plates', archiveId],
@@ -1407,6 +1421,7 @@ export function PrintModal({
                 onForceColorMatchChange={(slotId, value) =>
                   setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
                 }
+                archiveAmsMapping={archiveSlicerAmsMapping}
               />
             )}
 
@@ -1433,6 +1448,7 @@ export function PrintModal({
                   onForceColorMatchChange={(slotId, value) =>
                     setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
                   }
+                  archiveAmsMapping={archiveSlicerAmsMapping}
                 />
               );
             })}

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

@@ -224,6 +224,14 @@ export interface FilamentMappingProps {
    *  plate. Each plate prints its own subset of the file's slots and gets its
    *  own AMS mapping, so the panels have to be told apart. */
   plateLabel?: string;
+  /** The archive's own saved AMS-slot pick from the slicer
+   *  (`extra_data.slicer_ams_mapping`, written when the source virtual
+   *  printer has "Save AMS mapping" enabled) — position = slot_id-1, value =
+   *  global tray ID. When present, a "Mapping" toggle next to "Re-read" lets
+   *  the user select every slot from this array instead of the type/color
+   *  auto-match. Undefined/omitted when the archive has no saved mapping —
+   *  the toggle is hidden and behaviour is unchanged. */
+  archiveAmsMapping?: number[];
 }
 
 /**

+ 38 - 0
frontend/src/components/VirtualPrinterCard.tsx

@@ -55,6 +55,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   const [localModel, setLocalModel] = useState(printer.model || '');
   const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
   const [localQueueForceColorMatch, setLocalQueueForceColorMatch] = useState(printer.queue_force_color_match ?? false);
+  const [localSaveAmsMapping, setLocalSaveAmsMapping] = useState(printer.save_ams_mapping ?? false);
   const [localGcodeInjection, setLocalGcodeInjection] = useState(printer.gcode_injection ?? false);
   const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
   const [showAccessCode, setShowAccessCode] = useState(false);
@@ -101,6 +102,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
       setLocalModel(printer.model || '');
       setLocalAutoDispatch(printer.auto_dispatch ?? true);
       setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
+      setLocalSaveAmsMapping(printer.save_ams_mapping ?? false);
       setLocalGcodeInjection(printer.gcode_injection ?? false);
       setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
     }
@@ -133,6 +135,12 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
       setLocalTargetPrinterId(printer.target_printer_id);
       setLocalBindIp(printer.bind_ip || '');
       setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
+      // Queue-mode behaviour toggles. Without these the switch stays visually
+      // flipped after a failed save, so the card claims a setting the server
+      // never accepted.
+      setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
+      setLocalSaveAmsMapping(printer.save_ams_mapping ?? false);
+      setLocalGcodeInjection(printer.gcode_injection ?? false);
       setPendingAction(null);
     },
   });
@@ -439,6 +447,36 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
               </div>
             )}
 
+            {/* Save-AMS-mapping toggle - only for queue mode */}
+            {localMode === 'queue' && (
+              <div className="pt-2 border-t border-bambu-dark-tertiary">
+                <div className="flex items-center justify-between gap-3">
+                  <div className="min-w-0">
+                    <div className="text-white text-sm font-medium">{t('virtualPrinter.saveAmsMapping.title')}</div>
+                    <div className="text-[10px] text-bambu-gray">{t('virtualPrinter.saveAmsMapping.description')}</div>
+                  </div>
+                  <button
+                    onClick={() => {
+                      const newVal = !localSaveAmsMapping;
+                      setLocalSaveAmsMapping(newVal);
+                      setPendingAction('saveAmsMapping');
+                      updateMutation.mutate({ save_ams_mapping: newVal });
+                    }}
+                    disabled={pendingAction === 'saveAmsMapping'}
+                    className={`relative w-10 h-5 rounded-full transition-colors flex-shrink-0 ${
+                      localSaveAmsMapping ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                    } ${pendingAction === 'saveAmsMapping' ? 'opacity-50' : ''}`}
+                  >
+                    <span
+                      className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
+                        localSaveAmsMapping ? 'translate-x-5' : ''
+                      }`}
+                    />
+                  </button>
+                </div>
+              </div>
+            )}
+
             {/* G-code injection toggle - only for queue mode (#1516) */}
             {localMode === 'queue' && (
               <div className="pt-2 border-t border-bambu-dark-tertiary">

+ 57 - 5
frontend/src/components/spool-form/utils.ts

@@ -378,13 +378,50 @@ export function toFilamentId(id: string | null | undefined): string {
 }
 
 // "GFx99" identifiers (GFL99, GFG99, GFB99, ...) are Bambu's *generic* filament
-// IDs — shared across many different physical filaments. Matching K-profiles
-// by an exact generic ID would over-match, so the id-match path skips them and
-// the caller falls through to name-based matching.
+// IDs — one per material, shared across every physical filament the user hasn't
+// given a specific preset. They still identify a material unambiguously, so an
+// exact generic id match is only ambiguous about *brand*, never about material.
 export function isGenericFilamentId(id: string | null | undefined): boolean {
   return !!id && /^GF[A-Z]99$/i.test(id);
 }
 
+// The material each generic Bambu filament ID stands for. Used to sanity-check
+// a generic id-match against the material the caller already knows (#2710): a
+// PETG spool must never claim GFL99 (generic PLA) profiles just because both
+// sides happen to have stored the same generic id.
+const GENERIC_FILAMENT_MATERIALS: Record<string, string> = {
+  GFB99: 'ABS',
+  GFC99: 'PC',
+  GFG99: 'PETG',
+  GFL99: 'PLA',
+  GFN99: 'PA',
+  GFP99: 'PE',
+  GFR99: 'EVA',
+  GFS99: 'PVA',
+  GFU99: 'TPU',
+};
+
+// Material a generic filament ID stands for ("GFL99" → "PLA"), or '' when the
+// ID isn't a known generic one.
+export function materialForGenericFilamentId(id: string | null | undefined): string {
+  if (!id) return '';
+  return GENERIC_FILAMENT_MATERIALS[id.toUpperCase()] || '';
+}
+
+// Bambu labels nylon "PA"; users routinely type "Nylon". Compare materials
+// through this so the two spellings agree.
+function normaliseMaterial(material: string): string {
+  const upper = material.trim().toUpperCase();
+  return upper === 'NYLON' ? 'PA' : upper;
+}
+
+// True when a generic filament ID may stand in for the given material — i.e.
+// the ID is generic and describes that same material.
+export function genericFilamentIdMatchesMaterial(id: string, material: string): boolean {
+  const generic = materialForGenericFilamentId(id);
+  return !!generic && !!material && normaliseMaterial(generic) === normaliseMaterial(material);
+}
+
 // Check if a calibration matches based on brand, material, and variant
 export function isMatchingCalibration(
   cal: { name?: string; filament_id?: string },
@@ -399,8 +436,23 @@ export function isMatchingCalibration(
   // "GFG98" without going anywhere near parsePresetName.
   const spoolFid = toFilamentId(formData.slicer_filament);
   const calFid = toFilamentId(cal.filament_id);
-  if (spoolFid && calFid && spoolFid === calFid && !isGenericFilamentId(calFid)) {
-    return true;
+  if (spoolFid && calFid && spoolFid === calFid) {
+    if (!isGenericFilamentId(calFid)) {
+      return true;
+    }
+    // Both sides carry the same *generic* id (#2710). That still pins the
+    // material, so the only thing left ambiguous is brand — a printer holds
+    // one flat calibration table per generic id and users routinely name
+    // those entries by colour ("Dark Brown", "Marble"), which no amount of
+    // name parsing can tie back to a material. Accept the match when the
+    // material agrees and the spool claims no brand of its own; a spool that
+    // does name a brand keeps the stricter name-based path below so its
+    // suggestions stay brand-specific.
+    const brand = formData.brand.trim();
+    const brandIsGeneric = !brand || brand.toUpperCase() === 'GENERIC';
+    if (brandIsGeneric && genericFilamentIdMatchesMaterial(calFid, formData.material)) {
+      return true;
+    }
   }
 
   const profileName = cal.name || '';

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: 'Hochgeladen von',
       noPermissionReprint: 'Sie haben keine Berechtigung, erneut zu drucken',
       noFileForReprint: 'Keine 3MF-Datei verfügbar — die Datei konnte beim Aufzeichnen des Drucks nicht vom Drucker heruntergeladen werden',
+      slicerAmsMapping: 'AMS-Zuordnung gespeichert ({{printer}})',
+      slicerAmsMappingTooltip: 'Die AMS-Steckplatzwahl des Slicers wurde für {{printer}} gespeichert. Steckplatznummern gelten nur für diesen Drucker, daher wird sie bei einem erneuten Druck nur wiederverwendet, wenn er wieder an {{printer}} geht.',
       noPermissionEdit: 'Sie haben keine Berechtigung, Archive zu bearbeiten',
       noPermissionDelete: 'Sie haben keine Berechtigung, Archive zu löschen',
       openInBambuStudio: 'Im Slicer öffnen',
@@ -1144,6 +1146,10 @@ export default {
       unknown: 'unbekannt',
       printAnyway: 'Trotzdem drucken',
     },
+    slicerAmsMapping: {
+      rowBadge: 'AMS-Steckplätze für diesen Drucker gespeichert',
+      rowTooltip: 'Dieses Archiv enthält die genauen AMS-Steckplätze, die der Slicer ausgewählt hat, gespeichert für den Drucker dieses Eintrags. Ein erneuter Druck darauf kann diese Fächer wiederverwenden, statt erneut nach Typ und Farbe zuzuordnen.',
+    },
     title: 'Druckwarteschlange',
     subtitle: 'Planen und verwalten Sie Ihre Druckaufträge',
     // Print modal
@@ -2231,6 +2237,8 @@ export default {
     slicerCard: 'Slicer',
     orcaslicerApiUrl: 'OrcaSlicer Sidecar-URL',
     bambuStudioApiUrl: 'Bambu Studio Sidecar-URL',
+    slicerStallTimeout: 'Zeitlimit bei Slicer-Stillstand (Minuten)',
+    slicerStallTimeoutDescription: 'Bricht einen Slice-Vorgang ab, wenn der Sidecar so lange keinen Fortschritt meldet. Aufwendige Modelle, die weiter Fortschritt melden, werden nie abgebrochen, egal wie lange sie brauchen. Sidecars ohne Fortschrittsmeldung nutzen diesen Wert stattdessen als Gesamtzeitlimit.',
     slicerApiUrlDescription: 'URL des Slicer-API-Sidecar-Containers. Leer lassen, um die SLICER_API_URL- bzw. BAMBU_STUDIO_API_URL-Umgebungsvariablen zu nutzen.',
     slicerBundlesRemoved: {
       title: 'Slicer-Bundles (entfernt)',
@@ -2302,6 +2310,7 @@ export default {
       connectionFailed: 'Verbindung fehlgeschlagen',
       testFailed: 'Test fehlgeschlagen',
       cameraConnected: 'Kamera verbunden{{resolution}}',
+      cameraConnectedCoalesced: 'Kamera verbunden{{resolution}} (geteilt mit einer bereits laufenden Aufnahme)',
     },
     testConnection: 'Verbindung testen',
     catalog: {
@@ -2445,6 +2454,8 @@ export default {
     autoArchiveDescription: '3MF-Dateien automatisch speichern, wenn Drucke abgeschlossen sind',
     saveThumbnailsDescription: 'Vorschaubilder aus 3MF-Dateien extrahieren und speichern',
     captureFinishPhotoDescription: 'Foto von der Druckerkamera aufnehmen, wenn der Druck abgeschlossen ist. Bambuddy zeichnet während des Drucks einen kurzen Zeitraffer auf, damit das Foto aus dem Moment vor dem Absenken der Druckplatte stammen kann. Die Zeitraffer-Datei bleibt erhalten, wenn du den Zeitraffer für diesen Druck aktiviert hast, andernfalls wird sie nach Aufnahme des Fotos automatisch gelöscht.',
+    finishPhotoRestorePlate: 'Druckplatte für Abschlussfoto anheben',
+    finishPhotoRestorePlateDescription: 'Der Drucker senkt die Druckplatte am Druckende um etwa 100 mm ab, wodurch der fertige Druck unterhalb des Kamerabildausschnitts liegt. Bambuddy hebt sie wieder bis knapp über die zuletzt gedruckte Schicht an, nimmt das Foto auf und senkt sie danach wieder ab. Wird übersprungen, wenn die Druckhöhe unbekannt ist oder ein weiterer Auftrag in der Warteschlange steht.',
     ffmpegNotInstalled: 'ffmpeg nicht installiert',
     ffmpegRequired: 'Kameraaufnahme benötigt ffmpeg. Installieren über <brew>brew install ffmpeg</brew> (macOS) oder <apt>apt install ffmpeg</apt> (Linux).',
     // Camera
@@ -2834,6 +2845,7 @@ export default {
     title: 'Fehler - {{name}}',
     noErrors: 'Keine Fehler',
     viewOnWiki: 'Im Bambu Lab Wiki ansehen',
+    mqttVerifyFailedRemedy: 'Aktiviere den Entwicklermodus am Drucker, starte den Drucker neu und starte den Auftrag dann erneut.',
     unknownCode: 'Unbekannter HMS-Code — Details siehe Bambu Lab Wiki.',
     clearInstructions: 'Löschen Sie die Fehler am Drucker, um sie hier zu entfernen.',
     clearErrors: 'Fehler löschen',
@@ -4618,6 +4630,10 @@ export default {
     selectPrinter: 'Drucker auswählen',
     selectPlate: 'Platte auswählen',
     filamentMapping: 'Filamentzuordnung',
+    useArchiveMapping: 'Zuordnung',
+    useArchiveMappingTooltip: 'Jeden Steckplatz aus der mit diesem Archiv gespeicherten AMS-Zuordnung (vom Slicer) auswählen, anstatt nach Typ/Farbe abzugleichen.',
+    clickToChangeSlot: 'Klicken, um die Steckplatzzuweisung zu ändern',
+    reRead: 'Neu einlesen',
     plateN: 'Platte {{n}}',
     plateFilamentsUnreadable: 'Die Filamente einer ausgewählten Platte konnten nicht gelesen werden, sie lässt sich daher nicht zuordnen. Wähle sie ab, um die anderen einzureihen.',
     totalCost: 'Gesamtkosten:',
@@ -4730,7 +4746,8 @@ export default {
     noPrintersConnected: 'Keine Drucker verbunden',
     printersConnected: '{{connected}}/{{total}} verbunden',
     cloudProfiles: 'Cloud-Profile',
-    cloudProfilesDescription: 'Filament-, Drucker- und Prozessprofile aus der Bambu Cloud',
+    cloudProfilesDescription: 'Filament-, Drucker- und Prozessprofile aus Bambu Cloud und Orca Cloud',
+    cloudProfilesAccounts: 'Verbundene Konten — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
     appSettings: 'App-Einstellungen',
     appSettingsDescription: 'Bambuddy-Konfiguration (komplette Datenbank)',
     spoolInventory: 'Spulenbestand',
@@ -5139,6 +5156,10 @@ export default {
       title: 'Farbabgleich erzwingen',
       description: 'Druckaufträge nur an Drucker senden, bei denen der genaue Filament-Typ und die genaue Farbe geladen sind. Standardmäßig deaktiviert — ohne diese Option verwendet die Warteschlange nur den Drucker-Modell-Abgleich und wählt möglicherweise einen Drucker mit der falschen Farbe.',
     },
+    saveAmsMapping: {
+      title: 'AMS-Zuordnung speichern',
+      description: 'Speichert die vom Slicer selbst gewählte AMS-Steckplatz-Zuordnung (aus dem MQTT-Befehl project_file) im Archiv, sodass ein späterer erneuter Druck dieselbe physische Spule verwendet, anstatt sie erneut aus Typ/Farbe der Datei abzuleiten. Standardmäßig deaktiviert.',
+    },
     gcodeInjection: {
       title: 'G-code-Injektion',
       description: 'Wendet die in den Einstellungen pro Modell konfigurierten G-code-Snippets auf Jobs dieses VP an. Standardmäßig aus.',
@@ -5918,6 +5939,7 @@ export default {
     filteringFor: 'Filtern nach: {{material}}',
     noKProfile: 'Kein K-Profil (Standard 0.020 verwenden)',
     noMatchingKProfiles: 'Keine passenden K-Profile gefunden. Standard K=0.020 wird verwendet.',
+    otherKProfiles: 'Weitere K-Profile auf diesem Drucker',
     selectFilamentFirst: 'Zuerst ein Filamentprofil auswählen',
     kFromCalibration: 'K={{value}} aus Druckerkalibrierung',
     customColorLabel: 'Benutzerdefinierte Farbe (optional)',
@@ -6526,8 +6548,12 @@ export default {
     description: 'Überwacht Drucke über eine selbst gehostete Obico-ML-API und reagiert automatisch auf erkannte Fehldrucke.',
     mlUrl: 'Obico-ML-API-URL',
     mlUrlHint: 'Basis-URL deines selbst gehosteten Obico-ml_api-Containers (z. B. http://192.168.1.10:3333).',
+    mlToken: 'ML-API-Token (optional)',
+    mlTokenPlaceholder: 'Leer lassen, wenn der Server ohne ML_API_TOKEN läuft',
+    mlTokenHint: 'Muss mit der Umgebungsvariable ML_API_TOKEN deines Obico-ml_api-Containers übereinstimmen. Leer lassen, wenn der Container ohne Token läuft.',
     test: 'Testen',
     testSuccess: 'ML-API erreichbar und funktionsfähig.',
+    testSuccessTokenUnknown: 'ML-API erreichbar und funktionsfähig. Das Token konnte nicht geprüft werden.',
     testFailed: 'ML-API konnte nicht erreicht werden.',
     sensitivity: 'Empfindlichkeit',
     sensitivityLow: 'Niedrig (weniger Fehlalarme)',

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

@@ -939,6 +939,8 @@ export default {
       uploadedBy: 'Uploaded By',
       noPermissionReprint: 'You do not have permission to reprint',
       noFileForReprint: 'No 3MF file available — the file could not be downloaded from the printer when the print was recorded',
+      slicerAmsMapping: 'AMS mapping saved ({{printer}})',
+      slicerAmsMappingTooltip: 'The slicer\'s own AMS slot choice was saved for {{printer}}. Tray numbers only mean something on that printer, so a reprint reuses them only when it targets {{printer}} again.',
       noPermissionEdit: 'You do not have permission to edit archives',
       noPermissionDelete: 'You do not have permission to delete archives',
       openInBambuStudio: 'Open in Slicer',
@@ -1155,6 +1157,10 @@ export default {
       unknown: 'unknown',
       printAnyway: 'Print Anyway',
     },
+    slicerAmsMapping: {
+      rowBadge: 'AMS slots saved for this printer',
+      rowTooltip: 'This archive carries the exact AMS slots the slicer picked, saved for the printer this item targets. A reprint on it can reuse those trays instead of matching again by type and colour.',
+    },
     // Print modal
     editQueueItem: 'Edit Queue Item',
     selectAllPlates: 'Select All {{count}} Plates',
@@ -2250,6 +2256,8 @@ export default {
     slicerCard: 'Slicer',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: 'Slicer stall timeout (minutes)',
+    slicerStallTimeoutDescription: 'Give up on a slice after this long with no progress from the sidecar. Heavy models that keep reporting progress are never cut off, however long they take. Sidecars that do not report progress use this as a total time limit instead.',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     slicerBundlesRemoved: {
       title: 'Slicer Bundles (removed)',
@@ -2321,6 +2329,7 @@ export default {
       connectionFailed: 'Connection failed',
       testFailed: 'Test failed',
       cameraConnected: 'Camera connected{{resolution}}',
+      cameraConnectedCoalesced: 'Camera connected{{resolution}} (shared with a capture already running)',
     },
     testConnection: 'Test Connection',
     catalog: {
@@ -2464,6 +2473,8 @@ export default {
     autoArchiveDescription: 'Automatically save 3MF files when prints complete',
     saveThumbnailsDescription: 'Extract and save preview images from 3MF files',
     captureFinishPhotoDescription: 'Take a photo from printer camera when print completes. Bambuddy records a brief timelapse during the print so the photo can be sourced from the moment before the bed drops; the timelapse file is kept if you enabled timelapse for this print, otherwise it is deleted automatically after the photo is captured.',
+    finishPhotoRestorePlate: 'Restore plate for finish photo',
+    finishPhotoRestorePlateDescription: 'The printer drops the build plate about 100 mm when a print ends, leaving the finished print below the camera\'s framing. Bambuddy raises it back to just above the last printed layer, takes the photo, then lowers it again. Skipped when the print height is unknown or another job is queued.',
     ffmpegNotInstalled: 'ffmpeg not installed',
     ffmpegRequired: 'Camera capture requires ffmpeg. Install it via <brew>brew install ffmpeg</brew> (macOS) or <apt>apt install ffmpeg</apt> (Linux).',
     // Camera
@@ -2863,6 +2874,7 @@ export default {
     title: 'Errors - {{name}}',
     noErrors: 'No errors',
     viewOnWiki: 'View on Bambu Lab Wiki',
+    mqttVerifyFailedRemedy: 'Enable Developer Mode on the printer, restart the printer, then start the job again.',
     unknownCode: 'Unknown HMS code — see the Bambu Lab wiki for details.',
     clearInstructions: 'Clear errors on the printer to dismiss them here.',
     clearErrors: 'Clear Errors',
@@ -4661,6 +4673,10 @@ export default {
     selectPrinter: 'Select Printer',
     selectPlate: 'Select Plate',
     filamentMapping: 'Filament Mapping',
+    useArchiveMapping: 'Mapping',
+    useArchiveMappingTooltip: 'Select every slot from the AMS mapping saved with this archive (from the slicer), instead of matching by type/color.',
+    clickToChangeSlot: 'Click to change slot assignment',
+    reRead: 'Re-read',
     plateN: 'Plate {{n}}',
     plateFilamentsUnreadable: 'The filaments of a selected plate could not be read, so it can\'t be mapped. Deselect it to queue the others.',
     totalCost: 'Total cost:',
@@ -4773,7 +4789,8 @@ export default {
     noPrintersConnected: 'No printers connected',
     printersConnected: '{{connected}}/{{total}} connected',
     cloudProfiles: 'Cloud Profiles',
-    cloudProfilesDescription: 'Filament, printer, and process presets from Bambu Cloud',
+    cloudProfilesDescription: 'Filament, printer, and process presets from Bambu Cloud and Orca Cloud',
+    cloudProfilesAccounts: 'Connected accounts — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
     appSettings: 'App Settings',
     appSettingsDescription: 'Bambuddy configuration (complete database)',
     spoolInventory: 'Spool Inventory',
@@ -5183,6 +5200,10 @@ export default {
       title: 'Force color match',
       description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
     },
+    saveAmsMapping: {
+      title: 'Save AMS mapping',
+      description: 'Persist the slicer\'s own AMS-slot pick (from the project_file MQTT command) onto the archive, so a later reprint reuses the exact physical spool instead of re-deriving one from the file\'s type/color. Off by default.',
+    },
     gcodeInjection: {
       title: 'G-code injection',
       description: 'Apply the per-model G-code snippets configured in Settings to jobs from this VP. Off by default.',
@@ -5962,6 +5983,7 @@ export default {
     filteringFor: 'Filtering for: {{material}}',
     noKProfile: 'No K profile (use default 0.020)',
     noMatchingKProfiles: 'No matching K profiles found. Default K=0.020 will be used.',
+    otherKProfiles: 'Other K profiles on this printer',
     selectFilamentFirst: 'Select a filament profile first',
     kFromCalibration: 'K={{value}} from printer calibration',
     customColorLabel: 'Custom Color (optional)',
@@ -6570,8 +6592,12 @@ export default {
     description: 'Monitor prints with a self-hosted Obico ML API and act on detected failures automatically.',
     mlUrl: 'Obico ML API URL',
     mlUrlHint: 'Base URL of your self-hosted Obico ml_api container (e.g. http://192.168.1.10:3333).',
+    mlToken: 'ML API Token (optional)',
+    mlTokenPlaceholder: 'Leave empty if the server runs without ML_API_TOKEN',
+    mlTokenHint: 'Must match the ML_API_TOKEN environment variable of your Obico ml_api container. Leave empty when the container runs without one.',
     test: 'Test',
     testSuccess: 'ML API reachable and healthy.',
+    testSuccessTokenUnknown: 'ML API reachable and healthy. The token could not be checked.',
     testFailed: 'Could not reach the ML API.',
     sensitivity: 'Sensitivity',
     sensitivityLow: 'Low (fewer false positives)',

+ 27 - 1
frontend/src/i18n/locales/es.ts

@@ -935,6 +935,8 @@ export default {
       uploadedBy: 'Subido por',
       noPermissionReprint: 'No tiene permiso para reimprimir',
       noFileForReprint: 'No hay archivo 3MF disponible — no se pudo descargar el archivo de la impresora cuando se registró la impresión',
+      slicerAmsMapping: 'Mapeo de AMS guardado ({{printer}})',
+      slicerAmsMappingTooltip: 'La elección de ranura AMS del slicer se guardó para {{printer}}. Los números de ranura solo significan algo en esa impresora, así que una reimpresión los reutiliza únicamente si vuelve a dirigirse a {{printer}}.',
       noPermissionEdit: 'No tiene permiso para editar archivos',
       noPermissionDelete: 'No tiene permiso para eliminar archivos',
       openInBambuStudio: 'Abrir en el laminador',
@@ -1144,6 +1146,10 @@ export default {
       unknown: 'desconocido',
       printAnyway: 'Imprimir de todos modos',
     },
+    slicerAmsMapping: {
+      rowBadge: 'Ranuras AMS guardadas para esta impresora',
+      rowTooltip: 'Este archivo conserva las ranuras AMS exactas que eligió el slicer, guardadas para la impresora de este elemento. Una reimpresión en ella puede reutilizar esas bobinas en lugar de volver a emparejar por tipo y color.',
+    },
     title: 'Cola de impresión',
     subtitle: 'Programe y gestione sus trabajos de impresión',
     // Print modal
@@ -2234,6 +2240,8 @@ export default {
     slicerCard: 'Laminador',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: 'Tiempo de espera por inactividad del laminador (minutos)',
+    slicerStallTimeoutDescription: 'Abandona un laminado tras este tiempo sin progreso del sidecar. Los modelos pesados que siguen informando progreso nunca se interrumpen, por mucho que tarden. Los sidecars que no informan progreso usan este valor como limite de tiempo total.',
     slicerApiUrlDescription: 'URL del contenedor auxiliar de la API del laminador. Déjelo en blanco para usar los valores predeterminados de las variables de entorno SLICER_API_URL / BAMBU_STUDIO_API_URL.',
     slicerBundlesRemoved: {
       title: 'Paquetes del laminador (eliminado)',
@@ -2305,6 +2313,7 @@ export default {
       connectionFailed: 'Error de conexión',
       testFailed: 'La prueba falló',
       cameraConnected: 'Cámara conectada{{resolution}}',
+      cameraConnectedCoalesced: 'Cámara conectada{{resolution}} (compartida con una captura ya en curso)',
     },
     testConnection: 'Probar conexión',
     catalog: {
@@ -2448,6 +2457,8 @@ export default {
     autoArchiveDescription: 'Guardar automáticamente los archivos 3MF cuando se completan las impresiones',
     saveThumbnailsDescription: 'Extraer y guardar imágenes de vista previa de los archivos 3MF',
     captureFinishPhotoDescription: 'Tomar una foto desde la cámara de la impresora cuando se completa la impresión. Bambuddy graba un breve timelapse durante la impresión para que la foto pueda obtenerse del momento previo al descenso de la cama; el archivo del timelapse se conserva si activaste el timelapse para esta impresión, de lo contrario se elimina automáticamente tras capturar la foto.',
+    finishPhotoRestorePlate: 'Elevar la cama para la foto final',
+    finishPhotoRestorePlateDescription: 'La impresora baja la cama unos 100 mm al terminar una impresión, dejando la pieza terminada por debajo del encuadre de la cámara. Bambuddy la vuelve a subir hasta justo encima de la última capa impresa, toma la foto y luego la baja de nuevo. Se omite si se desconoce la altura de la impresión o si hay otro trabajo en cola.',
     ffmpegNotInstalled: 'ffmpeg no instalado',
     ffmpegRequired: 'La captura de cámara requiere ffmpeg. Instálelo mediante <brew>brew install ffmpeg</brew> (macOS) o <apt>apt install ffmpeg</apt> (Linux).',
     // Camera
@@ -2837,6 +2848,7 @@ export default {
     title: 'Errores - {{name}}',
     noErrors: 'No hay errores',
     viewOnWiki: 'Ver en la wiki de Bambu Lab',
+    mqttVerifyFailedRemedy: 'Activa el modo desarrollador en la impresora, reinicia la impresora y vuelve a iniciar el trabajo.',
     unknownCode: 'Código HMS desconocido — consulta la wiki de Bambu Lab para más detalles.',
     clearInstructions: 'Borre los errores en la impresora para descartarlos aquí.',
     clearErrors: 'Borrar errores',
@@ -4626,6 +4638,10 @@ export default {
     selectPrinter: 'Seleccionar impresora',
     selectPlate: 'Seleccionar cama',
     filamentMapping: 'Mapeo de filamentos',
+    useArchiveMapping: 'Mapeo',
+    useArchiveMappingTooltip: 'Selecciona todas las ranuras a partir del mapeo de AMS guardado con este archivo (del slicer), en lugar de emparejar por tipo/color.',
+    clickToChangeSlot: 'Haga clic para cambiar la asignación de ranura',
+    reRead: 'Releer',
     plateN: 'Cama {{n}}',
     plateFilamentsUnreadable: 'No se han podido leer los filamentos de una cama seleccionada, por lo que no se puede asignar. Deselecciónala para encolar las demás.',
     totalCost: 'Coste total:',
@@ -4738,7 +4754,8 @@ export default {
     noPrintersConnected: 'No hay impresoras conectadas',
     printersConnected: '{{connected}}/{{total}} conectadas',
     cloudProfiles: 'Perfiles en la nube',
-    cloudProfilesDescription: 'Preajustes de filamento, impresora y proceso de Bambu Cloud',
+    cloudProfilesDescription: 'Preajustes de filamento, impresora y proceso de Bambu Cloud y Orca Cloud',
+    cloudProfilesAccounts: 'Cuentas conectadas — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
     appSettings: 'Ajustes de la aplicación',
     appSettingsDescription: 'Configuración de Bambuddy (base de datos completa)',
     spoolInventory: 'Inventario de bobinas',
@@ -5148,6 +5165,10 @@ export default {
       title: 'Forzar la coincidencia de color',
       description: 'Negarse a enviar a una impresora que no tiene cargados el tipo y el color exactos de filamento. Desactivado de forma predeterminada — sin esto, la cola usa la coincidencia solo por modelo y puede elegir una impresora con el color equivocado cargado.',
     },
+    saveAmsMapping: {
+      title: 'Guardar mapeo de AMS',
+      description: 'Guarda en el archivo la selección de ranura AMS propia del slicer (del comando MQTT project_file), de modo que una reimpresión posterior reutilice el mismo carrete físico en lugar de volver a derivarlo del tipo/color del archivo. Desactivado de forma predeterminada.',
+    },
     gcodeInjection: {
       title: 'Inyección de G-code',
       description: 'Aplica los fragmentos de G-code configurados por modelo en Ajustes a los trabajos de esta IV. Desactivado de forma predeterminada.',
@@ -5927,6 +5948,7 @@ export default {
     filteringFor: 'Filtrando por: {{material}}',
     noKProfile: 'Sin perfil K (usar el predeterminado 0,020)',
     noMatchingKProfiles: 'No se encontraron perfiles K coincidentes. Se usará el K=0,020 predeterminado.',
+    otherKProfiles: 'Otros perfiles K en esta impresora',
     selectFilamentFirst: 'Seleccione primero un perfil de filamento',
     kFromCalibration: 'K={{value}} de la calibración de la impresora',
     customColorLabel: 'Color personalizado (opcional)',
@@ -6535,8 +6557,12 @@ export default {
     description: 'Supervise las impresiones con una API de ML de Obico autoalojada y actúe automáticamente ante los fallos detectados.',
     mlUrl: 'URL de la API de ML de Obico',
     mlUrlHint: 'URL base de su contenedor ml_api de Obico autoalojado (p. ej. http://192.168.1.10:3333).',
+    mlToken: 'Token de la API de ML (opcional)',
+    mlTokenPlaceholder: 'Déjelo vacío si el servidor funciona sin ML_API_TOKEN',
+    mlTokenHint: 'Debe coincidir con la variable de entorno ML_API_TOKEN de su contenedor ml_api de Obico. Déjelo vacío si el contenedor funciona sin token.',
     test: 'Probar',
     testSuccess: 'API de ML accesible y correcta.',
+    testSuccessTokenUnknown: 'API de ML accesible y correcta. No se pudo comprobar el token.',
     testFailed: 'No se pudo alcanzar la API de ML.',
     sensitivity: 'Sensibilidad',
     sensitivityLow: 'Baja (menos falsos positivos)',

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: 'Téléversé par',
       noPermissionReprint: 'Pas d\'autorisation de réimpression',
       noFileForReprint: 'Aucun fichier 3MF disponible — le fichier n\'a pas pu être téléchargé depuis l\'imprimante lors de l\'enregistrement',
+      slicerAmsMapping: 'Mappage AMS enregistré ({{printer}})',
+      slicerAmsMappingTooltip: 'Le choix d\'emplacement AMS du slicer a été enregistré pour {{printer}}. Les numéros d\'emplacement n\'ont de sens que sur cette imprimante : une réimpression ne les réutilise donc que si elle vise à nouveau {{printer}}.',
       noPermissionEdit: 'Pas d\'autorisation de modification',
       noPermissionDelete: 'Pas d\'autorisation de suppression',
       openInBambuStudio: 'Ouvrir dans le Slicer',
@@ -1144,6 +1146,10 @@ export default {
       unknown: 'inconnu',
       printAnyway: 'Imprimer quand meme',
     },
+    slicerAmsMapping: {
+      rowBadge: 'Emplacements AMS enregistrés pour cette imprimante',
+      rowTooltip: 'Cette archive conserve les emplacements AMS exacts choisis par le slicer, enregistrés pour l\'imprimante de cet élément. Une réimpression dessus peut réutiliser ces bobines au lieu de refaire la correspondance par type et couleur.',
+    },
     title: 'File d\'attente',
     subtitle: 'Gérez vos travaux d\'impression',
     // Print modal
@@ -2187,6 +2193,8 @@ export default {
     slicerCard: 'Slicer',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: "Delai d'inactivite du trancheur (minutes)",
+    slicerStallTimeoutDescription: 'Abandonne un decoupage apres cette duree sans progression du sidecar. Les modeles lourds qui continuent a signaler leur progression ne sont jamais interrompus, quel que soit le temps necessaire. Les sidecars qui ne signalent pas de progression utilisent cette valeur comme limite de duree totale.',
     slicerApiUrlDescription: 'URL du conteneur sidecar slicer-API. Laisser vide pour utiliser les variables d\'environnement SLICER_API_URL / BAMBU_STUDIO_API_URL.',
     slicerBundlesRemoved: {
       title: 'Bundles de slicer (supprimé)',
@@ -2258,6 +2266,7 @@ export default {
       connectionFailed: 'Échec connexion',
       testFailed: 'Échec test',
       cameraConnected: 'Caméra connectée {{resolution}}',
+      cameraConnectedCoalesced: 'Caméra connectée {{resolution}} (partagée avec une capture déjà en cours)',
     },
     testConnection: 'Tester la connexion',
     catalog: {
@@ -2399,6 +2408,8 @@ export default {
     autoArchiveDescription: 'Sauvegarder automatiquement les fichiers 3MF à la fin des impressions',
     saveThumbnailsDescription: 'Extraire et sauvegarder les images d\'aperçu des fichiers 3MF',
     captureFinishPhotoDescription: 'Prendre une photo avec la caméra de l\'imprimante à la fin de l\'impression. Bambuddy enregistre un court timelapse pendant l\'impression afin que la photo puisse provenir du moment précédant l\'abaissement du plateau ; le fichier du timelapse est conservé si vous avez activé le timelapse pour cette impression, sinon il est supprimé automatiquement après la capture de la photo.',
+    finishPhotoRestorePlate: 'Remonter le plateau pour la photo finale',
+    finishPhotoRestorePlateDescription: 'L\'imprimante abaisse le plateau d\'environ 100 mm à la fin d\'une impression, plaçant l\'objet terminé sous le cadrage de la caméra. Bambuddy le remonte juste au-dessus de la dernière couche imprimée, prend la photo, puis le rabaisse. Ignoré si la hauteur d\'impression est inconnue ou si un autre travail est en file d\'attente.',
     ffmpegNotInstalled: 'ffmpeg non installé',
     ffmpegRequired: 'La capture caméra nécessite ffmpeg. Installez-le via <brew>brew install ffmpeg</brew> (macOS) ou <apt>apt install ffmpeg</apt> (Linux).',
     camera: 'Caméra',
@@ -2823,6 +2834,7 @@ export default {
     title: 'Erreurs - {{name}}',
     noErrors: 'Aucune erreur',
     viewOnWiki: 'Voir sur le Wiki Bambu Lab',
+    mqttVerifyFailedRemedy: "Activez le mode developpeur sur l'imprimante, redemarrez l'imprimante, puis relancez la tache.",
     unknownCode: 'Code HMS inconnu — consultez le wiki Bambu Lab pour plus de détails.',
     clearInstructions: 'Effacez les erreurs sur l\'imprimante pour les retirer ici.',
     clearErrors: 'Effacer les erreurs',
@@ -4607,6 +4619,10 @@ export default {
     selectPrinter: 'Choisir l\'imprimante',
     selectPlate: 'Choisir le plateau',
     filamentMapping: 'Mapping Filament',
+    useArchiveMapping: 'Mappage',
+    useArchiveMappingTooltip: 'Sélectionner tous les emplacements à partir du mappage AMS enregistré avec cette archive (depuis le slicer), au lieu de faire correspondre par type/couleur.',
+    clickToChangeSlot: 'Cliquez pour modifier l\'attribution de l\'emplacement',
+    reRead: 'Relire',
     plateN: 'Plateau {{n}}',
     plateFilamentsUnreadable: 'Les filaments d\'un plateau sélectionné n\'ont pas pu être lus, il est donc impossible de l\'affecter. Désélectionnez-le pour mettre les autres en file.',
     totalCost: 'Coût total :',
@@ -4719,7 +4735,8 @@ export default {
     noPrintersConnected: 'Aucune imprimante connectée',
     printersConnected: '{{connected}}/{{total}} connectées',
     cloudProfiles: 'Profils Cloud',
-    cloudProfilesDescription: 'Préréglages de filament, imprimante et processus depuis Bambu Cloud',
+    cloudProfilesDescription: 'Préréglages de filament, imprimante et processus depuis Bambu Cloud et Orca Cloud',
+    cloudProfilesAccounts: 'Comptes connectés — Bambu Cloud : {{bambu}}, Orca Cloud : {{orca}}',
     appSettings: 'Paramètres de l\'application',
     appSettingsDescription: 'Configuration Bambuddy (base de données complète)',
     spoolInventory: 'Inventaire des bobines',
@@ -5129,6 +5146,10 @@ export default {
       title: 'Forcer la correspondance des couleurs',
       description: 'Refuser l\'envoi vers une imprimante qui n\'a pas exactement le type de filament et la couleur chargés. Désactivé par défaut — sans cela, la file d\'attente utilise uniquement la correspondance par modèle et peut choisir une imprimante avec la mauvaise couleur.',
     },
+    saveAmsMapping: {
+      title: 'Enregistrer le mappage AMS',
+      description: 'Conserve dans l\'archive le choix d\'emplacement AMS propre au slicer (depuis la commande MQTT project_file), afin qu\'une réimpression ultérieure réutilise la même bobine physique au lieu de la redéduire à partir du type/de la couleur du fichier. Désactivé par défaut.',
+    },
     gcodeInjection: {
       title: 'Injection G-code',
       description: 'Applique les extraits de G-code configurés par modèle dans les Paramètres aux travaux de ce VP. Désactivé par défaut.',
@@ -5908,6 +5929,7 @@ export default {
     filteringFor: 'Filtrage pour : {{material}}',
     noKProfile: 'Pas de profil K (utiliser défaut 0.020)',
     noMatchingKProfiles: 'Aucun profil K trouvé. K=0.020 par défaut sera utilisé.',
+    otherKProfiles: 'Autres profils K sur cette imprimante',
     selectFilamentFirst: 'Sélectionnez d\'abord un profil filament',
     kFromCalibration: 'K={{value}} de la calibration imprimante',
     customColorLabel: 'Couleur personnalisée (optionnel)',
@@ -6516,8 +6538,12 @@ export default {
     description: 'Surveille les impressions via une API ML Obico auto-hébergée et agit automatiquement sur les échecs détectés.',
     mlUrl: 'URL de l\'API ML Obico',
     mlUrlHint: 'URL de base de votre conteneur Obico ml_api auto-hébergé (ex. http://192.168.1.10:3333).',
+    mlToken: 'Jeton de l\'API ML (facultatif)',
+    mlTokenPlaceholder: 'Laissez vide si le serveur fonctionne sans ML_API_TOKEN',
+    mlTokenHint: 'Doit correspondre à la variable d\'environnement ML_API_TOKEN de votre conteneur Obico ml_api. Laissez vide si le conteneur fonctionne sans jeton.',
     test: 'Tester',
     testSuccess: 'API ML accessible et fonctionnelle.',
+    testSuccessTokenUnknown: 'API ML accessible et fonctionnelle. Le jeton n\'a pas pu être vérifié.',
     testFailed: 'Impossible d\'atteindre l\'API ML.',
     sensitivity: 'Sensibilité',
     sensitivityLow: 'Basse (moins de faux positifs)',

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: 'Caricato da',
       noPermissionReprint: 'Non hai il permesso di ristampare',
       noFileForReprint: 'Nessun file 3MF disponibile — il file non è stato scaricato dalla stampante durante la registrazione',
+      slicerAmsMapping: 'Mappatura AMS salvata ({{printer}})',
+      slicerAmsMappingTooltip: 'La scelta dello slot AMS fatta dallo slicer è stata salvata per {{printer}}. I numeri di slot hanno senso solo su quella stampante, quindi una ristampa li riutilizza solo se torna su {{printer}}.',
       noPermissionEdit: 'Non hai il permesso di modificare archivi',
       noPermissionDelete: 'Non hai il permesso di eliminare archivi',
       openInBambuStudio: 'Apri nello slicer',
@@ -1144,6 +1146,10 @@ export default {
       unknown: 'sconosciuto',
       printAnyway: 'Stampa comunque',
     },
+    slicerAmsMapping: {
+      rowBadge: 'Slot AMS salvati per questa stampante',
+      rowTooltip: 'Questo archivio conserva gli slot AMS esatti scelti dallo slicer, salvati per la stampante di questo elemento. Una ristampa su di essa può riutilizzare quelle bobine invece di rifare l\'abbinamento per tipo e colore.',
+    },
     title: 'Coda di stampa',
     subtitle: 'Programma e gestisci i tuoi lavori di stampa',
     // Print modal
@@ -2187,6 +2193,8 @@ export default {
     slicerCard: 'Slicer',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: 'Timeout di inattivita dello slicer (minuti)',
+    slicerStallTimeoutDescription: 'Interrompe uno slice dopo questo tempo senza progressi dal sidecar. I modelli pesanti che continuano a segnalare progressi non vengono mai interrotti, per quanto tempo richiedano. I sidecar che non segnalano progressi usano questo valore come limite di tempo totale.',
     slicerApiUrlDescription: 'URL del container sidecar slicer-API. Lascia vuoto per usare le variabili d\'ambiente SLICER_API_URL / BAMBU_STUDIO_API_URL.',
     slicerBundlesRemoved: {
       title: 'Bundle slicer (rimosso)',
@@ -2258,6 +2266,7 @@ export default {
       connectionFailed: 'Connessione fallita',
       testFailed: 'Test fallito',
       cameraConnected: 'Camera connessa{{resolution}}',
+      cameraConnectedCoalesced: 'Camera connessa{{resolution}} (condivisa con un\'acquisizione già in corso)',
     },
     testConnection: 'Testa connessione',
     catalog: {
@@ -2398,6 +2407,8 @@ export default {
     autoArchiveDescription: 'Salva automaticamente i file 3MF al completamento delle stampe',
     saveThumbnailsDescription: 'Estrai e salva le immagini di anteprima dai file 3MF',
     captureFinishPhotoDescription: 'Scatta una foto dalla fotocamera della stampante al completamento della stampa. Bambuddy registra un breve timelapse durante la stampa in modo che la foto possa essere ricavata dal momento precedente all\'abbassamento del piatto; il file del timelapse viene mantenuto se hai abilitato il timelapse per questa stampa, altrimenti viene eliminato automaticamente dopo l\'acquisizione della foto.',
+    finishPhotoRestorePlate: 'Solleva il piatto per la foto finale',
+    finishPhotoRestorePlateDescription: 'La stampante abbassa il piatto di circa 100 mm al termine di una stampa, lasciando l\'oggetto finito sotto l\'inquadratura della fotocamera. Bambuddy lo risolleva fino a poco sopra l\'ultimo strato stampato, scatta la foto e poi lo riabbassa. Ignorato se l\'altezza di stampa è sconosciuta o se un altro lavoro è in coda.',
     ffmpegNotInstalled: 'ffmpeg non installato',
     ffmpegRequired: 'L\'acquisizione dalla fotocamera richiede ffmpeg. Installalo tramite <brew>brew install ffmpeg</brew> (macOS) o <apt>apt install ffmpeg</apt> (Linux).',
     camera: 'Fotocamera',
@@ -2822,6 +2833,7 @@ export default {
     title: 'Errori - {{name}}',
     noErrors: 'Nessun errore',
     viewOnWiki: 'Vedi su Bambu Lab Wiki',
+    mqttVerifyFailedRemedy: 'Attiva la modalita sviluppatore sulla stampante, riavvia la stampante e avvia di nuovo il lavoro.',
     unknownCode: 'Codice HMS sconosciuto — consulta la wiki di Bambu Lab per i dettagli.',
     clearInstructions: 'Cancella gli errori sulla stampante per rimuoverli qui.',
     clearErrors: 'Cancella errori',
@@ -4606,6 +4618,10 @@ export default {
     selectPrinter: 'Seleziona stampante',
     selectPlate: 'Seleziona piatto',
     filamentMapping: 'Mappatura filamento',
+    useArchiveMapping: 'Mappatura',
+    useArchiveMappingTooltip: 'Seleziona ogni slot dalla mappatura AMS salvata con questo archivio (dallo slicer), invece di abbinare per tipo/colore.',
+    clickToChangeSlot: 'Fai clic per modificare l\'assegnazione dello slot',
+    reRead: 'Rileggi',
     plateN: 'Piatto {{n}}',
     plateFilamentsUnreadable: 'Non è stato possibile leggere i filamenti di un piatto selezionato, quindi non può essere assegnato. Deselezionalo per accodare gli altri.',
     totalCost: 'Costo totale:',
@@ -4718,7 +4734,8 @@ export default {
     noPrintersConnected: 'Nessuna stampante connessa',
     printersConnected: '{{connected}}/{{total}} connesse',
     cloudProfiles: 'Profili Cloud',
-    cloudProfilesDescription: 'Preset di filamento, stampante e processo da Bambu Cloud',
+    cloudProfilesDescription: 'Preset di filamento, stampante e processo da Bambu Cloud e Orca Cloud',
+    cloudProfilesAccounts: 'Account collegati — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
     appSettings: 'Impostazioni App',
     appSettingsDescription: 'Configurazione Bambuddy (database completo)',
     spoolInventory: 'Inventario bobine',
@@ -5128,6 +5145,10 @@ export default {
       title: 'Forza corrispondenza colori',
       description: 'Rifiuta di inviare a una stampante che non ha esattamente il tipo di filamento e il colore caricato. Disattivato per impostazione predefinita — senza questo, la coda usa solo la corrispondenza per modello e potrebbe scegliere una stampante con il colore sbagliato.',
     },
+    saveAmsMapping: {
+      title: 'Salva mappatura AMS',
+      description: 'Salva nell\'archivio la scelta dello slot AMS effettuata dallo slicer stesso (dal comando MQTT project_file), in modo che una ristampa successiva riutilizzi la stessa bobina fisica invece di ricavarla di nuovo dal tipo/colore del file. Disattivato per impostazione predefinita.',
+    },
     gcodeInjection: {
       title: 'Iniezione G-code',
       description: 'Applica gli snippet G-code configurati per modello nelle Impostazioni ai lavori di questo VP. Disattivato per impostazione predefinita.',
@@ -5907,6 +5928,7 @@ export default {
     filteringFor: 'Filtrando per: {{material}}',
     noKProfile: 'Nessun profilo K (usa predefinito 0.020)',
     noMatchingKProfiles: 'Nessun profilo K corrispondente. Verrà usato K=0.020 predefinito.',
+    otherKProfiles: 'Altri profili K su questa stampante',
     selectFilamentFirst: 'Seleziona prima un profilo filamento',
     kFromCalibration: 'K={{value}} dalla calibrazione stampante',
     customColorLabel: 'Colore personalizzato (opzionale)',
@@ -6515,8 +6537,12 @@ export default {
     description: 'Monitora le stampe tramite un\'API ML Obico auto-ospitata e agisce automaticamente sui guasti rilevati.',
     mlUrl: 'URL API ML Obico',
     mlUrlHint: 'URL base del tuo container Obico ml_api auto-ospitato (es. http://192.168.1.10:3333).',
+    mlToken: 'Token API ML (facoltativo)',
+    mlTokenPlaceholder: 'Lascia vuoto se il server funziona senza ML_API_TOKEN',
+    mlTokenHint: 'Deve corrispondere alla variabile di ambiente ML_API_TOKEN del tuo container Obico ml_api. Lascia vuoto se il container funziona senza token.',
     test: 'Prova',
     testSuccess: 'API ML raggiungibile e funzionante.',
+    testSuccessTokenUnknown: 'API ML raggiungibile e funzionante. Non è stato possibile verificare il token.',
     testFailed: 'Impossibile raggiungere l\'API ML.',
     sensitivity: 'Sensibilità',
     sensitivityLow: 'Bassa (meno falsi positivi)',

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

@@ -934,6 +934,8 @@ export default {
       uploadedBy: 'アップロード者',
       noPermissionReprint: '再印刷する権限がありません',
       noFileForReprint: '3MFファイルがありません — 印刷記録時にプリンターからファイルをダウンロードできませんでした',
+      slicerAmsMapping: 'AMSマッピングを保存済み({{printer}})',
+      slicerAmsMappingTooltip: 'スライサーが選んだAMSスロットは{{printer}}用に保存されています。スロット番号はそのプリンターでのみ意味を持つため、再印刷で再利用されるのは再び{{printer}}に送る場合だけです。',
       noPermissionEdit: 'プロファイルを編集する権限がありません',
       noPermissionDelete: 'アーカイブを削除する権限がありません',
       openInBambuStudio: 'スライサーで開く',
@@ -1143,6 +1145,10 @@ export default {
       unknown: '不明',
       printAnyway: 'それでも印刷',
     },
+    slicerAmsMapping: {
+      rowBadge: 'このプリンター用に保存されたAMSスロット',
+      rowTooltip: 'このアーカイブには、スライサーが選択した正確なAMSスロットが、この項目の対象プリンター用に保存されています。そのプリンターでの再印刷では、タイプと色で照合し直す代わりにそれらのトレイを再利用できます。',
+    },
     title: '印刷キュー',
     subtitle: '印刷ジョブのスケジュールと管理',
     // Print modal
@@ -2230,6 +2236,8 @@ export default {
     slicerCard: 'スライサー',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: 'スライサー停止タイムアウト(分)',
+    slicerStallTimeoutDescription: 'サイドカーからの進捗がこの時間なければスライスを中止します。進捗を報告し続ける重いモデルは、どれだけ時間がかかっても中断されません。進捗を報告しないサイドカーでは、この値が合計時間の上限になります。',
     slicerApiUrlDescription: 'slicer-APIサイドカーコンテナのURL。空のままにすると SLICER_API_URL / BAMBU_STUDIO_API_URL 環境変数のデフォルト値が使用されます。',
     slicerBundlesRemoved: {
       title: 'スライサーバンドル(削除済み)',
@@ -2301,6 +2309,7 @@ export default {
       connectionFailed: '接続失敗',
       testFailed: 'テスト通知の送信に失敗しました',
       cameraConnected: 'カメラ接続{{resolution}}',
+      cameraConnectedCoalesced: 'カメラ接続{{resolution}}(実行中のキャプチャと共有)',
     },
     testConnection: '接続テスト',
     catalog: {
@@ -2444,6 +2453,8 @@ export default {
     autoArchiveDescription: '印刷完了時に3MFファイルを自動保存',
     saveThumbnailsDescription: '3MFファイルからプレビュー画像を抽出して保存',
     captureFinishPhotoDescription: '印刷完了時にプリンターカメラから写真を撮影します。Bambuddy は印刷中に短いタイムラプスを記録し、ベッドが下がる前の瞬間から写真を取得できるようにします。この印刷でタイムラプスを有効にしていた場合はタイムラプスファイルが保存され、それ以外の場合は写真の取得後に自動的に削除されます。',
+    finishPhotoRestorePlate: '完了写真のためにプレートを戻す',
+    finishPhotoRestorePlateDescription: 'プリンターは印刷終了時にビルドプレートを約 100 mm 下降させるため、完成した造形物がカメラの画角より下に来ます。Bambuddy はプレートを最終印刷レイヤーのすぐ上まで戻して写真を撮影し、その後再び下降させます。造形高さが不明な場合や次のジョブがキューにある場合はスキップされます。',
     ffmpegNotInstalled: 'ffmpegがインストールされていません',
     ffmpegRequired: 'カメラ撮影にはffmpegが必要です。<brew>brew install ffmpeg</brew>(macOS)または<apt>apt install ffmpeg</apt>(Linux)でインストールしてください。',
     // Camera
@@ -2834,6 +2845,7 @@ export default {
     title: 'エラー - {{name}}',
     noErrors: 'エラーなし',
     viewOnWiki: 'Bambu Lab Wikiで表示',
+    mqttVerifyFailedRemedy: 'プリンターで開発者モードを有効にし、プリンターを再起動してから、ジョブをもう一度開始してください。',
     unknownCode: '不明なHMSコード — 詳細はBambu Lab Wikiを参照してください。',
     clearInstructions: 'プリンターでエラーをクリアするとここからも消えます。',
     clearErrors: 'エラーをクリア',
@@ -4618,6 +4630,10 @@ export default {
     selectPrinter: 'プリンターを選択',
     selectPlate: 'プレートを選択',
     filamentMapping: 'フィラメントマッピング',
+    useArchiveMapping: 'マッピング',
+    useArchiveMappingTooltip: 'タイプ/色で照合する代わりに、このアーカイブに保存されたAMSマッピング(スライサーから)からすべてのスロットを選択します。',
+    clickToChangeSlot: 'クリックしてスロットの割り当てを変更',
+    reRead: '再読み込み',
     plateN: 'プレート {{n}}',
     plateFilamentsUnreadable: '選択したプレートのフィラメントを読み取れなかったため、割り当てできません。そのプレートの選択を解除すると、残りをキューに追加できます。',
     totalCost: '合計コスト:',
@@ -4730,7 +4746,8 @@ export default {
     noPrintersConnected: 'プリンターが接続されていません',
     printersConnected: '{{connected}}/{{total}} 接続済み',
     cloudProfiles: 'クラウドプロファイル',
-    cloudProfilesDescription: 'Bambu Cloudからのフィラメント、プリンター、プロセスプリセット',
+    cloudProfilesDescription: 'Bambu CloudとOrca Cloudからのフィラメント、プリンター、プロセスプリセット',
+    cloudProfilesAccounts: '接続済みアカウント — Bambu Cloud: {{bambu}}、Orca Cloud: {{orca}}',
     appSettings: 'アプリ設定',
     appSettingsDescription: 'Bambuddy設定(データベース全体)',
     spoolInventory: 'スプール在庫',
@@ -5140,6 +5157,10 @@ export default {
       title: '色の一致を強制',
       description: '正確なフィラメントタイプと色がロードされていないプリンターへの送信を拒否します。デフォルトはオフ — これがないと、キューはモデルのみのマッチングを使用し、間違った色がロードされたプリンターを選ぶ可能性があります。',
     },
+    saveAmsMapping: {
+      title: 'AMSマッピングを保存',
+      description: 'スライサー自身が選択したAMSスロット(project_file MQTTコマンドから)をアーカイブに保存し、後で再印刷する際にファイルのタイプ/色から再導出するのではなく、同じ物理スプールを再利用できるようにします。デフォルトはオフです。',
+    },
     gcodeInjection: {
       title: 'G-codeインジェクション',
       description: '設定でモデルごとに構成したG-codeスニペットを、このVPのジョブに適用します。デフォルトはオフです。',
@@ -5919,6 +5940,7 @@ export default {
     filteringFor: 'フィルター中: {{material}}',
     noKProfile: 'Kプロファイルなし(デフォルト0.020を使用)',
     noMatchingKProfiles: '一致するKプロファイルが見つかりません。デフォルトK=0.020が使用されます。',
+    otherKProfiles: 'このプリンターの他のKプロファイル',
     selectFilamentFirst: 'まずフィラメントプロファイルを選択してください',
     kFromCalibration: 'K={{value}}(プリンターキャリブレーションから)',
     customColorLabel: 'カスタム色(オプション)',
@@ -6527,8 +6549,12 @@ export default {
     description: 'セルフホストされた Obico ML API で印刷を監視し、検出された失敗に自動的に対応します。',
     mlUrl: 'Obico ML API の URL',
     mlUrlHint: 'セルフホストした Obico ml_api コンテナのベース URL (例: http://192.168.1.10:3333)。',
+    mlToken: 'ML API トークン(任意)',
+    mlTokenPlaceholder: 'サーバーが ML_API_TOKEN なしで動作している場合は空のままにします',
+    mlTokenHint: 'Obico ml_api コンテナの環境変数 ML_API_TOKEN と一致させる必要があります。コンテナがトークンなしで動作している場合は空のままにしてください。',
     test: 'テスト',
     testSuccess: 'ML API に接続でき、正常です。',
+    testSuccessTokenUnknown: 'ML API に接続でき、正常です。トークンは確認できませんでした。',
     testFailed: 'ML API に接続できませんでした。',
     sensitivity: '感度',
     sensitivityLow: '低(誤検出が少ない)',

+ 28 - 2
frontend/src/i18n/locales/ko.ts

@@ -890,6 +890,8 @@ export default {
       uploadedBy: '업로드한 사용자',
       noPermissionReprint: '재인쇄 권한이 없습니다',
       noFileForReprint: '3MF 파일 없음 — 인쇄 기록 시 프린터에서 파일을 다운로드할 수 없었습니다',
+      slicerAmsMapping: 'AMS 매핑 저장됨({{printer}})',
+      slicerAmsMappingTooltip: '슬라이서가 선택한 AMS 슬롯이 {{printer}}용으로 저장되었습니다. 슬롯 번호는 해당 프린터에서만 의미가 있으므로, 다시 {{printer}}로 보낼 때만 재인쇄에서 재사용됩니다.',
       noPermissionEdit: '아카이브를 편집할 권한이 없습니다',
       noPermissionDelete: '아카이브를 삭제할 권한이 없습니다',
       openInBambuStudio: '슬라이서에서 열기',
@@ -1349,7 +1351,11 @@ export default {
       lineItem: '슬롯 {{slot}}: {{required}}g 필요, {{remaining}}g 남음',
       unknown: '알 수 없음',
       printAnyway: '그냥 인쇄'
-    }
+    },
+    slicerAmsMapping: {
+      rowBadge: '이 프린터용으로 저장된 AMS 슬롯',
+      rowTooltip: '이 아카이브에는 슬라이서가 선택한 정확한 AMS 슬롯이 이 항목의 대상 프린터용으로 저장되어 있습니다. 해당 프린터에서 재인쇄하면 유형과 색상으로 다시 맞추는 대신 그 트레이를 재사용할 수 있습니다.',
+    },
   },
   stats: {
     title: '대시보드',
@@ -2104,6 +2110,8 @@ export default {
     slicerCard: '슬라이서',
     orcaslicerApiUrl: 'OrcaSlicer 사이드카 URL',
     bambuStudioApiUrl: 'Bambu Studio 사이드카 URL',
+    slicerStallTimeout: '슬라이서 정지 시간 제한(분)',
+    slicerStallTimeoutDescription: '사이드카에서 이 시간 동안 진행 상황이 없으면 슬라이싱을 중단합니다. 진행 상황을 계속 보고하는 무거운 모델은 아무리 오래 걸려도 중단되지 않습니다. 진행 상황을 보고하지 않는 사이드카에서는 이 값이 전체 시간 제한으로 사용됩니다.',
     slicerApiUrlDescription: '슬라이서 API 사이드카 컨테이너의 URL. SLICER_API_URL / BAMBU_STUDIO_API_URL 환경 변수 기본값을 사용하려면 비워두세요.',
     slicerBundlesRemoved: {
       title: '슬라이서 번들 (제거됨)',
@@ -2170,6 +2178,7 @@ export default {
       connectionFailed: '연결 실패',
       testFailed: '테스트 실패',
       cameraConnected: '카메라 연결됨{{resolution}}',
+      cameraConnectedCoalesced: '카메라 연결됨{{resolution}} (이미 진행 중인 캡처와 공유됨)',
       passwordNeedsUppercase: '비밀번호에 대문자가 최소 1개 포함되어야 합니다',
       passwordNeedsLowercase: '비밀번호에 소문자가 최소 1개 포함되어야 합니다',
       passwordNeedsDigit: '비밀번호에 숫자가 최소 1개 포함되어야 합니다',
@@ -2314,6 +2323,8 @@ export default {
     autoArchiveDescription: '인쇄 완료 시 3MF 파일 자동 저장',
     saveThumbnailsDescription: '3MF 파일에서 미리보기 이미지 추출 및 저장',
     captureFinishPhotoDescription: '인쇄 완료 시 프린터 카메라로 사진 촬영. Bambuddy는 인쇄 중 짧은 타임랩스를 기록하여 베드가 내려가기 전 순간에서 사진을 가져올 수 있도록 합니다. 이 인쇄에 대해 타임랩스를 활성화한 경우 타임랩스 파일이 보관되며, 그렇지 않으면 사진 촬영 후 자동으로 삭제됩니다.',
+    finishPhotoRestorePlate: '완료 사진을 위해 베드 올리기',
+    finishPhotoRestorePlateDescription: '프린터는 인쇄가 끝나면 베드를 약 100 mm 내리므로 완성된 출력물이 카메라 화각 아래에 놓입니다. Bambuddy는 베드를 마지막 인쇄 레이어 바로 위까지 다시 올려 사진을 찍은 뒤 다시 내립니다. 출력 높이를 알 수 없거나 다른 작업이 대기 중이면 건너뜁니다.',
     ffmpegNotInstalled: 'ffmpeg 미설치',
     ffmpegRequired: '카메라 캡처에 ffmpeg가 필요합니다. macOS에서는 <brew>brew install ffmpeg</brew>, Linux에서는 <apt>apt install ffmpeg</apt>로 설치하세요.',
     camera: '카메라',
@@ -2684,6 +2695,7 @@ export default {
     title: '오류 - {{name}}',
     noErrors: '오류 없음',
     viewOnWiki: 'Bambu Lab 위키에서 보기',
+    mqttVerifyFailedRemedy: '프린터에서 개발자 모드를 활성화하고 프린터를 재시작한 다음 작업을 다시 시작하세요.',
     unknownCode: '알 수 없는 HMS 코드 — 자세한 내용은 Bambu Lab 위키를 참조하세요.',
     clearInstructions: '오류를 해제하려면 프린터에서 오류를 지우세요.',
     clearErrors: '오류 지우기',
@@ -4390,6 +4402,10 @@ export default {
     selectPrinter: '프린터 선택',
     selectPlate: '플레이트 선택',
     filamentMapping: '필라멘트 매핑',
+    useArchiveMapping: '매핑',
+    useArchiveMappingTooltip: '유형/색상으로 매칭하는 대신, 이 아카이브에 저장된 AMS 매핑(슬라이서 제공)에서 모든 슬롯을 선택합니다.',
+    clickToChangeSlot: '클릭하여 슬롯 할당 변경',
+    reRead: '다시 읽기',
     plateN: '플레이트 {{n}}',
     plateFilamentsUnreadable: '선택한 플레이트의 필라멘트를 읽을 수 없어 매핑할 수 없습니다. 해당 플레이트를 선택 해제하면 나머지를 대기열에 추가할 수 있습니다.',
     totalCost: '총 비용:',
@@ -4495,7 +4511,8 @@ export default {
     noPrintersConnected: '연결된 프린터 없음',
     printersConnected: '{{total}}개 중 {{connected}}개 연결됨',
     cloudProfiles: '클라우드 프로필',
-    cloudProfilesDescription: 'Bambu 클라우드의 필라멘트, 프린터 및 프로세스 프리셋',
+    cloudProfilesDescription: 'Bambu 클라우드와 Orca 클라우드의 필라멘트, 프린터 및 프로세스 프리셋',
+    cloudProfilesAccounts: '연결된 계정 — Bambu 클라우드: {{bambu}}, Orca 클라우드: {{orca}}',
     appSettings: '앱 설정',
     appSettingsDescription: 'Bambuddy 구성 (전체 데이터베이스)',
     spoolInventory: '스풀 재고',
@@ -4877,6 +4894,10 @@ export default {
       title: '색상 일치 강제',
       description: '정확한 필라멘트 유형과 색상이 장착되지 않은 프린터에는 발송을 거부합니다. 기본적으로 꺼져 있음 — 이 옵션 없이는 대기열이 모델 전용 매칭을 사용하여 잘못된 색상이 장착된 프린터를 선택할 수 있습니다.'
     },
+    saveAmsMapping: {
+      title: 'AMS 매핑 저장',
+      description: '슬라이서가 직접 선택한 AMS 슬롯(project_file MQTT 명령에서)을 아카이브에 저장하여, 이후 재인쇄 시 파일의 유형/색상에서 다시 유추하지 않고 동일한 실물 스풀을 재사용하도록 합니다. 기본값은 꺼짐입니다.',
+    },
     gcodeInjection: {
       title: 'G-code 주입',
       description: '설정에서 모델별로 구성한 G-code 스니펫을 이 가상 프린터의 작업에 적용합니다. 기본값은 꺼짐입니다.'
@@ -5609,6 +5630,7 @@ export default {
     filteringFor: '필터링 중: {{material}}',
     noKProfile: 'K 프로필 없음 (기본값 0.020 사용)',
     noMatchingKProfiles: '일치하는 K 프로필을 찾을 수 없습니다. 기본값 K=0.020이 사용됩니다.',
+    otherKProfiles: '이 프린터의 다른 K 프로필',
     selectFilamentFirst: '먼저 필라멘트 프로필을 선택하세요',
     kFromCalibration: 'K={{value}} (프린터 보정에서)',
     customColorLabel: '사용자 지정 색상 (선택사항)',
@@ -5995,8 +6017,12 @@ export default {
     description: '자체 호스팅 Obico ML API로 인쇄를 모니터링하고 감지된 실패에 자동으로 조치합니다.',
     mlUrl: 'Obico ML API URL',
     mlUrlHint: '자체 호스팅 Obico ml_api 컨테이너의 기본 URL (예: http://192.168.1.10:3333).',
+    mlToken: 'ML API 토큰 (선택 사항)',
+    mlTokenPlaceholder: '서버가 ML_API_TOKEN 없이 실행 중이면 비워 두세요',
+    mlTokenHint: 'Obico ml_api 컨테이너의 ML_API_TOKEN 환경 변수와 일치해야 합니다. 컨테이너가 토큰 없이 실행 중이면 비워 두세요.',
     test: '테스트',
     testSuccess: 'ML API에 도달 가능하며 정상입니다.',
+    testSuccessTokenUnknown: 'ML API에 도달 가능하며 정상입니다. 토큰은 확인할 수 없었습니다.',
     testFailed: 'ML API에 도달할 수 없습니다.',
     sensitivity: '민감도',
     sensitivityLow: '낮음 (오탐 적음)',

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: 'Enviado por',
       noPermissionReprint: 'Você não tem permissão para reimprimir',
       noFileForReprint: 'Nenhum arquivo 3MF disponível — o arquivo não pôde ser baixado da impressora quando a impressão foi registrada',
+      slicerAmsMapping: 'Mapeamento de AMS salvo ({{printer}})',
+      slicerAmsMappingTooltip: 'A escolha de slot AMS do fatiador foi salva para {{printer}}. Os números de slot só significam algo naquela impressora, portanto uma reimpressão só os reutiliza se voltar a mirar {{printer}}.',
       noPermissionEdit: 'Você não tem permissão para editar arquivos',
       noPermissionDelete: 'Você não tem permissão para excluir arquivos',
       openInBambuStudio: 'Abrir no Bambu Studio',
@@ -1144,6 +1146,10 @@ export default {
       unknown: 'desconhecido',
       printAnyway: 'Imprimir mesmo assim',
     },
+    slicerAmsMapping: {
+      rowBadge: 'Slots AMS salvos para esta impressora',
+      rowTooltip: 'Este arquivo mantém os slots AMS exatos escolhidos pelo fatiador, salvos para a impressora deste item. Uma reimpressão nela pode reutilizar esses carretéis em vez de casar novamente por tipo e cor.',
+    },
     title: 'Fila de Impressão',
     subtitle: 'Agende e gerencie seus trabalhos de impressão',
     // Print modal
@@ -2187,6 +2193,8 @@ export default {
     slicerCard: 'Fatiador',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: 'Tempo limite de inatividade do fatiador (minutos)',
+    slicerStallTimeoutDescription: 'Desiste de um fatiamento apos esse tempo sem progresso do sidecar. Modelos pesados que continuam relatando progresso nunca sao interrompidos, por mais que demorem. Sidecars que nao relatam progresso usam este valor como limite de tempo total.',
     slicerApiUrlDescription: 'URL do contêiner sidecar slicer-API. Deixe em branco para usar SLICER_API_URL / BAMBU_STUDIO_API_URL.',
     slicerBundlesRemoved: {
       title: 'Bundles do fatiador (removido)',
@@ -2258,6 +2266,7 @@ export default {
       connectionFailed: 'Falha na conexão',
       testFailed: 'Falha no teste',
       cameraConnected: 'Câmera conectada{{resolution}}',
+      cameraConnectedCoalesced: 'Câmera conectada{{resolution}} (compartilhada com uma captura já em andamento)',
     },
     testConnection: 'Testar Conexão',
     catalog: {
@@ -2398,6 +2407,8 @@ export default {
     autoArchiveDescription: 'Salvar automaticamente arquivos 3MF quando impressões forem concluídas',
     saveThumbnailsDescription: 'Extrair e salvar imagens de pré-visualização dos arquivos 3MF',
     captureFinishPhotoDescription: 'Tirar foto da câmera da impressora quando a impressão for concluída. Bambuddy grava um timelapse curto durante a impressão para que a foto possa ser obtida do momento antes da mesa descer; o arquivo do timelapse é mantido se você habilitou o timelapse para esta impressão, caso contrário ele é excluído automaticamente após a captura da foto.',
+    finishPhotoRestorePlate: 'Elevar a mesa para a foto final',
+    finishPhotoRestorePlateDescription: 'A impressora baixa a mesa cerca de 100 mm ao fim de uma impressão, deixando a peça pronta abaixo do enquadramento da câmera. O Bambuddy a eleva novamente até logo acima da última camada impressa, tira a foto e depois a baixa de novo. Ignorado quando a altura da impressão é desconhecida ou há outro trabalho na fila.',
     ffmpegNotInstalled: 'ffmpeg não instalado',
     ffmpegRequired: 'A captura de câmera requer ffmpeg. Instale via <brew>brew install ffmpeg</brew> (macOS) ou <apt>apt install ffmpeg</apt> (Linux).',
     camera: 'Câmera',
@@ -2822,6 +2833,7 @@ export default {
     title: 'Erros - {{name}}',
     noErrors: 'Nenhum erro',
     viewOnWiki: 'Ver no Bambu Lab Wiki',
+    mqttVerifyFailedRemedy: 'Ative o Modo Desenvolvedor na impressora, reinicie a impressora e inicie o trabalho novamente.',
     unknownCode: 'Código HMS desconhecido — consulte o wiki da Bambu Lab para mais detalhes.',
     clearInstructions: 'Limpe os erros na impressora para descartá-los aqui.',
     clearErrors: 'Limpar Erros',
@@ -4606,6 +4618,10 @@ export default {
     selectPrinter: 'Selecionar Impressora',
     selectPlate: 'Selecionar Placa',
     filamentMapping: 'Mapeamento de Filamento',
+    useArchiveMapping: 'Mapeamento',
+    useArchiveMappingTooltip: 'Selecionar todos os slots a partir do mapeamento de AMS salvo com este arquivo (do fatiador), em vez de corresponder por tipo/cor.',
+    clickToChangeSlot: 'Clique para alterar a atribuição do slot',
+    reRead: 'Reler',
     plateN: 'Placa {{n}}',
     plateFilamentsUnreadable: 'Não foi possível ler os filamentos de uma placa selecionada, portanto ela não pode ser mapeada. Desmarque-a para enfileirar as demais.',
     totalCost: 'Custo total:',
@@ -4718,7 +4734,8 @@ export default {
     noPrintersConnected: 'Nenhuma impressora conectada',
     printersConnected: '{{connected}}/{{total}} conectadas',
     cloudProfiles: 'Perfis Cloud',
-    cloudProfilesDescription: 'Predefinições de filamento, impressora e processo do Bambu Cloud',
+    cloudProfilesDescription: 'Predefinições de filamento, impressora e processo do Bambu Cloud e do Orca Cloud',
+    cloudProfilesAccounts: 'Contas conectadas — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
     appSettings: 'Configurações do App',
     appSettingsDescription: 'Configuração do Bambuddy (banco de dados completo)',
     spoolInventory: 'Inventário de bobinas',
@@ -5128,6 +5145,10 @@ export default {
       title: 'Forçar correspondência de cor',
       description: 'Recusa enviar para uma impressora que não tenha exatamente o tipo e cor de filamento carregados. Desativado por padrão — sem isto, a fila usa apenas correspondência por modelo e pode escolher uma impressora com a cor errada carregada.',
     },
+    saveAmsMapping: {
+      title: 'Salvar mapeamento de AMS',
+      description: 'Persiste a escolha de slot AMS feita pelo próprio fatiador (do comando MQTT project_file) no arquivo, para que uma reimpressão posterior reutilize o mesmo carretel físico em vez de derivá-lo novamente do tipo/cor do arquivo. Desativado por padrão.',
+    },
     gcodeInjection: {
       title: 'Injeção de G-code',
       description: 'Aplica os trechos de G-code configurados por modelo nas Configurações aos trabalhos deste VP. Desativado por padrão.',
@@ -5907,6 +5928,7 @@ export default {
     filteringFor: 'Filtrando por: {{material}}',
     noKProfile: 'Nenhum perfil K (usar padrão 0.020)',
     noMatchingKProfiles: 'Nenhum perfil K correspondente encontrado. O K padrão=0.020 será usado.',
+    otherKProfiles: 'Outros perfis K nesta impressora',
     selectFilamentFirst: 'Selecione um perfil de filamento primeiro',
     kFromCalibration: 'K={{value}} da calibração da impressora',
     customColorLabel: 'Cor Personalizada (opcional)',
@@ -6515,8 +6537,12 @@ export default {
     description: 'Monitora impressões via API ML do Obico auto-hospedada e age automaticamente em falhas detectadas.',
     mlUrl: 'URL da API ML do Obico',
     mlUrlHint: 'URL base do seu contêiner Obico ml_api auto-hospedado (ex.: http://192.168.1.10:3333).',
+    mlToken: 'Token da API ML (opcional)',
+    mlTokenPlaceholder: 'Deixe vazio se o servidor for executado sem ML_API_TOKEN',
+    mlTokenHint: 'Deve corresponder à variável de ambiente ML_API_TOKEN do seu contêiner Obico ml_api. Deixe vazio se o contêiner for executado sem token.',
     test: 'Testar',
     testSuccess: 'API ML acessível e operacional.',
+    testSuccessTokenUnknown: 'API ML acessível e operacional. Não foi possível verificar o token.',
     testFailed: 'Não foi possível acessar a API ML.',
     sensitivity: 'Sensibilidade',
     sensitivityLow: 'Baixa (menos falsos positivos)',

+ 27 - 1
frontend/src/i18n/locales/ru.ts

@@ -891,6 +891,8 @@ export default {
       uploadedBy: "Загрузил",
       noPermissionReprint: "У вас нет разрешения на повторную печать",
       noFileForReprint: "Файл 3MF недоступен: при сохранении задания не удалось скачать его с принтера",
+      slicerAmsMapping: "Маппинг AMS сохранён ({{printer}})",
+      slicerAmsMappingTooltip: "Выбор ячеек AMS, сделанный слайсером, сохранён для принтера {{printer}}. Номера ячеек имеют смысл только на нём, поэтому повторная печать использует их только при отправке снова на {{printer}}.",
       noPermissionEdit: "У вас нет разрешения на изменение архива",
       noPermissionDelete: "У вас нет разрешения на удаление записей из архива",
       openInBambuStudio: "Открыть в слайсере",
@@ -1096,6 +1098,10 @@ export default {
       unknown: "неизвестно",
       printAnyway: "Всё равно печатать",
     },
+    slicerAmsMapping: {
+      rowBadge: "Ячейки AMS сохранены для этого принтера",
+      rowTooltip: "У этого архива сохранены точные ячейки AMS, выбранные слайсером, — для принтера, на который нацелено это задание. Повторная печать на нём может использовать те же катушки вместо повторного подбора по типу и цвету.",
+    },
     editQueueItem: "Изменить задание в очереди",
     selectAllPlates: "Выбрать все пластины ({{count}})",
     deselectAll: "Снять выделение",
@@ -2105,6 +2111,8 @@ export default {
     slicerCard: "Слайсер",
     orcaslicerApiUrl: "URL API-службы OrcaSlicer",
     bambuStudioApiUrl: "URL API-службы Bambu Studio",
+    slicerStallTimeout: 'Тайм-аут простоя слайсера (минуты)',
+    slicerStallTimeoutDescription: 'Прервать нарезку, если sidecar не сообщает о прогрессе в течение этого времени. Тяжёлые модели, которые продолжают сообщать о прогрессе, не прерываются, сколько бы времени ни потребовалось. Для sidecar без отчёта о прогрессе это значение используется как общий лимит времени.',
     slicerApiUrlDescription: "URL контейнера API-службы слайсера. Оставьте пустым, чтобы использовать значения переменных окружения SLICER_API_URL или BAMBU_STUDIO_API_URL.",
     slicerBundlesRemoved: {
       title: "Пакеты профилей слайсера (удалено)",
@@ -2175,6 +2183,7 @@ export default {
       connectionFailed: "Не удалось подключиться",
       testFailed: "Проверка завершилась ошибкой",
       cameraConnected: "Камера подключена{{resolution}}",
+      cameraConnectedCoalesced: "Камера подключена{{resolution}} (используется уже выполняющийся захват)",
     },
     testConnection: "Проверить подключение",
     catalog: {
@@ -2315,6 +2324,8 @@ export default {
     autoArchiveDescription: "Автоматически сохранять 3MF после завершения печати",
     saveThumbnailsDescription: "Извлекать и сохранять изображения предпросмотра из 3MF",
     captureFinishPhotoDescription: "Сделать снимок камерой принтера после завершения печати. Во время печати Bambuddy записывает короткий таймлапс, чтобы получить кадр до опускания стола. Если таймлапс был включён для задания, файл сохранится; иначе после получения снимка он будет автоматически удалён.",
+    finishPhotoRestorePlate: "Поднимать стол для финального снимка",
+    finishPhotoRestorePlateDescription: "По окончании печати принтер опускает стол примерно на 100 мм, и готовая модель оказывается ниже кадра камеры. Bambuddy поднимает стол обратно чуть выше последнего напечатанного слоя, делает снимок и снова опускает его. Пропускается, если высота печати неизвестна или в очереди есть другое задание.",
     ffmpegNotInstalled: "ffmpeg не установлен",
     ffmpegRequired: "Для захвата изображения требуется ffmpeg. Установите его командой <brew>brew install ffmpeg</brew> в macOS или <apt>apt install ffmpeg</apt> в Linux.",
     camera: "Камера",
@@ -2676,6 +2687,7 @@ export default {
     title: "Ошибки — {{name}}",
     noErrors: "Ошибок нет",
     viewOnWiki: "Открыть в Bambu Lab Wiki",
+    mqttVerifyFailedRemedy: "Включите режим разработчика на принтере, перезагрузите принтер и запустите задание снова.",
     unknownCode: "Неизвестный код HMS — подробности см. в Bambu Lab Wiki.",
     clearInstructions: "Устраните ошибки на принтере, чтобы они исчезли из этого списка.",
     clearErrors: "Очистить ошибки",
@@ -4379,6 +4391,10 @@ export default {
     selectPrinter: "Выберите принтер",
     selectPlate: "Выберите пластину",
     filamentMapping: "Сопоставление филаментов",
+    useArchiveMapping: "Маппинг",
+    useArchiveMappingTooltip: "Выбрать все ячейки из маппинга AMS, сохранённого с этим архивом (от слайсера), вместо подбора по типу/цвету.",
+    clickToChangeSlot: "Нажмите, чтобы изменить назначение ячейки",
+    reRead: "Перечитать",
     plateN: "Пластина {{n}}",
     plateFilamentsUnreadable: "Не удалось определить филаменты выбранной пластины, поэтому их невозможно сопоставить. Снимите выбор с этой пластины, чтобы добавить остальные в очередь.",
     totalCost: "Общая стоимость:",
@@ -4487,7 +4503,8 @@ export default {
     noPrintersConnected: "Нет подключённых принтеров",
     printersConnected: "Подключено {{connected}} из {{total}}",
     cloudProfiles: "Облачные профили",
-    cloudProfilesDescription: "Предустановки филамента, принтера и процесса из Bambu Cloud",
+    cloudProfilesDescription: "Предустановки филамента, принтера и процесса из Bambu Cloud и Orca Cloud",
+    cloudProfilesAccounts: "Подключённые аккаунты — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}",
     appSettings: "Настройки приложения",
     appSettingsDescription: "Конфигурация Bambuddy (полная база данных)",
     spoolInventory: "Учёт катушек",
@@ -4865,6 +4882,10 @@ export default {
       title: "Требовать совпадения цвета",
       description: "Не назначать задание принтеру, если загружены филамент другого типа или другого цвета. По умолчанию выключено: без этой проверки очередь сопоставляет только модель принтера и может выбрать принтер с неподходящим цветом.",
     },
+    saveAmsMapping: {
+      title: "Сохранять маппинг AMS",
+      description: "Сохранять выбор AMS-ячейки, который сделал слайсер (из MQTT-команды project_file), в архив — тогда повторная печать использует ту же физическую катушку вместо повторного подбора по типу/цвету файла. По умолчанию выключено.",
+    },
     gcodeInjection: {
       title: "Вставка G-code",
       description: "Применять к заданиям этого виртуального принтера фрагменты G-code для соответствующей модели, заданные в настройках. По умолчанию выключено.",
@@ -5596,6 +5617,7 @@ export default {
     filteringFor: "Фильтр по материалу: {{material}}",
     noKProfile: "Без K-профиля (стандартное значение 0,020)",
     noMatchingKProfiles: "Подходящие K-профили не найдены. Будет использовано стандартное значение K=0,020.",
+    otherKProfiles: "Другие K-профили на этом принтере",
     selectFilamentFirst: "Сначала выберите профиль филамента",
     kFromCalibration: "K={{value}} из калибровки принтера",
     customColorLabel: "Пользовательский цвет (необязательно)",
@@ -6154,8 +6176,12 @@ export default {
     description: "Контролируйте печать через собственный сервер Obico ML API и автоматически реагируйте на обнаруженные сбои.",
     mlUrl: "URL Obico ML API",
     mlUrlHint: "Базовый URL собственного контейнера Obico ml_api, например http://192.168.1.10:3333.",
+    mlToken: "Токен ML API (необязательно)",
+    mlTokenPlaceholder: "Оставьте пустым, если сервер работает без ML_API_TOKEN",
+    mlTokenHint: "Должен совпадать с переменной окружения ML_API_TOKEN вашего контейнера Obico ml_api. Оставьте пустым, если контейнер работает без токена.",
     test: "Проверить",
     testSuccess: "ML API доступен и работает.",
+    testSuccessTokenUnknown: "ML API доступен и работает. Проверить токен не удалось.",
     testFailed: "Не удалось подключиться к ML API.",
     sensitivity: "Чувствительность",
     sensitivityLow: "Низкая (меньше ложных срабатываний)",

+ 27 - 1
frontend/src/i18n/locales/tr.ts

@@ -935,6 +935,8 @@ export default {
       uploadedBy: 'Yükleyen',
       noPermissionReprint: 'Yeniden yazdırma izniniz yok',
       noFileForReprint: 'Kullanılabilir 3MF dosyası yok — baskı kaydedildiğinde dosya yazıcıdan indirilemedi',
+      slicerAmsMapping: 'AMS eşlemesi kaydedildi ({{printer}})',
+      slicerAmsMappingTooltip: 'Dilimleyicinin AMS yuva seçimi {{printer}} için kaydedildi. Yuva numaraları yalnızca o yazıcıda anlamlıdır; bu nedenle yeniden yazdırma bunları yalnızca yine {{printer}} hedeflendiğinde kullanır.',
       noPermissionEdit: 'Arşivleri düzenleme izniniz yok',
       noPermissionDelete: 'Arşivleri silme izniniz yok',
       openInBambuStudio: 'Dilimleyicide Aç',
@@ -1146,6 +1148,10 @@ export default {
       unknown: 'bilinmiyor',
       printAnyway: 'Yine de Yazdır',
     },
+    slicerAmsMapping: {
+      rowBadge: 'Bu yazıcı için kaydedilen AMS yuvaları',
+      rowTooltip: 'Bu arşiv, dilimleyicinin seçtiği tam AMS yuvalarını bu öğenin hedeflediği yazıcı için saklar. O yazıcıda yeniden yazdırma, tür ve renge göre yeniden eşleştirmek yerine bu makaraları yeniden kullanabilir.',
+    },
     // Baskı modali
     editQueueItem: 'Kuyruk Öğesini Düzenle',
     selectAllPlates: 'Tüm {{count}} Plakayı Seç',
@@ -2235,6 +2241,8 @@ export default {
     slicerCard: 'Dilimleyici',
     orcaslicerApiUrl: 'OrcaSlicer yardımcı bileşen URL',
     bambuStudioApiUrl: 'Bambu Studio yardımcı bileşen URL',
+    slicerStallTimeout: 'Dilimleyici duraklama zaman asimi (dakika)',
+    slicerStallTimeoutDescription: 'Sidecar bu sure boyunca ilerleme bildirmezse dilimleme iptal edilir. Ilerleme bildirmeye devam eden agir modeller ne kadar surerse sursun kesilmez. Ilerleme bildirmeyen sidecar surumleri bu degeri toplam sure siniri olarak kullanir.',
     slicerApiUrlDescription: 'Dilimleyici-API yardımcı bileşen konteynerinin URL\'si. SLICER_API_URL / BAMBU_STUDIO_API_URL ortam değişkeni varsayılanlarını kullanmak için boş bırakın.',
     slicerBundlesRemoved: {
       title: 'Dilimleyici Paketleri (kaldırıldı)',
@@ -2306,6 +2314,7 @@ export default {
       connectionFailed: 'Bağlantı başarısız',
       testFailed: 'Test başarısız',
       cameraConnected: 'Kamera bağlandı{{resolution}}',
+      cameraConnectedCoalesced: 'Kamera bağlandı{{resolution}} (hâlihazırda süren bir yakalamayla paylaşıldı)',
     },
     testConnection: 'Bağlantıyı Test Et',
     catalog: {
@@ -2449,6 +2458,8 @@ export default {
     autoArchiveDescription: 'Baskılar tamamlandığında 3MF dosyalarını otomatik olarak kaydet',
     saveThumbnailsDescription: '3MF dosyalarından önizleme görüntülerini çıkar ve kaydet',
     captureFinishPhotoDescription: 'Baskı tamamlandığında yazıcı kamerasından bir fotoğraf çek. Bambuddy, baskı sırasında kısa bir zaman atlamalı kayıt yapar, böylece fotoğraf tabla inmeden önceki andan alınabilir. Bu baskı için zaman atlamalı kaydı etkinleştirdiyseniz dosya saklanır, aksi takdirde fotoğraf çekildikten sonra otomatik olarak silinir.',
+    finishPhotoRestorePlate: 'Bitiş fotoğrafı için tablayı yükselt',
+    finishPhotoRestorePlateDescription: 'Yazıcı, baskı bittiğinde tablayı yaklaşık 100 mm aşağı indirir ve tamamlanmış baskı kameranın çerçevesinin altında kalır. Bambuddy tablayı son basılan katmanın hemen üzerine geri kaldırır, fotoğrafı çeker ve ardından tekrar indirir. Baskı yüksekliği bilinmiyorsa veya kuyrukta başka bir iş varsa atlanır.',
     ffmpegNotInstalled: 'ffmpeg yüklü değil',
     ffmpegRequired: 'Kamera yakalama ffmpeg gerektirir. <brew>brew install ffmpeg</brew> (macOS) veya <apt>apt install ffmpeg</apt> (Linux) ile yükleyin.',
     // Kamera
@@ -2838,6 +2849,7 @@ export default {
     title: 'Hatalar - {{name}}',
     noErrors: 'Hata yok',
     viewOnWiki: 'Bambu Lab Wiki\'de görüntüle',
+    mqttVerifyFailedRemedy: 'Yazicida Gelistirici Modunu etkinlestirin, yaziciyi yeniden baslatin ve isi tekrar baslatin.',
     unknownCode: 'Bilinmeyen HMS kodu — ayrıntılar için Bambu Lab wiki sayfasına bakın.',
     clearInstructions: 'Buradan kapatmak için yazıcıdaki hataları temizleyin.',
     clearErrors: 'Hataları Temizle',
@@ -4596,6 +4608,10 @@ export default {
     selectPrinter: 'Yazıcı Seç',
     selectPlate: 'Plaka Seç',
     filamentMapping: 'Filament Eşlemesi',
+    useArchiveMapping: 'Eşleme',
+    useArchiveMappingTooltip: 'Tür/renge göre eşleştirmek yerine, bu arşivle kaydedilen AMS eşlemesindeki (dilimleyiciden) her yuvayı seç.',
+    clickToChangeSlot: 'Yuva atamasını değiştirmek için tıklayın',
+    reRead: 'Yeniden oku',
     plateN: 'Plaka {{n}}',
     plateFilamentsUnreadable: 'Seçili bir plakanın filamentleri okunamadı, bu yüzden eşleştirilemiyor. Diğerlerini kuyruğa almak için o plakanın seçimini kaldırın.',
     totalCost: 'Toplam maliyet:',
@@ -4708,7 +4724,8 @@ export default {
     noPrintersConnected: 'Bağlı yazıcı yok',
     printersConnected: '{{connected}}/{{total}} bağlı',
     cloudProfiles: 'Bulut Profilleri',
-    cloudProfilesDescription: 'Bambu Cloud\'dan filament, yazıcı ve işlem ön ayarları',
+    cloudProfilesDescription: 'Bambu Cloud ve Orca Cloud\'dan filament, yazıcı ve işlem ön ayarları',
+    cloudProfilesAccounts: 'Bağlı hesaplar — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
     appSettings: 'Uygulama Ayarları',
     appSettingsDescription: 'Bambuddy yapılandırması (tam veritabanı)',
     spoolInventory: 'Makara Envanteri',
@@ -5104,6 +5121,10 @@ export default {
       title: 'Renk eşleşmesini zorla',
       description: 'Tam olarak doğru filament türü ve rengi yüklü olmayan bir yazıcıya sevk etmeyi reddet. Varsayılan olarak kapalı — bu olmadan kuyruk yalnızca model eşleşmesi kullanır ve yanlış renk yüklü bir yazıcı seçebilir.',
     },
+    saveAmsMapping: {
+      title: 'AMS eşlemesini kaydet',
+      description: 'Dilimleyicinin kendi seçtiği AMS yuvasını (project_file MQTT komutundan) arşive kalıcı olarak kaydeder, böylece daha sonraki bir yeniden yazdırma dosyanın türünden/renginden yeniden türetmek yerine tam olarak aynı fiziksel makarayı yeniden kullanır. Varsayılan olarak kapalı.',
+    },
     gcodeInjection: {
       title: 'G-code enjeksiyonu',
       description: "Ayarlar'da model bazında yapılandırılan G-code parçacıklarını bu VP'nin işlerine uygular. Varsayılan olarak kapalı.",
@@ -5863,6 +5884,7 @@ export default {
     filteringFor: 'Şu için filtreleniyor: {{material}}',
     noKProfile: 'K profili yok (varsayılan 0.020 kullan)',
     noMatchingKProfiles: 'Eşleşen K profili bulunamadı. Varsayılan K=0.020 kullanılacak.',
+    otherKProfiles: 'Bu yazıcıdaki diğer K profilleri',
     selectFilamentFirst: 'Önce bir filament profili seçin',
     kFromCalibration: 'Yazıcı kalibrasyonundan K={{value}}',
     customColorLabel: 'Özel Renk (isteğe bağlı)',
@@ -6466,8 +6488,12 @@ export default {
     description: 'Baskıları kendi barındırılan bir Obico ML API ile izle ve algılanan başarısızlıklara otomatik olarak yanıt ver.',
     mlUrl: 'Obico ML API URL\'si',
     mlUrlHint: 'Kendi barındırılan Obico ml_api konteynerinizin temel URL\'si (örn. http://192.168.1.10:3333).',
+    mlToken: 'ML API Belirteci (isteğe bağlı)',
+    mlTokenPlaceholder: 'Sunucu ML_API_TOKEN olmadan çalışıyorsa boş bırakın',
+    mlTokenHint: 'Obico ml_api konteynerinizin ML_API_TOKEN ortam değişkeniyle eşleşmelidir. Konteyner belirteç olmadan çalışıyorsa boş bırakın.',
     test: 'Test',
     testSuccess: 'ML API erişilebilir ve sağlıklı.',
+    testSuccessTokenUnknown: 'ML API erişilebilir ve sağlıklı. Belirteç doğrulanamadı.',
     testFailed: 'ML API\'ye erişilemedi.',
     sensitivity: 'Hassasiyet',
     sensitivityLow: 'Düşük (daha az yanlış pozitif)',

+ 27 - 1
frontend/src/i18n/locales/uk.ts

@@ -939,6 +939,8 @@ export default {
       uploadedBy: "Вивантажив",
       noPermissionReprint: "Ви не маєте дозволу на передрук",
       noFileForReprint: "Файл 3MF недоступний: його не вдалося завантажити з принтера під час збереження запису про друк",
+      slicerAmsMapping: "Зіставлення AMS збережено ({{printer}})",
+      slicerAmsMappingTooltip: "Вибір слотів AMS, зроблений слайсером, збережено для принтера {{printer}}. Номери слотів мають сенс лише на ньому, тож повторний друк використає їх, тільки якщо знову спрямований на {{printer}}.",
       noPermissionEdit: "Ви не маєте прав на редагування архівів",
       noPermissionDelete: "Ви не маєте дозволу на видалення архівів",
       openInBambuStudio: "Відкрити у слайсері",
@@ -1155,6 +1157,10 @@ export default {
       unknown: "невідомо",
       printAnyway: "Усе одно друкувати",
     },
+    slicerAmsMapping: {
+      rowBadge: "Слоти AMS збережено для цього принтера",
+      rowTooltip: "Цей запис містить точні слоти AMS, вибрані слайсером, збережені для принтера, на який націлено це завдання. Повторний друк на ньому може використати ті самі котушки замість повторного добору за типом і кольором.",
+    },
     // Print modal
     editQueueItem: "Редагувати елемент черги",
     selectAllPlates: "Вибрати всі пластини ({{count}})",
@@ -2250,6 +2256,8 @@ export default {
     slicerCard: "Слайсер",
     orcaslicerApiUrl: "URL допоміжного сервісу OrcaSlicer",
     bambuStudioApiUrl: "URL допоміжного сервісу Bambu Studio",
+    slicerStallTimeout: 'Тайм-аут простою слайсера (хвилини)',
+    slicerStallTimeoutDescription: 'Перервати нарізку, якщо sidecar не повідомляє про прогрес протягом цього часу. Важкі моделі, які продовжують повідомляти про прогрес, ніколи не перериваються, скільки б часу не знадобилося. Для sidecar без звіту про прогрес це значення використовується як загальний ліміт часу.',
     slicerApiUrlDescription: "URL контейнера допоміжного сервісу slicer-API. Залиште поле порожнім, щоб використовувати типові значення зі змінних середовища SLICER_API_URL / BAMBU_STUDIO_API_URL.",
     slicerBundlesRemoved: {
       title: "Пакети профілів слайсера (вилучено)",
@@ -2321,6 +2329,7 @@ export default {
       connectionFailed: "Помилка підключення",
       testFailed: "Тест не вдалося",
       cameraConnected: "Камера підключена{{resolution}}",
+      cameraConnectedCoalesced: "Камера підключена{{resolution}} (спільно з уже виконуваним захопленням)",
     },
     testConnection: "Тестове підключення",
     catalog: {
@@ -2464,6 +2473,8 @@ export default {
     autoArchiveDescription: "Автоматично зберігати файли 3MF після завершення друку",
     saveThumbnailsDescription: "Витягніть і збережіть зображення попереднього перегляду з файлів 3MF.",
     captureFinishPhotoDescription: "Зробіть фотографію з камери принтера після завершення друку. Bambuddy записує короткий проміжок часу під час друку, щоб фотографію можна було отримати з моменту, коли стіл опускається; файл уповільненої зйомки зберігається, якщо ви ввімкнули уповільнену зйомку для цього друку, інакше він автоматично видаляється після зйомки фотографії.",
+    finishPhotoRestorePlate: "Піднімати стіл для фінального знімка",
+    finishPhotoRestorePlateDescription: "Після завершення друку принтер опускає стіл приблизно на 100 мм, і готова модель опиняється нижче кадру камери. Bambuddy піднімає стіл назад трохи вище останнього надрукованого шару, робить знімок і знову опускає його. Пропускається, якщо висота друку невідома або в черзі є інше завдання.",
     ffmpegNotInstalled: "ffmpeg не встановлено",
     ffmpegRequired: "Для зйомки камерою потрібен ffmpeg. Встановіть його за допомогою <brew>brew install ffmpeg</brew> (macOS) або <apt>apt install ffmpeg</apt> (Linux).",
     // Camera
@@ -2863,6 +2874,7 @@ export default {
     title: "Помилки - {{name}}",
     noErrors: "Помилок немає",
     viewOnWiki: "Переглянути на Bambu Lab Wiki",
+    mqttVerifyFailedRemedy: "Увімкніть режим розробника на принтері, перезавантажте принтер і запустіть завдання знову.",
     unknownCode: "Невідомий код HMS — подробиці дивіться у вікі Bambu Lab.",
     clearInstructions: "Усуньте помилки на принтері, щоб вони зникли тут.",
     clearErrors: "Очистити помилки",
@@ -4661,6 +4673,10 @@ export default {
     selectPrinter: "Вибрати принтер",
     selectPlate: "Вибрати пластину",
     filamentMapping: "Зіставлення філаментів",
+    useArchiveMapping: "Зіставлення",
+    useArchiveMappingTooltip: "Вибрати всі слоти зі зіставлення AMS, збереженого з цим записом (зі слайсера), замість добору за типом/кольором.",
+    clickToChangeSlot: "Натисніть, щоб змінити призначення слота",
+    reRead: "Перечитати",
     plateN: "Пластина {{n}}",
     plateFilamentsUnreadable: "Не вдалося прочитати філаменти вибраної пластини, тому їх неможливо зіставити. Зніміть вибір із цієї пластини, щоб додати решту до черги.",
     totalCost: "Загальна вартість:",
@@ -4773,7 +4789,8 @@ export default {
     noPrintersConnected: "Немає підключених принтерів",
     printersConnected: "{{connected}}/{{total}} підключено",
     cloudProfiles: "Хмарні профілі",
-    cloudProfilesDescription: "Попередні налаштування філаменту, принтера та процесу від Bambu Cloud",
+    cloudProfilesDescription: "Попередні налаштування філаменту, принтера та процесу від Bambu Cloud і Orca Cloud",
+    cloudProfilesAccounts: "Підключені акаунти — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}",
     appSettings: "Налаштування програми",
     appSettingsDescription: "Конфігурація Bambuddy (повна база даних)",
     spoolInventory: "Облік котушок",
@@ -5183,6 +5200,10 @@ export default {
       title: "Примусовий збіг кольорів",
       description: "Не надсилати завдання на принтер без філаменту точного типу й кольору. Типово вимкнено: без цього черга зіставляє лише модель і може вибрати принтер із філаментом іншого кольору.",
     },
+    saveAmsMapping: {
+      title: "Зберігати зіставлення AMS",
+      description: "Зберігати в записі вибір слота AMS, зроблений самим слайсером (з MQTT-команди project_file), щоб пізніший повторний друк використав ту саму фізичну котушку замість повторного визначення за типом/кольором файлу. Типово вимкнено.",
+    },
     gcodeInjection: {
       title: "Вставлення G-коду",
       description: "Застосовувати до завдань цього віртуального принтера фрагменти G-коду, налаштовані для кожної моделі. Типово вимкнено.",
@@ -5962,6 +5983,7 @@ export default {
     filteringFor: "Фільтрування за: {{material}}",
     noKProfile: "Немає профілю K (використовуйте значення за замовчуванням 0,020)",
     noMatchingKProfiles: "Не знайдено відповідних K профілів. Використовуватиметься K=0,020 за замовчуванням.",
+    otherKProfiles: "Інші K-профілі на цьому принтері",
     selectFilamentFirst: "Спочатку виберіть профіль філаменту",
     kFromCalibration: "K={{value}} від калібрування принтера",
     customColorLabel: "Власний колір (необов’язково)",
@@ -6570,8 +6592,12 @@ export default {
     description: "Відстежуйте друк за допомогою самостійно розгорнутого Obico ML API та автоматично реагуйте на виявлені помилки.",
     mlUrl: "URL Obico ML API",
     mlUrlHint: "База URL вашого контейнера ml_api, розміщеного на власному хості Obico (наприклад, http://192.168.1.10:3333).",
+    mlToken: "Токен ML API (необов'язково)",
+    mlTokenPlaceholder: "Залиште порожнім, якщо сервер працює без ML_API_TOKEN",
+    mlTokenHint: "Має збігатися зі змінною середовища ML_API_TOKEN вашого контейнера Obico ml_api. Залиште порожнім, якщо контейнер працює без токена.",
     test: "Тест",
     testSuccess: "ML API доступний і працює належним чином.",
+    testSuccessTokenUnknown: "ML API доступний і працює належним чином. Не вдалося перевірити токен.",
     testFailed: "Не вдалося підключитися до ML API.",
     sensitivity: "Чутливість",
     sensitivityLow: "Низький (менше помилкових спрацьовувань)",

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: '上传者',
       noPermissionReprint: '您没有重新打印的权限',
       noFileForReprint: '无可用的 3MF 文件 — 打印记录时无法从打印机下载该文件',
+      slicerAmsMapping: '已保存 AMS 映射({{printer}})',
+      slicerAmsMappingTooltip: '切片软件选择的 AMS 槽位已为 {{printer}} 保存。槽位编号只在该打印机上有意义,因此只有再次发往 {{printer}} 时,重新打印才会复用它们。',
       noPermissionEdit: '您没有编辑归档的权限',
       noPermissionDelete: '您没有删除归档的权限',
       openInBambuStudio: '在切片软件中打开',
@@ -1144,6 +1146,10 @@ export default {
       unknown: '未知',
       printAnyway: '仍要打印',
     },
+    slicerAmsMapping: {
+      rowBadge: '已为此打印机保存 AMS 槽位',
+      rowTooltip: '此存档保留了切片软件选择的确切 AMS 槽位,并为该项目的目标打印机保存。在该打印机上重新打印时,可复用这些料盘,而不必再按类型和颜色重新匹配。',
+    },
     title: '打印队列',
     subtitle: '排程和管理您的打印任务',
     // Print modal
@@ -2232,6 +2238,8 @@ export default {
     slicerCard: '切片器',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: '切片器停滞超时(分钟)',
+    slicerStallTimeoutDescription: '若 sidecar 在此时长内没有任何进度,则放弃本次切片。持续报告进度的复杂模型无论耗时多久都不会被中断。不报告进度的 sidecar 则将此值作为总时长上限。',
     slicerApiUrlDescription: 'slicer-API sidecar 容器的 URL。留空以使用 SLICER_API_URL / BAMBU_STUDIO_API_URL 环境变量默认值。',
     slicerBundlesRemoved: {
       title: '切片器捆绑包(已移除)',
@@ -2303,6 +2311,7 @@ export default {
       connectionFailed: '连接失败',
       testFailed: '测试失败',
       cameraConnected: '摄像头已连接{{resolution}}',
+      cameraConnectedCoalesced: '摄像头已连接{{resolution}}(与正在进行的抓取共享)',
     },
     testConnection: '测试连接',
     catalog: {
@@ -2443,6 +2452,8 @@ export default {
     autoArchiveDescription: '打印完成时自动保存3MF文件',
     saveThumbnailsDescription: '从3MF文件中提取并保存预览图像',
     captureFinishPhotoDescription: '打印完成时从打印机摄像头拍照。Bambuddy 会在打印期间录制一段短延时摄影,以便从热床下降前的瞬间获取照片;如果您为本次打印启用了延时摄影,文件将保留,否则会在拍照完成后自动删除。',
+    finishPhotoRestorePlate: '为完成照片抬升热床',
+    finishPhotoRestorePlateDescription: '打印结束时打印机会将热床下降约 100 mm,使完成的模型落在相机取景范围之下。Bambuddy 会将热床抬回到最后一层打印高度略上方,拍摄照片后再次下降。若打印高度未知或队列中还有其他任务,则跳过此步骤。',
     ffmpegNotInstalled: '未安装ffmpeg',
     ffmpegRequired: '摄像头捕获需要ffmpeg。通过 <brew>brew install ffmpeg</brew>(macOS)或 <apt>apt install ffmpeg</apt>(Linux)安装。',
     camera: '摄像头',
@@ -2822,6 +2833,7 @@ export default {
     title: '错误 - {{name}}',
     noErrors: '无错误',
     viewOnWiki: '在拓竹 Wiki 上查看',
+    mqttVerifyFailedRemedy: '在打印机上启用开发者模式,重启打印机,然后重新开始该任务。',
     unknownCode: '未知 HMS 代码 — 详情请参阅拓竹 Wiki。',
     clearInstructions: '在打印机上清除错误以在此处消除它们。',
     clearErrors: '清除错误',
@@ -4606,6 +4618,10 @@ export default {
     selectPrinter: '选择打印机',
     selectPlate: '选择板',
     filamentMapping: '耗材映射',
+    useArchiveMapping: '映射',
+    useArchiveMappingTooltip: '从此存档保存的 AMS 映射(来自切片软件)中选择所有槽位,而不是按类型/颜色匹配。',
+    clickToChangeSlot: '点击更改槽位分配',
+    reRead: '重新读取',
     plateN: '板 {{n}}',
     plateFilamentsUnreadable: '无法读取所选盘的耗材信息,因此无法进行映射。取消选择该盘即可将其余盘加入队列。',
     totalCost: '总成本:',
@@ -4718,7 +4734,8 @@ export default {
     noPrintersConnected: '没有打印机连接',
     printersConnected: '{{connected}}/{{total}} 已连接',
     cloudProfiles: '云配置文件',
-    cloudProfilesDescription: '来自 Bambu Cloud 的耗材、打印机和工艺预设',
+    cloudProfilesDescription: '来自 Bambu Cloud 和 Orca Cloud 的耗材、打印机和工艺预设',
+    cloudProfilesAccounts: '已连接账户 — Bambu Cloud:{{bambu}},Orca Cloud:{{orca}}',
     appSettings: '应用设置',
     appSettingsDescription: 'Bambuddy 配置(完整数据库)',
     spoolInventory: '耗材库存',
@@ -5128,6 +5145,10 @@ export default {
       title: '强制颜色匹配',
       description: '拒绝派发到没有完全相同耗材类型和颜色的打印机。默认关闭 — 不启用时,队列仅按型号匹配,可能选到颜色错误的打印机。',
     },
+    saveAmsMapping: {
+      title: '保存 AMS 映射',
+      description: '将切片软件自身选择的 AMS 槽位(来自 project_file MQTT 命令)保存到存档中,以便之后重新打印时复用同一卷实体线材,而不是根据文件的类型/颜色重新推导。默认关闭。',
+    },
     gcodeInjection: {
       title: 'G-code 注入',
       description: '将“设置”中按型号配置的 G-code 片段应用到此 VP 的作业。默认关闭。',
@@ -5906,6 +5927,7 @@ export default {
     filteringFor: '筛选:{{material}}',
     noKProfile: '无 K 值配置(使用默认值 0.020)',
     noMatchingKProfiles: '未找到匹配的 K 值配置。将使用默认 K=0.020。',
+    otherKProfiles: '此打印机上的其他 K 值配置',
     selectFilamentFirst: '请先选择耗材配置',
     kFromCalibration: 'K={{value}}(来自打印机校准)',
     customColorLabel: '自定义颜色(可选)',
@@ -6514,8 +6536,12 @@ export default {
     description: '通过自托管的 Obico ML API 监控打印,并对检测到的故障自动采取行动。',
     mlUrl: 'Obico ML API 地址',
     mlUrlHint: '您自托管的 Obico ml_api 容器的基础 URL(例如 http://192.168.1.10:3333)。',
+    mlToken: 'ML API 令牌(可选)',
+    mlTokenPlaceholder: '如果服务器未设置 ML_API_TOKEN,请留空',
+    mlTokenHint: '必须与您的 Obico ml_api 容器的 ML_API_TOKEN 环境变量一致。如果容器未使用令牌,请留空。',
     test: '测试',
     testSuccess: 'ML API 可访问且正常。',
+    testSuccessTokenUnknown: 'ML API 可访问且正常。无法验证令牌。',
     testFailed: '无法访问 ML API。',
     sensitivity: '灵敏度',
     sensitivityLow: '低(减少误报)',

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

@@ -935,6 +935,8 @@ export default {
       uploadedBy: '上傳者',
       noPermissionReprint: '您沒有重新列印的權限',
       noFileForReprint: '無可用的 3MF 檔案 — 列印紀錄時無法從印表機下載該檔案',
+      slicerAmsMapping: '已儲存 AMS 對應({{printer}})',
+      slicerAmsMappingTooltip: '切片軟體選擇的 AMS 槽位已為 {{printer}} 儲存。槽位編號僅在該印表機上有意義,因此只有再次傳送至 {{printer}} 時,重新列印才會重複使用。',
       noPermissionEdit: '您沒有編輯歸檔的權限',
       noPermissionDelete: '您沒有刪除歸檔的權限',
       openInBambuStudio: '在切片軟體中開啟',
@@ -1144,6 +1146,10 @@ export default {
       unknown: '不明',
       printAnyway: '仍要列印',
     },
+    slicerAmsMapping: {
+      rowBadge: '已為此印表機儲存 AMS 槽位',
+      rowTooltip: '此封存保留了切片軟體選擇的確切 AMS 槽位,並為此項目的目標印表機儲存。在該印表機上重新列印時,可重複使用這些料盤,而不必再依類型與顏色重新比對。',
+    },
     title: '列印佇列',
     subtitle: '排程和管理您的列印任務',
     // Print modal
@@ -2232,6 +2238,8 @@ export default {
     slicerCard: '切片器',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: '切片器停滯逾時(分鐘)',
+    slicerStallTimeoutDescription: '若 sidecar 在此時長內沒有任何進度,則放棄本次切片。持續回報進度的複雜模型無論耗時多久都不會被中斷。不回報進度的 sidecar 則將此值作為總時長上限。',
     slicerApiUrlDescription: 'slicer-API sidecar 容器的 URL。留空以使用 SLICER_API_URL / BAMBU_STUDIO_API_URL 環境變數預設值。',
     slicerBundlesRemoved: {
       title: '切片器捆綁包(已移除)',
@@ -2303,6 +2311,7 @@ export default {
       connectionFailed: '連線失敗',
       testFailed: '測試失敗',
       cameraConnected: '攝影機已連線{{resolution}}',
+      cameraConnectedCoalesced: '攝影機已連線{{resolution}}(與進行中的擷取共用)',
     },
     testConnection: '測試連線',
     catalog: {
@@ -2443,6 +2452,8 @@ export default {
     autoArchiveDescription: '列印完成時自動儲存3MF檔案',
     saveThumbnailsDescription: '從3MF檔案中提取並儲存預覽影像',
     captureFinishPhotoDescription: '列印完成時從印表機攝影機拍照。Bambuddy 會在列印期間錄製一段短縮時攝影,以便從熱床下降前的瞬間取得照片;如果您為本次列印啟用了縮時攝影,檔案將保留,否則會在拍照完成後自動刪除。',
+    finishPhotoRestorePlate: '為完成照片抬升熱床',
+    finishPhotoRestorePlateDescription: '列印結束時印表機會將熱床下降約 100 mm,使完成的模型落在相機取景範圍之下。Bambuddy 會將熱床抬回到最後一層列印高度略上方,拍攝照片後再次下降。若列印高度未知或佇列中還有其他任務,則跳過此步驟。',
     ffmpegNotInstalled: '未安裝ffmpeg',
     ffmpegRequired: '攝影機捕獲需要ffmpeg。透過 <brew>brew install ffmpeg</brew>(macOS)或 <apt>apt install ffmpeg</apt>(Linux)安裝。',
     camera: '攝影機',
@@ -2822,6 +2833,7 @@ export default {
     title: '錯誤 - {{name}}',
     noErrors: '無錯誤',
     viewOnWiki: '在拓竹 Wiki 上檢視',
+    mqttVerifyFailedRemedy: '在印表機上啟用開發者模式,重新啟動印表機,然後重新開始該工作。',
     unknownCode: '未知 HMS 代碼 — 詳情請參閱拓竹 Wiki。',
     clearInstructions: '在印表機上清除錯誤以在此處消除它們。',
     clearErrors: '清除錯誤',
@@ -4606,6 +4618,10 @@ export default {
     selectPrinter: '選擇印表機',
     selectPlate: '選擇板',
     filamentMapping: '耗材對應',
+    useArchiveMapping: '對應',
+    useArchiveMappingTooltip: '從此封存儲存的 AMS 對應(來自切片軟體)中選取所有槽位,而不是依類型/顏色比對。',
+    clickToChangeSlot: '點擊更改槽位分配',
+    reRead: '重新讀取',
     plateN: '板 {{n}}',
     plateFilamentsUnreadable: '無法讀取所選盤的耗材資訊,因此無法進行對應。取消選取該盤即可將其餘盤加入佇列。',
     totalCost: '總成本:',
@@ -4718,7 +4734,8 @@ export default {
     noPrintersConnected: '沒有印表機連線',
     printersConnected: '{{connected}}/{{total}} 已連線',
     cloudProfiles: '雲設定檔案',
-    cloudProfilesDescription: '來自 Bambu Cloud 的耗材、印表機和工藝預設',
+    cloudProfilesDescription: '來自 Bambu Cloud 和 Orca Cloud 的耗材、印表機和工藝預設',
+    cloudProfilesAccounts: '已連線帳戶 — Bambu Cloud:{{bambu}},Orca Cloud:{{orca}}',
     appSettings: '應用程式設定',
     appSettingsDescription: 'Bambuddy 設定(完整資料庫)',
     spoolInventory: '耗材庫存',
@@ -5128,6 +5145,10 @@ export default {
       title: '強制顏色匹配',
       description: '拒絕派發到沒有完全相同耗材類型和顏色的印表機。預設關閉 — 不啟用時,佇列僅按型號匹配,可能選到顏色錯誤的印表機。',
     },
+    saveAmsMapping: {
+      title: '儲存 AMS 對應',
+      description: '將切片軟體自行選擇的 AMS 槽位(來自 project_file MQTT 指令)儲存到封存中,讓之後的重新列印能重複使用同一捲實體線材,而不是依檔案的類型/顏色重新推導。預設關閉。',
+    },
     gcodeInjection: {
       title: 'G-code 注入',
       description: '將「設定」中依型號設定的 G-code 片段套用到此 VP 的作業。預設關閉。',
@@ -5906,6 +5927,7 @@ export default {
     filteringFor: '篩選:{{material}}',
     noKProfile: '無 K 值設定(使用預設值 0.020)',
     noMatchingKProfiles: '未找到匹配的 K 值設定。將使用預設 K=0.020。',
+    otherKProfiles: '此印表機上的其他 K 值設定',
     selectFilamentFirst: '請先選擇耗材設定',
     kFromCalibration: 'K={{value}}(來自印表機校準)',
     customColorLabel: '自訂顏色(可選)',
@@ -6514,8 +6536,12 @@ export default {
     description: '透過自託管的 Obico ML API 監控列印,並對偵測到的故障自動採取行動。',
     mlUrl: 'Obico ML API 地址',
     mlUrlHint: '您自託管的 Obico ml_api 容器的基礎 URL(例如 http://192.168.1.10:3333)。',
+    mlToken: 'ML API 權杖(選填)',
+    mlTokenPlaceholder: '如果伺服器未設定 ML_API_TOKEN,請留空',
+    mlTokenHint: '必須與您的 Obico ml_api 容器的 ML_API_TOKEN 環境變數一致。如果容器未使用權杖,請留空。',
     test: '測試',
     testSuccess: 'ML API 可存取且正常。',
+    testSuccessTokenUnknown: 'ML API 可存取且正常。無法驗證權杖。',
     testFailed: '無法存取 ML API。',
     sensitivity: '靈敏度',
     sensitivityLow: '低(減少誤報)',

+ 49 - 9
frontend/src/pages/ArchivesPage.tsx

@@ -1,4 +1,4 @@
-import { useState, useRef, useEffect, useCallback } from 'react';
+import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
 import { Link, useNavigate } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
@@ -56,6 +56,7 @@ import {
   Cog,
   Archive as ArchiveIcon,
   History,
+  CheckCircle2,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { SliceModal } from '../components/SliceModal';
@@ -64,6 +65,7 @@ import { openInSlicer, type SlicerType } from '../utils/slicer';
 import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
 import { getCurrencySymbol } from '../utils/currency';
 import { getBedTypeInfo } from '../utils/bedType';
+import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
@@ -146,6 +148,7 @@ async function openInSlicerWithToken(
 function ArchiveCard({
   archive,
   printerName,
+  printerMap,
   isSelected,
   onSelect,
   selectionMode,
@@ -171,6 +174,10 @@ function ArchiveCard({
   currency: string;
   t: TFunction;
   onNavigateToArchive?: (archiveId: number) => void;
+  /** Printer id -> name, for naming the printer a saved slicer AMS mapping
+   *  belongs to. The card can't know which printer a reprint will target, so
+   *  the badge names the one the mapping is actually good for. */
+  printerMap: Map<number, string>;
 }) {
   // Debug: log when card is highlighted
   if (isHighlighted) {
@@ -182,6 +189,17 @@ function ArchiveCard({
   const { hasPermission, canModify } = useAuth();
   const isMobile = useIsMobile();
   const navigate = useNavigate();
+  // Name of the printer this archive's saved slicer AMS mapping was resolved
+  // against, or undefined when there is none. Undefined also when the printer
+  // has since been deleted — a mapping whose printer is gone can never be
+  // reused, so the badge stays off rather than naming a ghost.
+  const savedSlicerAmsMappingPrinter = useMemo(() => {
+    const saved = (archive.extra_data as Record<string, unknown> | null)?.slicer_ams_mapping as
+      | { mapping?: unknown; printer_id?: number }
+      | undefined;
+    if (!saved || !Array.isArray(saved.mapping) || saved.printer_id == null) return undefined;
+    return printerMap.get(saved.printer_id);
+  }, [archive.extra_data, printerMap]);
   const [showReprint, setShowReprint] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
   const [showRunPipeline, setShowRunPipeline] = useState(false);
@@ -359,7 +377,9 @@ function ArchiveCard({
   const deleteMutation = useMutation({
     mutationFn: (purgeStats: boolean) => api.deleteArchive(archive.id, purgeStats),
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      // A deleted archive leaves its project too, so the project views have to
+      // be refreshed alongside the archive list (#2731).
+      invalidateArchiveAndProjectViews(queryClient);
       showToast(t('archives.toast.archiveDeleted'));
     },
     onError: () => {
@@ -384,8 +404,7 @@ function ArchiveCard({
   const assignProjectMutation = useMutation({
     mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }),
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['archives'] });
-      queryClient.invalidateQueries({ queryKey: ['projects'] });
+      invalidateArchiveAndProjectViews(queryClient);
       showToast(t('archives.toast.projectUpdated'));
     },
     onError: () => {
@@ -1109,6 +1128,20 @@ function ArchiveCard({
           )}
         </div>
 
+        {/* Slicer's own saved AMS-slot pick (see "Save AMS mapping" VP setting).
+            Named with the printer it was resolved against: global tray IDs mean
+            nothing on any other printer, so a reprint only reuses these exact
+            spools when it targets that same printer. */}
+        {savedSlicerAmsMappingPrinter && (
+          <div
+            className="flex items-center gap-1.5 text-bambu-green text-xs mb-3"
+            title={t('archives.card.slicerAmsMappingTooltip', { printer: savedSlicerAmsMappingPrinter })}
+          >
+            <CheckCircle2 className="w-3.5 h-3.5" />
+            {t('archives.card.slicerAmsMapping', { printer: savedSlicerAmsMappingPrinter })}
+          </div>
+        )}
+
         {/* Tags & Notes */}
         {(archive.tags || archive.notes) && (
           <div className="flex flex-wrap items-center gap-1.5 mb-3">
@@ -1744,7 +1777,9 @@ function ArchiveListRow({
   const deleteMutation = useMutation({
     mutationFn: (purgeStats: boolean) => api.deleteArchive(archive.id, purgeStats),
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      // A deleted archive leaves its project too, so the project views have to
+      // be refreshed alongside the archive list (#2731).
+      invalidateArchiveAndProjectViews(queryClient);
       showToast(t('archives.toast.archiveDeleted'));
     },
     onError: () => {
@@ -1769,8 +1804,7 @@ function ArchiveListRow({
   const assignProjectMutation = useMutation({
     mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }),
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['archives'] });
-      queryClient.invalidateQueries({ queryKey: ['projects'] });
+      invalidateArchiveAndProjectViews(queryClient);
       showToast(t('archives.toast.projectUpdated'));
     },
     onError: () => {
@@ -2796,7 +2830,7 @@ export function ArchivesPage() {
       return ids.length;
     },
     onSuccess: (count) => {
-      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      invalidateArchiveAndProjectViews(queryClient);
       setSelectedIds(new Set());
       showToast(`${count} archive${count !== 1 ? 's' : ''} deleted`);
     },
@@ -2957,7 +2991,12 @@ export function ArchivesPage() {
     localStorage.setItem('logPageSize', logPageSize.toString());
   }, [logPageSize]);
 
-  const printerMap = new Map(printers?.map((p) => [p.id, p.name]) || []);
+  // Memoised: it's handed to every ArchiveCard as a prop, and a fresh Map each
+  // render would re-run their lookups for no reason.
+  const printerMap = useMemo(
+    () => new Map<number, string>(printers?.map((p) => [p.id, p.name]) || []),
+    [printers],
+  );
 
   // Extract unique materials and colors from archives
   const uniqueMaterials = [...new Set(
@@ -3711,6 +3750,7 @@ export function ArchivesPage() {
                 key={archive.id}
                 archive={archive}
                 printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model || 'No Printer')}
+                printerMap={printerMap}
                 isSelected={selectedIds.has(archive.id)}
                 onSelect={toggleSelect}
                 selectionMode={selectionMode}

+ 13 - 0
frontend/src/pages/QueuePage.tsx

@@ -731,6 +731,19 @@ function SortableQueueItem({
             </p>
           )}
 
+          {/* Archive carries the slicer's own live-resolved AMS-slot pick
+              (extra_data.slicer_ams_mapping) — reprints of this archive reuse
+              the exact physical spool instead of re-deriving one. */}
+          {item.archive_has_slicer_ams_mapping && (
+            <p
+              className="text-[10px] sm:text-xs text-green-700 dark:text-green-400 mt-1.5 sm:mt-2 flex items-start gap-1"
+              title={t('queue.slicerAmsMapping.rowTooltip')}
+            >
+              <Check className="w-3 h-3 mt-0.5 flex-shrink-0" />
+              <span>{t('queue.slicerAmsMapping.rowBadge')}</span>
+            </p>
+          )}
+
           {/* Error message */}
           {item.error_message && (
             <p className="text-[10px] sm:text-xs text-red-700 dark:text-red-400 mt-1.5 sm:mt-2 flex items-center gap-1">

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

@@ -959,6 +959,7 @@ export function SettingsPage() {
       settings.auto_archive !== localSettings.auto_archive ||
       settings.save_thumbnails !== localSettings.save_thumbnails ||
       settings.capture_finish_photo !== localSettings.capture_finish_photo ||
+      (settings.finish_photo_restore_plate ?? true) !== (localSettings.finish_photo_restore_plate ?? true) ||
       settings.default_filament_cost !== localSettings.default_filament_cost ||
       settings.currency !== localSettings.currency ||
       settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
@@ -1008,6 +1009,7 @@ export function SettingsPage() {
       (settings.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
       (settings.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
       (settings.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
+      (settings.slicer_stall_timeout_minutes ?? 15) !== (localSettings.slicer_stall_timeout_minutes ?? 15) ||
       (settings.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') ||
       settings.prometheus_enabled !== localSettings.prometheus_enabled ||
       settings.prometheus_token !== localSettings.prometheus_token ||
@@ -1058,6 +1060,10 @@ export function SettingsPage() {
         auto_archive: localSettings.auto_archive,
         save_thumbnails: localSettings.save_thumbnails,
         capture_finish_photo: localSettings.capture_finish_photo,
+        // #2547: `?? true` mirrors the toggle's own default, so an install
+        // whose settings payload predates this field saves what the user is
+        // actually looking at rather than `undefined`.
+        finish_photo_restore_plate: localSettings.finish_photo_restore_plate ?? true,
         default_filament_cost: localSettings.default_filament_cost,
         currency: localSettings.currency,
         energy_cost_per_kwh: localSettings.energy_cost_per_kwh,
@@ -1107,6 +1113,7 @@ export function SettingsPage() {
         open_in_slicer: localSettings.open_in_slicer,
         use_slicer_api: localSettings.use_slicer_api,
         orcaslicer_api_url: localSettings.orcaslicer_api_url,
+        slicer_stall_timeout_minutes: localSettings.slicer_stall_timeout_minutes,
         bambu_studio_api_url: localSettings.bambu_studio_api_url,
         prometheus_enabled: localSettings.prometheus_enabled,
         prometheus_token: localSettings.prometheus_token,
@@ -1164,7 +1171,15 @@ export function SettingsPage() {
       const result = await api.testExternalCamera(printerId, url, cameraType);
       setExtCameraTestResults(prev => ({ ...prev, [printerId]: result }));
       if (result.success) {
-        showToast(t('settings.toast.cameraConnected', { resolution: result.resolution || '' }), 'success');
+        // A shared capture means the frame is real but was not fetched over a
+        // connection this test opened, so say so rather than implying the
+        // camera was just reached.
+        showToast(
+          result.coalesced
+            ? t('settings.toast.cameraConnectedCoalesced', { resolution: result.resolution || '' })
+            : t('settings.toast.cameraConnected', { resolution: result.resolution || '' }),
+          'success'
+        );
       } else {
         showToast(result.error || t('settings.toast.connectionFailed'), 'error');
       }
@@ -1889,6 +1904,28 @@ export function SettingsPage() {
                   <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
                 </label>
               </div>
+              {/* #2547: only meaningful while finish photos are being taken at
+                  all, so it hangs off the toggle above rather than standing
+                  alone in the list. */}
+              {localSettings.capture_finish_photo && (
+                <div className="flex items-center justify-between pl-4 border-l-2 border-bambu-dark-tertiary">
+                  <div>
+                    <p className="text-white">{t('settings.finishPhotoRestorePlate')}</p>
+                    <p className="text-sm text-bambu-gray">
+                      {t('settings.finishPhotoRestorePlateDescription')}
+                    </p>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={localSettings.finish_photo_restore_plate ?? true}
+                      onChange={(e) => updateSetting('finish_photo_restore_plate', e.target.checked)}
+                      className="sr-only peer"
+                    />
+                    <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                  </label>
+                </div>
+              )}
               {localSettings.capture_finish_photo && ffmpegStatus && !ffmpegStatus.installed && (
                 <div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
                   <AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
@@ -4854,6 +4891,26 @@ export function SettingsPage() {
                   </p>
                 </div>
               )}
+              {(localSettings.use_slicer_api ?? false) && (
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">
+                    {t('settings.slicerStallTimeout')}
+                  </label>
+                  <input
+                    type="number"
+                    min={1}
+                    max={240}
+                    value={localSettings.slicer_stall_timeout_minutes ?? 15}
+                    onChange={(e) =>
+                      updateSetting('slicer_stall_timeout_minutes', Number(e.target.value))
+                    }
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  />
+                  <p className="text-xs text-bambu-gray mt-1">
+                    {t('settings.slicerStallTimeoutDescription')}
+                  </p>
+                </div>
+              )}
             </CardContent>
           </Card>
 

+ 39 - 0
frontend/src/utils/projectQueries.ts

@@ -0,0 +1,39 @@
+import type { QueryClient } from '@tanstack/react-query';
+
+/**
+ * Every React Query key whose data is derived from which archives belong to a
+ * project. All are prefixes: `['project']` matches `['project', 42]`, so one
+ * entry covers every project id currently in the cache.
+ */
+const PROJECT_VIEW_QUERY_KEYS = [
+  ['projects'],
+  ['project'],
+  ['project-archives'],
+  ['project-timeline'],
+  ['project-file-progress'],
+] as const;
+
+/**
+ * Refresh everything a project shows after an archive is deleted, or moved
+ * into or out of a project.
+ *
+ * The default `staleTime` is 60s, so without this a project page visited
+ * within a minute of the change serves its cached answer and keeps showing a
+ * print that is no longer there — the user has to reload by hand (#2731).
+ * Deletes previously invalidated only `['archives']`, and the project-assign
+ * mutations only `['projects']`, which refreshed the overview cards but never
+ * the detail page they were most likely looking at.
+ */
+export function invalidateProjectViews(queryClient: QueryClient) {
+  return Promise.all(
+    PROJECT_VIEW_QUERY_KEYS.map((queryKey) => queryClient.invalidateQueries({ queryKey: [...queryKey] })),
+  );
+}
+
+/** As above, plus the archive list itself — for mutations that change both. */
+export function invalidateArchiveAndProjectViews(queryClient: QueryClient) {
+  return Promise.all([
+    queryClient.invalidateQueries({ queryKey: ['archives'] }),
+    invalidateProjectViews(queryClient),
+  ]);
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-CxAiFpme.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C2LOlVCR.js"></script>
+    <script type="module" crossorigin src="/assets/index-CxAiFpme.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
   </head>
   <body>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است