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

Merge branch 'dev' into feature/save-ams-mapping-toggle

MartinNYHC 1 месяц назад
Родитель
Сommit
4b4cb18a64
63 измененных файлов с 4464 добавлено и 317 удалено
  1. 4 0
      CHANGELOG.md
  2. 2 0
      backend/app/api/routes/archives.py
  3. 13 1
      backend/app/api/routes/library.py
  4. 10 3
      backend/app/api/routes/obico.py
  5. 24 8
      backend/app/api/routes/projects.py
  6. 1 0
      backend/app/api/routes/settings.py
  7. 155 20
      backend/app/api/routes/support.py
  8. 521 33
      backend/app/main.py
  9. 35 0
      backend/app/schemas/settings.py
  10. 133 32
      backend/app/services/bambu_mqtt.py
  11. 8 2
      backend/app/services/export.py
  12. 8 1
      backend/app/services/failure_analysis.py
  13. 87 12
      backend/app/services/obico_detection.py
  14. 68 0
      backend/app/services/print_dispatch_context.py
  15. 96 2
      backend/app/services/print_scheduler.py
  16. 6 0
      backend/app/services/printer_diagnostic.py
  17. 15 0
      backend/app/services/printer_manager.py
  18. 6 1
      backend/app/services/slice_preview.py
  19. 211 50
      backend/app/services/slicer_api.py
  20. 65 14
      backend/app/utils/threemf_tools.py
  21. 92 0
      backend/tests/integration/test_archives_api.py
  22. 26 1
      backend/tests/integration/test_library_slice_api.py
  23. 203 0
      backend/tests/integration/test_projects_api.py
  24. 236 60
      backend/tests/unit/services/test_bambu_mqtt.py
  25. 77 0
      backend/tests/unit/services/test_print_dispatch_context.py
  26. 157 0
      backend/tests/unit/test_connection_watchdog.py
  27. 430 15
      backend/tests/unit/test_finish_photo_moment_sync.py
  28. 199 0
      backend/tests/unit/test_obico_detection.py
  29. 115 0
      backend/tests/unit/test_scheduler_watchdog.py
  30. 229 0
      backend/tests/unit/test_slicer_stall_timeout.py
  31. 128 6
      backend/tests/unit/test_support_helpers.py
  32. 88 0
      backend/tests/unit/test_threemf_tools.py
  33. 192 0
      frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx
  34. 97 0
      frontend/src/__tests__/components/FailureDetectionSettings.test.tsx
  35. 45 1
      frontend/src/__tests__/components/HMSErrorModal.test.tsx
  36. 78 3
      frontend/src/__tests__/components/spool-form/isMatchingCalibration.test.ts
  37. 60 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  38. 75 0
      frontend/src/__tests__/utils/projectQueries.test.ts
  39. 13 2
      frontend/src/api/client.ts
  40. 4 8
      frontend/src/components/BatchProjectModal.tsx
  41. 90 19
      frontend/src/components/ConfigureAmsSlotModal.tsx
  42. 4 2
      frontend/src/components/EditArchiveModal.tsx
  43. 29 3
      frontend/src/components/FailureDetectionSettings.tsx
  44. 43 5
      frontend/src/components/HMSErrorModal.tsx
  45. 57 5
      frontend/src/components/spool-form/utils.ts
  46. 10 0
      frontend/src/i18n/locales/de.ts
  47. 10 0
      frontend/src/i18n/locales/en.ts
  48. 10 0
      frontend/src/i18n/locales/es.ts
  49. 10 0
      frontend/src/i18n/locales/fr.ts
  50. 10 0
      frontend/src/i18n/locales/it.ts
  51. 10 0
      frontend/src/i18n/locales/ja.ts
  52. 10 0
      frontend/src/i18n/locales/ko.ts
  53. 10 0
      frontend/src/i18n/locales/pt-BR.ts
  54. 10 0
      frontend/src/i18n/locales/ru.ts
  55. 10 0
      frontend/src/i18n/locales/tr.ts
  56. 10 0
      frontend/src/i18n/locales/uk.ts
  57. 10 0
      frontend/src/i18n/locales/zh-CN.ts
  58. 10 0
      frontend/src/i18n/locales/zh-TW.ts
  59. 10 7
      frontend/src/pages/ArchivesPage.tsx
  60. 49 0
      frontend/src/pages/SettingsPage.tsx
  61. 39 0
      frontend/src/utils/projectQueries.ts
  62. 0 0
      static/assets/index-CMvWx2qm.js
  63. 1 1
      static/index.html

Разница между файлами не показана из-за своего большого размера
+ 4 - 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),
     )
 
 

+ 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 - 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,

+ 521 - 33
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 (
@@ -365,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.
@@ -2275,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)
@@ -2293,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:
@@ -2455,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):
@@ -4304,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).
 
@@ -4349,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
@@ -4359,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:
@@ -4375,22 +4604,64 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
         # exactly one rotation in `_stage22_finish_frames` either way.
         frame_already_rotated = False
 
-        # #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":
+        # 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
@@ -4447,6 +4718,7 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
                 "[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",
@@ -4454,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.
@@ -5270,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)
 
@@ -5345,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",
@@ -5371,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.
@@ -5471,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
@@ -5673,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."""
@@ -6316,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
@@ -6590,10 +7059,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).
@@ -6637,6 +7106,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)
@@ -6858,6 +7342,9 @@ 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)
@@ -6910,6 +7397,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()

+ 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)

+ 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)

+ 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

+ 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:

+ 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)
 
 

+ 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,

+ 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:

+ 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

+ 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()

+ 430 - 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"
@@ -380,6 +436,9 @@ class TestStage22CacheHoldsExactlyOneRotation:
         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
 
@@ -458,3 +517,359 @@ def test_the_consumer_does_not_rotate_the_cached_frame():
         "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 == []

+ 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."""
 

+ 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', () =>

+ 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();

+ 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);
+  });
+});

+ 13 - 2
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;
@@ -2783,6 +2789,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 {
@@ -6526,10 +6535,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

+ 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 ${

+ 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) => {

+ 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 || '';

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

@@ -2237,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)',
@@ -2452,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
@@ -2841,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',
@@ -5933,6 +5938,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)',
@@ -6541,8 +6547,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)',

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

@@ -2256,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)',
@@ -2471,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
@@ -2870,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',
@@ -5977,6 +5982,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)',
@@ -6585,8 +6591,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)',

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

@@ -2240,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)',
@@ -2455,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
@@ -2844,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',
@@ -5942,6 +5947,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)',
@@ -6550,8 +6556,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)',

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

@@ -2193,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é)',
@@ -2406,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',
@@ -2830,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',
@@ -5923,6 +5928,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)',
@@ -6531,8 +6537,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)',

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

@@ -2193,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)',
@@ -2405,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',
@@ -2829,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',
@@ -5922,6 +5927,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)',
@@ -6530,8 +6536,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)',

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

@@ -2236,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: 'スライサーバンドル(削除済み)',
@@ -2451,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
@@ -2841,6 +2845,7 @@ export default {
     title: 'エラー - {{name}}',
     noErrors: 'エラーなし',
     viewOnWiki: 'Bambu Lab Wikiで表示',
+    mqttVerifyFailedRemedy: 'プリンターで開発者モードを有効にし、プリンターを再起動してから、ジョブをもう一度開始してください。',
     unknownCode: '不明なHMSコード — 詳細はBambu Lab Wikiを参照してください。',
     clearInstructions: 'プリンターでエラーをクリアするとここからも消えます。',
     clearErrors: 'エラーをクリア',
@@ -5934,6 +5939,7 @@ export default {
     filteringFor: 'フィルター中: {{material}}',
     noKProfile: 'Kプロファイルなし(デフォルト0.020を使用)',
     noMatchingKProfiles: '一致するKプロファイルが見つかりません。デフォルトK=0.020が使用されます。',
+    otherKProfiles: 'このプリンターの他のKプロファイル',
     selectFilamentFirst: 'まずフィラメントプロファイルを選択してください',
     kFromCalibration: 'K={{value}}(プリンターキャリブレーションから)',
     customColorLabel: 'カスタム色(オプション)',
@@ -6542,8 +6548,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: '低(誤検出が少ない)',

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

@@ -2110,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: '슬라이서 번들 (제거됨)',
@@ -2321,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: '카메라',
@@ -2691,6 +2695,7 @@ export default {
     title: '오류 - {{name}}',
     noErrors: '오류 없음',
     viewOnWiki: 'Bambu Lab 위키에서 보기',
+    mqttVerifyFailedRemedy: '프린터에서 개발자 모드를 활성화하고 프린터를 재시작한 다음 작업을 다시 시작하세요.',
     unknownCode: '알 수 없는 HMS 코드 — 자세한 내용은 Bambu Lab 위키를 참조하세요.',
     clearInstructions: '오류를 해제하려면 프린터에서 오류를 지우세요.',
     clearErrors: '오류 지우기',
@@ -5624,6 +5629,7 @@ export default {
     filteringFor: '필터링 중: {{material}}',
     noKProfile: 'K 프로필 없음 (기본값 0.020 사용)',
     noMatchingKProfiles: '일치하는 K 프로필을 찾을 수 없습니다. 기본값 K=0.020이 사용됩니다.',
+    otherKProfiles: '이 프린터의 다른 K 프로필',
     selectFilamentFirst: '먼저 필라멘트 프로필을 선택하세요',
     kFromCalibration: 'K={{value}} (프린터 보정에서)',
     customColorLabel: '사용자 지정 색상 (선택사항)',
@@ -6010,8 +6016,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: '낮음 (오탐 적음)',

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

@@ -2193,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)',
@@ -2405,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',
@@ -2829,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',
@@ -5922,6 +5927,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)',
@@ -6530,8 +6536,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)',

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

@@ -2111,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: "Пакеты профилей слайсера (удалено)",
@@ -2322,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: "Камера",
@@ -2683,6 +2687,7 @@ export default {
     title: "Ошибки — {{name}}",
     noErrors: "Ошибок нет",
     viewOnWiki: "Открыть в Bambu Lab Wiki",
+    mqttVerifyFailedRemedy: "Включите режим разработчика на принтере, перезагрузите принтер и запустите задание снова.",
     unknownCode: "Неизвестный код HMS — подробности см. в Bambu Lab Wiki.",
     clearInstructions: "Устраните ошибки на принтере, чтобы они исчезли из этого списка.",
     clearErrors: "Очистить ошибки",
@@ -5611,6 +5616,7 @@ export default {
     filteringFor: "Фильтр по материалу: {{material}}",
     noKProfile: "Без K-профиля (стандартное значение 0,020)",
     noMatchingKProfiles: "Подходящие K-профили не найдены. Будет использовано стандартное значение K=0,020.",
+    otherKProfiles: "Другие K-профили на этом принтере",
     selectFilamentFirst: "Сначала выберите профиль филамента",
     kFromCalibration: "K={{value}} из калибровки принтера",
     customColorLabel: "Пользовательский цвет (необязательно)",
@@ -6169,8 +6175,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: "Низкая (меньше ложных срабатываний)",

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

@@ -2241,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ı)',
@@ -2456,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
@@ -2845,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',
@@ -5878,6 +5883,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ı)',
@@ -6481,8 +6487,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)',

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

@@ -2256,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: "Пакети профілів слайсера (вилучено)",
@@ -2471,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
@@ -2870,6 +2874,7 @@ export default {
     title: "Помилки - {{name}}",
     noErrors: "Помилок немає",
     viewOnWiki: "Переглянути на Bambu Lab Wiki",
+    mqttVerifyFailedRemedy: "Увімкніть режим розробника на принтері, перезавантажте принтер і запустіть завдання знову.",
     unknownCode: "Невідомий код HMS — подробиці дивіться у вікі Bambu Lab.",
     clearInstructions: "Усуньте помилки на принтері, щоб вони зникли тут.",
     clearErrors: "Очистити помилки",
@@ -5977,6 +5982,7 @@ export default {
     filteringFor: "Фільтрування за: {{material}}",
     noKProfile: "Немає профілю K (використовуйте значення за замовчуванням 0,020)",
     noMatchingKProfiles: "Не знайдено відповідних K профілів. Використовуватиметься K=0,020 за замовчуванням.",
+    otherKProfiles: "Інші K-профілі на цьому принтері",
     selectFilamentFirst: "Спочатку виберіть профіль філаменту",
     kFromCalibration: "K={{value}} від калібрування принтера",
     customColorLabel: "Власний колір (необов’язково)",
@@ -6585,8 +6591,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: "Низький (менше помилкових спрацьовувань)",

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

@@ -2238,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: '切片器捆绑包(已移除)',
@@ -2450,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: '摄像头',
@@ -2829,6 +2833,7 @@ export default {
     title: '错误 - {{name}}',
     noErrors: '无错误',
     viewOnWiki: '在拓竹 Wiki 上查看',
+    mqttVerifyFailedRemedy: '在打印机上启用开发者模式,重启打印机,然后重新开始该任务。',
     unknownCode: '未知 HMS 代码 — 详情请参阅拓竹 Wiki。',
     clearInstructions: '在打印机上清除错误以在此处消除它们。',
     clearErrors: '清除错误',
@@ -5921,6 +5926,7 @@ export default {
     filteringFor: '筛选:{{material}}',
     noKProfile: '无 K 值配置(使用默认值 0.020)',
     noMatchingKProfiles: '未找到匹配的 K 值配置。将使用默认 K=0.020。',
+    otherKProfiles: '此打印机上的其他 K 值配置',
     selectFilamentFirst: '请先选择耗材配置',
     kFromCalibration: 'K={{value}}(来自打印机校准)',
     customColorLabel: '自定义颜色(可选)',
@@ -6529,8 +6535,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: '低(减少误报)',

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

@@ -2238,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: '切片器捆綁包(已移除)',
@@ -2450,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: '攝影機',
@@ -2829,6 +2833,7 @@ export default {
     title: '錯誤 - {{name}}',
     noErrors: '無錯誤',
     viewOnWiki: '在拓竹 Wiki 上檢視',
+    mqttVerifyFailedRemedy: '在印表機上啟用開發者模式,重新啟動印表機,然後重新開始該工作。',
     unknownCode: '未知 HMS 代碼 — 詳情請參閱拓竹 Wiki。',
     clearInstructions: '在印表機上清除錯誤以在此處消除它們。',
     clearErrors: '清除錯誤',
@@ -5921,6 +5926,7 @@ export default {
     filteringFor: '篩選:{{material}}',
     noKProfile: '無 K 值設定(使用預設值 0.020)',
     noMatchingKProfiles: '未找到匹配的 K 值設定。將使用預設 K=0.020。',
+    otherKProfiles: '此印表機上的其他 K 值設定',
     selectFilamentFirst: '請先選擇耗材設定',
     kFromCalibration: 'K={{value}}(來自印表機校準)',
     customColorLabel: '自訂顏色(可選)',
@@ -6529,8 +6535,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: '低(減少誤報)',

+ 10 - 7
frontend/src/pages/ArchivesPage.tsx

@@ -65,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';
@@ -360,7 +361,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: () => {
@@ -385,8 +388,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: () => {
@@ -1761,7 +1763,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: () => {
@@ -1786,8 +1790,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: () => {
@@ -2813,7 +2816,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`);
     },

+ 49 - 0
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,
@@ -1897,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" />
@@ -4862,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-CMvWx2qm.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-fmZ_9rRe.js"></script>
+    <script type="module" crossorigin src="/assets/index-CMvWx2qm.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
   </head>
   <body>

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